Skip to content
Closed
53 changes: 53 additions & 0 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,56 @@ export default function Sidebar({
))}
</Box>

{/* FIVUCSAS suite bar — cross-site nav to landing + sister tools */}
<Box sx={{ px: 1.5, pt: 1.5, pb: 0 }}>
<Typography
variant="caption"
sx={{
display: 'block',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontSize: '0.62rem',
fontWeight: 700,
color: 'text.secondary',
mb: 0.75,
px: 0.5,
}}
>
{t('sidebar.suite', 'FIVUCSAS suite')}
</Typography>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
'& a': {
fontSize: '0.72rem',
fontWeight: 500,
color: 'text.secondary',
textDecoration: 'none',
padding: '4px 8px',
borderRadius: '6px',
border: `1px solid ${alpha('#6366f1', isDark ? 0.18 : 0.12)}`,
transition: 'background-color .15s, color .15s',
},
'& a:hover': {
color: 'text.primary',
backgroundColor: alpha('#6366f1', isDark ? 0.12 : 0.06),
},
}}
>
<a href="https://fivucsas.com/">{t('sidebar.suiteHome', 'Home')}</a>
<a href="https://demo.fivucsas.com/">{t('sidebar.suiteDemo', 'Demo')}</a>
<a href="https://verify.fivucsas.com/">{t('sidebar.suiteWidget', 'Widget')}</a>
<a href="https://amispoof.fivucsas.com/">amispoof</a>
</Box>
</Box>

