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..012cd756f --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/+layout.ts @@ -0,0 +1,38 @@ +import { getKycStatus } from '$lib/utils/wave/kyc.js'; +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'); + + // A checkpoint is a Sumsub applicant *action*, which needs an approved KYC to + // hang off — the API refuses to start one otherwise, so this flow would just + // dead-end. Identity verification is the actual next step for those users, + // and passing it satisfies the checkpoint anyway. + // + // `backTo` is dropped on purpose: wherever they were headed is gated on the + // check they can't take yet, so sending them back would bounce them right + // back here — and for a rejected KYC, `kyc-required` redirects to `backTo`, + // which would make that an endless loop. + if (!checkpoint.satisfied) { + const kycStatus = await getKycStatus(fetch); + const kycApproved = + kycStatus.status === 'applicantReviewed' && kycStatus.reviewAnswer === 'GREEN'; + + if (!kycApproved) { + throw redirect(302, `/wave/kyc-required?backTo=${encodeURIComponent('/wave')}`); + } + } + + 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..b25cbcff4 --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte @@ -0,0 +1,61 @@ + + + + {#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. If your device does not have a webcam, + come back here from a mobile device or a laptop with a camera to complete the check. + + {/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..62d788036 --- /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..14ac7da2d --- /dev/null +++ b/src/routes/(pages)/wave/(flows)/checkpoint/verify/+page.svelte @@ -0,0 +1,223 @@ + + +{#if phase === 'checking'} + +
+ +
+
+{/if} + +
+ {#if verdictSlow} +
+ + This is taking longer than usual. If the check is asking you to try again, follow the + prompts — otherwise hang tight, we're still waiting on the result. + +
+ {/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, + }; +}; diff --git a/src/routes/(pages)/wave/(flows)/shared/flow-step-wrapper.svelte b/src/routes/(pages)/wave/(flows)/shared/flow-step-wrapper.svelte index 80f4226a8..7f46f9304 100644 --- a/src/routes/(pages)/wave/(flows)/shared/flow-step-wrapper.svelte +++ b/src/routes/(pages)/wave/(flows)/shared/flow-step-wrapper.svelte @@ -14,6 +14,12 @@ leftActions?: import('svelte').Snippet; actions?: import('svelte').Snippet; confetti?: boolean; + /** + * Centres the whole step vertically instead of anchoring the header to the + * top. For steps that are only a header and one element, where the default + * "header top, actions bottom" spread leaves a hole in the middle. + */ + centered?: boolean; } let { @@ -21,13 +27,14 @@ description = undefined, icon = undefined, confetti = false, + centered = false, children, leftActions, actions, }: Props = $props(); -
+
{#if confetti} @@ -94,6 +101,10 @@ flex: 1; } + .step-layout.centered { + justify-content: center; + } + .top { display: flex; flex-direction: column;