Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/lib/utils/wave/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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;

Expand Down Expand Up @@ -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');
}
Expand Down
47 changes: 47 additions & 0 deletions src/lib/utils/wave/liveness.ts
Original file line number Diff line number Diff line change
@@ -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<typeof livenessCheckpointStatusSchema>;

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 }),
}),
);
}
43 changes: 43 additions & 0 deletions src/routes/(pages)/wave/(base-layout)/rewards/+layout.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
9 changes: 9 additions & 0 deletions src/routes/(pages)/wave/(flows)/checkpoint/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script lang="ts">
import HeadMeta from '$lib/components/head-meta/head-meta.svelte';

let { children } = $props();
</script>

<HeadMeta title="Quick checkpoint | Wave" />

{@render children?.()}
18 changes: 18 additions & 0 deletions src/routes/(pages)/wave/(flows)/checkpoint/+layout.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
68 changes: 68 additions & 0 deletions src/routes/(pages)/wave/(flows)/checkpoint/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script lang="ts">
import AnnotationBox from '$lib/components/annotation-box/annotation-box.svelte';
import Button from '$lib/components/button/button.svelte';
import ArrowRight from '$lib/components/icons/ArrowRight.svelte';
import ArrowBoxUpRight from '$lib/components/icons/ArrowBoxUpRight.svelte';
import LockAndKeyEmoji from '$lib/components/icons/🔐.svelte';
import FlowStepWrapper from '../shared/flow-step-wrapper.svelte';

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(
previousAttemptFailed
? "That check didn't go through. Take a new selfie in good light with nothing covering your face."
: "Before you continue, we need to verify it's really you with a quick selfie.",
);
</script>

<FlowStepWrapper icon={LockAndKeyEmoji} headline="Quick checkpoint" {description}>
{#if checkpoint.locked}
<AnnotationBox type="warning">
<span class="typo-text-small-bold">You've run out of identity checks for now.</span>
Please get in touch with support and we'll help you sort it out.

{#snippet actions()}
<Button href="https://docs.drips.network/wave/contributors/faq" target="_blank">
Contact support
</Button>
{/snippet}
</AnnotationBox>
{:else}
<AnnotationBox type="info">
It usually takes less than a minute, and you'll be able to withdraw your rewards immediately
afterwards.

{#snippet actions()}
<Button
href="https://docs.drips.network/wave/contributors/solving-issues-and-earning-rewards#verifying-your-identity"
target="_blank"
icon={ArrowBoxUpRight}
>
Learn more
</Button>
{/snippet}
</AnnotationBox>
{/if}

{#snippet leftActions()}
<Button variant="ghost" href="/wave">Back to Wave</Button>
{/snippet}

{#snippet actions()}
{#if !checkpoint.locked}
<Button
variant="primary"
icon={ArrowRight}
href={`/wave/checkpoint/verify?backTo=${encodeURIComponent(backTo)}`}
>
{previousAttemptFailed ? 'Try again' : 'Start check'}
</Button>
{/if}
{/snippet}
</FlowStepWrapper>
19 changes: 19 additions & 0 deletions src/routes/(pages)/wave/(flows)/checkpoint/+page.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
18 changes: 18 additions & 0 deletions src/routes/(pages)/wave/(flows)/checkpoint/success/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script lang="ts">
import Button from '$lib/components/button/button.svelte';
import ArrowRight from '$lib/components/icons/ArrowRight.svelte';
import FlowStepWrapper from '../../shared/flow-step-wrapper.svelte';

let { data } = $props();
let { backTo } = $derived(data);
</script>

<FlowStepWrapper
confetti
headline="You're all set"
description="Thanks — that's you verified. Your rewards are unlocked and you can withdraw right away."
>
{#snippet actions()}
<Button variant="primary" icon={ArrowRight} href={backTo}>Continue</Button>
{/snippet}
</FlowStepWrapper>
18 changes: 18 additions & 0 deletions src/routes/(pages)/wave/(flows)/checkpoint/success/+page.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading
Loading