{/* Footer status tile */}
<Box sx={{ p: 1.5 }}>
<Box
component="a"
href="https://status.fivucsas.com/"
sx={{
p: 1.5,
borderRadius: '12px',
Expand All @@ -316,6 +363,12 @@ export default function Sidebar({
display: 'flex',
alignItems: 'center',
gap: 1,
textDecoration: 'none',
color: 'inherit',
transition: 'background-color .15s, border-color .15s',
'&:hover': {
borderColor: alpha('#6366f1', isDark ? 0.4 : 0.3),
},
}}
>
<Box
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Tests for the face/hand → server-action mappers (Bug 4, 2026-05-12).
*
* The mapper translates web-side enums (ChallengeType, BiometricPuzzleId)
* into the lower_snake_case strings expected by the biometric-processor
* /liveness/verify-challenge endpoint. Mismatches mean the server rejects
* 100% of the challenge type, so we pin the mapping for both happy paths
* and the deliberate "no server counterpart" returns.
*/
import { describe, expect, it } from 'vitest'

import { ChallengeType } from '@/lib/biometric-engine/types'
import { BiometricPuzzleId } from '../BiometricPuzzleId'
import {
faceChallengeToServerAction,
handPuzzleToServerAction,
} from '../puzzleServerAction'

describe('faceChallengeToServerAction', () => {
it.each([
[ChallengeType.BLINK, 'blink'],
[ChallengeType.SMILE, 'smile'],
[ChallengeType.TURN_LEFT, 'turn_left'],
[ChallengeType.TURN_RIGHT, 'turn_right'],
[ChallengeType.OPEN_MOUTH, 'open_mouth'],
[ChallengeType.RAISE_BOTH_BROWS, 'raise_eyebrows'],
])('maps %s → %s', (input, expected) => {
expect(faceChallengeToServerAction(input)).toBe(expected)
})

it.each([
ChallengeType.CLOSE_LEFT,
ChallengeType.CLOSE_RIGHT,
ChallengeType.LOOK_UP,
ChallengeType.LOOK_DOWN,
ChallengeType.RAISE_LEFT_BROW,
ChallengeType.RAISE_RIGHT_BROW,
ChallengeType.NOD,
ChallengeType.SHAKE_HEAD,
])('returns null for unmapped variant %s', (input) => {
expect(faceChallengeToServerAction(input)).toBeNull()
})
})

describe('handPuzzleToServerAction', () => {
it.each([
[BiometricPuzzleId.HAND_FINGER_COUNT, 'finger_count'],
[BiometricPuzzleId.HAND_WAVE, 'wave'],
[BiometricPuzzleId.HAND_FLIP, 'hand_flip'],
[BiometricPuzzleId.HAND_FINGER_TAP, 'finger_tap'],
[BiometricPuzzleId.HAND_PINCH, 'pinch'],
[BiometricPuzzleId.HAND_PEEK_A_BOO, 'peek_a_boo'],
[BiometricPuzzleId.HAND_SHAPE_TRACE, 'shape_trace'],
[BiometricPuzzleId.HAND_MATH, 'math'],
])('maps %s → %s', (input, expected) => {
expect(handPuzzleToServerAction(input)).toBe(expected)
})

it('returns null for HAND_TRACE_TEMPLATE (client-only variant)', () => {
expect(
handPuzzleToServerAction(BiometricPuzzleId.HAND_TRACE_TEMPLATE),
).toBeNull()
})

it('returns null for face puzzles passed by accident', () => {
// Caller bug guard: a face-modality id must not silently round-trip
// a hand action — the mapper is one-modality-per-function.
expect(
handPuzzleToServerAction(BiometricPuzzleId.FACE_BLINK as unknown as BiometricPuzzleId),
).toBeNull()
})
})
59 changes: 59 additions & 0 deletions src/features/biometric-puzzles/puzzleServerAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Mapping from the web client's local enums to the biometric-processor
* ``ChallengeType`` string-values accepted by ``/liveness/verify-challenge``.
*
* The web side uses two distinct enums:
* - ``ChallengeType`` in ``biometric-engine/types`` for FACE puzzles
* (UPPERCASE, e.g. ``BLINK``, ``TURN_LEFT``).
* - ``BiometricPuzzleId`` for the public training registry (FACE_* and
* HAND_* prefixed identifiers).
*
* The server enum is a flat lower_snake_case set in
* ``biometric-processor/app/api/schemas/active_liveness.py``. Some local
* variants (CLOSE_LEFT, CLOSE_RIGHT, LOOK_UP, LOOK_DOWN, RAISE_LEFT_BROW,
* RAISE_RIGHT_BROW, NOD, SHAKE_HEAD, HAND_TRACE_TEMPLATE) don't have a
* 1:1 server counterpart — those rows return ``null`` here, which the
* caller hook treats as "skip server validation".
*/
import { ChallengeType } from '@/lib/biometric-engine/types'
import { BiometricPuzzleId } from './BiometricPuzzleId'
import type { PuzzleServerAction } from './useBiometricPuzzleServer'

/** Face ChallengeType → server ChallengeType action string. */
const FACE_CHALLENGE_TO_SERVER: Partial<Record<ChallengeType, PuzzleServerAction>> = {
[ChallengeType.BLINK]: 'blink',
[ChallengeType.SMILE]: 'smile',
[ChallengeType.TURN_LEFT]: 'turn_left',
[ChallengeType.TURN_RIGHT]: 'turn_right',
[ChallengeType.OPEN_MOUTH]: 'open_mouth',
[ChallengeType.RAISE_BOTH_BROWS]: 'raise_eyebrows',
// CLOSE_LEFT, CLOSE_RIGHT, LOOK_UP, LOOK_DOWN, RAISE_LEFT_BROW,
// RAISE_RIGHT_BROW, NOD, SHAKE_HEAD: no 1:1 server enum — skip.
}

/** Hand BiometricPuzzleId → server ChallengeType action string. */
const HAND_PUZZLE_TO_SERVER: Partial<Record<BiometricPuzzleId, PuzzleServerAction>> = {
[BiometricPuzzleId.HAND_FINGER_COUNT]: 'finger_count',
[BiometricPuzzleId.HAND_WAVE]: 'wave',
[BiometricPuzzleId.HAND_FLIP]: 'hand_flip',
[BiometricPuzzleId.HAND_FINGER_TAP]: 'finger_tap',
[BiometricPuzzleId.HAND_PINCH]: 'pinch',
[BiometricPuzzleId.HAND_PEEK_A_BOO]: 'peek_a_boo',
[BiometricPuzzleId.HAND_SHAPE_TRACE]: 'shape_trace',
[BiometricPuzzleId.HAND_MATH]: 'math',
// HAND_TRACE_TEMPLATE: client-only variant of SHAPE_TRACE — skip.
}

/** Resolve a face ChallengeType to its server action, or null if unmapped. */
export function faceChallengeToServerAction(
ct: ChallengeType,
): PuzzleServerAction | null {
return FACE_CHALLENGE_TO_SERVER[ct] ?? null
}

/** Resolve a hand BiometricPuzzleId to its server action, or null if unmapped. */
export function handPuzzleToServerAction(
id: BiometricPuzzleId,
): PuzzleServerAction | null {
return HAND_PUZZLE_TO_SERVER[id] ?? null
}
66 changes: 64 additions & 2 deletions src/features/biometric-puzzles/puzzles/FacePuzzle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
} from '@/lib/biometric-engine/core/BiometricPuzzle'
import { ChallengeType } from '@/lib/biometric-engine/types'
import type { BiometricPuzzleProps } from '../biometricPuzzleRegistry'
import { useBiometricPuzzleServer } from '../useBiometricPuzzleServer'
import { faceChallengeToServerAction } from '../puzzleServerAction'

// Lazily import DrawingUtils + FaceLandmarker statics from MediaPipe so the
// puzzle bundle can render the 468-point mesh + named contours over the
Expand Down Expand Up @@ -86,12 +88,19 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) {
const { engine, isReady, isLoading, error: engineError } = useBiometricEngine()
const puzzleEngineRef = useRef<BiometricPuzzleEngine | null>(null)

// Bug 4 (2026-05-12) — server validation client. We post the completion to
// /biometric/puzzles/verify-challenge before resolving onSuccess. See
// useBiometricPuzzleServer.ts for the graceful-degradation behavior when
// the proxy endpoint isn't deployed yet.
const { verifyChallenge } = useBiometricPuzzleServer()

const [cameraActive, setCameraActive] = useState(false)
const [videoReady, setVideoReady] = useState(false)
const [cameraError, setCameraError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [detected, setDetected] = useState(false)
const [running, setRunning] = useState(false)
const [serverVerifying, setServerVerifying] = useState(false)

// Build the per-challenge puzzle instance once the engine is ready.
useEffect(() => {
Expand Down Expand Up @@ -299,7 +308,48 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) {
completedRef.current = true
setRunning(false)
setProgress(100)
onSuccess()

// Bug 4 (2026-05-12) — round-trip the completion through
// the server before resolving onSuccess. The mapper
// returns null for face variants without a 1:1 server
// enum (CLOSE_LEFT/RIGHT, LOOK_UP/DOWN, individual
// brow raises, NOD, SHAKE_HEAD) — in that case the
// local verdict is final because the server can't
// express the same challenge today.
const serverAction = faceChallengeToServerAction(challengeType)
if (!serverAction) {
onSuccess()
return
}
const endTs = performance.now()
setServerVerifying(true)
verifyChallenge(
{
action: serverAction,
startTimestampMs: startTsRef.current,
endTimestampMs: endTs,
confidence: result.detected ? 0.9 : 0.5,
metrics: { progress: result.progress },
},
t,
)
.then((outcome) => {
setServerVerifying(false)
if (outcome.kind === 'success' || outcome.kind === 'soft_pass') {
onSuccess()
} else {
onError(outcome.message)
}
})
.catch(() => {
setServerVerifying(false)
onError(
t('biometricPuzzle.serverError', {
defaultValue:
'Server validation failed. Please try again.',
}),
)
})
return
}
} else {
Expand All @@ -319,7 +369,7 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) {
animFrameRef.current = 0
}
}
}, [engine, isReady, cameraActive, videoReady, challengeType, onSuccess, onError, t, drawFaceLandmarks, clearOverlay, detected])
}, [engine, isReady, cameraActive, videoReady, challengeType, onSuccess, onError, t, drawFaceLandmarks, clearOverlay, detected, verifyChallenge])

const hint = t(`${i18nKey}.hint`, { defaultValue: '' })

Expand Down Expand Up @@ -468,6 +518,18 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) {
</Box>
)}

{serverVerifying && (
<Alert
severity="info"
variant="outlined"
sx={{ width: '100%', borderRadius: '12px', fontWeight: 500 }}
>
{t('biometricPuzzle.verifyingServer', {
defaultValue: 'Verifying with server…',
})}
</Alert>
)}

{running && (
<Stack spacing={1} sx={{ width: '100%' }}>
<Stack
Expand Down
Loading
Loading