Skip to content

feat: audio accessibility alternative - #3123

Merged
HughParry merged 100 commits into
mainfrom
feat/audio-captcha
Oct 7, 2026
Merged

HughParry merged 100 commits into
mainfrom
feat/audio-captcha

Conversation

@HughParry

@HughParry HughParry commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Adds an audio challenge, offered only as an accessibility alternative (like reCAPTCHA's audio option). The user hears a short sequence of synthesised spoken digits and types them in.

Stacked on #3165 (icon order). Until that merges, this diff includes it.

What it does

  • Off by default. It only shows if the site sets audioAccessibilityEnabled. Then image, puzzle and icon-order challenges show a "Use audio instead" button, and pressing it swaps in the audio challenge. A wrong answer keeps the user on audio with a fresh clip.
  • Never a selectable type. A site's captchaType, traffic-filter categories, Restrict rules and the site-key CLI all reject audio; routing, PoW escalation and severity tiers leave it out. The provider only serves audio against the visual session the user already has, and refuses a request without a session.
  • The answer never leaves the provider, and a challenge can be submitted and verified only once.
  • New packages: @prosopo/audio-assets synthesises the speech (no recorded clips to collect), and @prosopo/procaptcha-audio is the widget.

Built on the shared code

  • Audio uses the same server handlers, verify route, submit/verify pipeline and database methods as puzzle and icon-order.
  • Its widget and the "Use audio instead" button use the shared plumbing in @prosopo/procaptcha-common.
  • Bugs fixed along the way: audio solves were skipped by the per-email limit, and the audio event array wasn't bounded.

Tests

  • Unit tests for synthesis, grading, the audio manager and widget, the button on all three visual widgets, frictionless switching to and staying on audio, the accessibility gate and request limits.
  • Integration tests for the single-use claims and the per-email limit.
  • A Cypress e2e spec that presses "Use audio instead", reads the answer and solves, plus a wrong-answer case.

Known gap

After a PoW challenge escalates to image or puzzle, the audio button isn't offered. The /frictionless response for a PoW session doesn't carry the flag. Fixing it needs a small design decision, so it's a follow-up.

Adds `CaptchaType.iconOrder`: a frame of procedurally generated icons with a
legend naming which of them to click, and in what order.

The answer never leaves the provider. Icon positions and the required order
are written to the challenge record before the response is sent; the widget
receives a composited frame and a legend strip and nothing else, so there is
no coordinate for a client to echo back. Grading is strict on order and uses
a hit radius that scales with each icon's own size.

New packages:
  - @prosopo/icon-order-assets — glyph vocabulary, placement, compositing and
    the ordered legend. Reuses puzzle-assets' PRNG, background generator and
    encoders, and shares its pre-generated background buffer at runtime.
  - @prosopo/procaptcha-icon-order — the widget: click capture, numbered
    markers, reset/confirm.

Also extracts InteractiveCaptchaManager, the server-verify pipeline both
interactive types now share (replay and recency checks, client-session
correlation, access policies, spam rules, traffic filter, IP validation and
the decision machine). PuzzleCaptchaManager moves onto it — 996 lines down to
484 — with its existing suite unchanged and green.

Ranking places icon-order above puzzle and below image wherever captcha types
are ordered by harshness. Coercion only reaches for icon-order when
icon-order was asked for: the puzzle and image fallbacks are untouched, so no
existing site's behaviour changes.
…odule commit

The lockfile was regenerated while the nested fingerprintjs submodule was
checked out at a commit other than the one this repo records, which pinned
@prosopo/config@3.3.1 against a submodule that asks for 3.3.3 and made
`npm ci` refuse the tree.
…ages

lint:refs requires each tsconfig's `references` to mirror its package.json
workspace dependencies.
`name` and `email` come straight off the form, so interpolating them into
markup lets anything typed into those fields run as HTML. CodeQL flags it as
js/xss-through-dom.
The icon-order demo pages existed but pointed at the puzzle sitekey, and
nothing seeded a sitekey configured for the new type.

  - `getDefaultSiteKeys` seeds one, so `npm run setup` registers
    `DEV_PHRASE//iconOrder` with `captchaType: iconOrder` and writes
    `PROSOPO_SITE_KEY_ICONORDER` into the env files. It is ordered before
    `puzzle` deliberately: `updateDemoHTMLFiles` rewrites the sitekey in every
    demo HTML file once per seeded type, so appending would have silently
    repointed the android/ios webview demos from puzzle to icon-order.
  - Both demo pages, the vite `define` block and the env templates use the new
    key, and the injected code sample now names the variable that actually
    exists.
  - The nav splits camelCase type names, so the entry reads "Icon Order".

Also fixes the /frictionless short-circuit, which threw
"Unhandled configured captchaType" for an icon-order sitekey: every widget
enters through /frictionless, so a configured icon-order site could not
obtain a session at all. Adds the missing `sendIconOrderCaptcha` alongside
the image/pow/puzzle helpers.
…env files

`updateEnvFiles` only replaced variables that were already present, so the
site key for a newly added captcha type never reached a developer's existing
`.env`: `copyEnvFile` seeds from the template only when the file is absent, so
any machine set up before the type existed kept a file without the variable.
The demo page then rendered with an undefined site key and the widget fell
through to the wrong captcha type.

A variable named like a site key is now written whenever the file already
tracks site keys at all. Files that track none are still left untouched, so
this doesn't scatter keys into unrelated env files.
…radient

A smooth mesh gradient was the wrong frame for this captcha. It has almost no
edges of its own, so every icon stroke was the strongest local signal in the
picture and a single edge-detection pass found all of them.

The frame is now a collage: colour panels blocking it into regions,
concentric ripple families, and heavy opaque bars slicing across. Panel
lightness is drawn from dark / near-white / mid bands and hues jump around the
wheel rather than staying analogous, so neighbouring regions actually contrast
— which is the boundary an icon stroke can hide against. Post-rasterisation
grain raises the noise floor those edges have to clear. `haloOpacity` goes up
to keep icons findable against it.

`backgroundClutter` scales every element family at once, so one operator knob
moves the frame from clean to busy; 0 renders a plain single-colour frame as
the escape hatch.

The collage is vector work rasterised natively, cheap enough to draw per
request, so icon-order no longer borrows the puzzle type's pre-generated
background buffer — that buffer exists only because the puzzle's mesh gradient
is per-pixel JS. Dropping it also means every frame is unique without a buffer
having to guarantee consume-once.
@HughParry
HughParry force-pushed the feat/header-restriction branch from 9f2b6ad to df08a0c Compare September 2, 2026 11:12
Base automatically changed from feat/header-restriction to main September 3, 2026 11:04
Conflict resolutions of note:

- CaptchaType / ClientApiPaths / server.ts docs: both sides added members;
  kept both (iconOrder and authenticated).
- captcha-severity: main extracted the per-type severity table that this
  branch had extended in blacklistRequestInspector and checkTrafficFilter.
  Dropped both local tables in favour of the shared package and added the
  iconOrder tier there (image > iconOrder > puzzle > pow > frictionless),
  so icon-order policies keep their ranking instead of falling to 0.
- provider tsconfig{,.cjs}.json: union of both reference lists. Also fixed
  the icon-order-assets entry in the cjs config, which pointed at the ESM
  tsconfig.
- getIconOrderCaptchaChallenge: main added a required requestHeaders
  argument to getPrioritisedAccessPolicies; the icon-order handler now
  passes normalizeHeadersForMatching(req.headers) like its siblings.
- package.json: took main's dependency versions and re-added the two new
  workspace deps. The two new packages pinned pre-merge sibling versions
  (@prosopo/puzzle-assets 0.1.3, @prosopo/api 4.1.3, vitest 4.1.10, ...),
  which resolved them off the registry instead of the workspace; realigned
  to the post-merge versions.
- package-lock.json: reconciled in place from main's lockfile rather than
  regenerated, so every platform's optional deps survive.
…kage

- `verifyIconOrderCaptchaSolution` claimed the challenge's single submission
  by reading `userSubmitted` off the record it had just fetched. Concurrent
  submitters all read the same unclaimed record, so each was graded and
  handed a verdict. `claimIconOrderCaptchaSubmission` does it under a
  `userSubmitted: { $ne: true }` filter instead, so exactly one wins.
- Lower the `iconOrderTolerance` ceiling from 20 to 12, the smallest value
  that still lets the Cypress specs make any click on the frame count. The
  arithmetic is written onto the field schema so it is not raised casually.
- Drop `private: true` from `@prosopo/icon-order-assets`: the published
  `@prosopo/provider` depends on it at runtime.
- Document the icon-order flow and the shared `InteractiveCaptchaManager`
  base in architecture.mmd, which still described only puzzle.
Release v3.8.8 bumped every workspace package while this branch was open.
Took main's package.json versions and re-added the two deps this branch
introduces (`@prosopo/procaptcha-icon-order` on procaptcha-frictionless,
`@prosopo/icon-order-assets` on provider), then realigned the two new
packages' own `@prosopo/*` pins to the released versions so npm links the
workspace copies rather than resolving them from the registry.

package-lock.json reconciled in place from main's, not regenerated.
The frame bound both onClick and onTouchEnd. A tap on a touch device fires
touchend followed by a synthesised compatibility click at the same
coordinates, so each tap appended two points to the answer. Three correct
taps submitted six clicks against three targets and gradeClicks rejected
them on length before comparing a single position — icon-order could not be
solved on any phone.

Staging records confirm it: every mobile submission holds six clicks in
near-identical pairs, each pair within its target's hit radius.

Switch to onPointerUp/onPointerMove, which cover mouse, touch and pen and
fire once per interaction. touch-action: none was already set on the frame
and does not suppress the synthesised click.

The regression test dispatches the full tap sequence — pointerup, touchend,
then the synthesised click — and asserts one marker.
Comment thread demos/client-bundle-example/src/audio-explicit.html Fixed
@forgetso
forgetso marked this pull request as ready for review September 11, 2026 11:55
HughParry and others added 3 commits September 11, 2026 13:08
Adds `CaptchaType.audio`: a challenge that speaks five digits and asks the
user to type them. It is directly selectable and routable like image, pow
and puzzle, and doubles as the accessibility path — a per-site
`audioAccessibilityEnabled` flag adds a "use audio instead" control to the
image and puzzle widgets.

Speech is synthesised procedurally rather than recorded or generated by a
TTS dependency. `@prosopo/audio-assets` implements a source-filter
(Klatt-style) formant synthesiser: glottal pulse train or band-limited
noise, three interpolated formants, lip radiation. A fixed corpus is a
finite set, and anything finite can eventually be collected; a continuous
parameter space has no such set.

The transcript never leaves the provider. `GetAudioCaptchaResponse` has no
field it could be assigned to, which is the structural half of the
guarantee; the puzzle captcha shipped its target coordinates to the client
once, and any caller could echo them back and pass without rendering
anything.

Challenges are single-use, so a wrong answer issues a fresh challenge
rather than reoffering the spent one. Grading is exact match after
non-digits are stripped, so "1 2 3 4 5" and "12345" both pass; there is no
edit-distance tolerance.

The render defaults are tuned for intelligibility rather than difficulty,
and the surrounding signals do the gatekeeping. Difficulty work is expected
to come from varying what the challenge asks rather than from distorting
the audio further, so the challenge task is a per-challenge parameter from
the start.

Also fixes a pre-existing bug found on the way: `settings.puzzle` was read
by the provider and offered in the portal but had no path in the mongoose
schema, so every operator override was silently discarded on save. Both
`puzzle` and `audio` now have paths, and the persistence test asserts each
field round-trips.

Scope is English digits only. Letters are excluded because several of the
letter names are too easily confused, and the 32 locale files carry the
English string as a placeholder. Tracked on the issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebases the audio work onto main, which has moved on considerably: the
captcha-type harshness tables the branch edited have been extracted into
`@prosopo/captcha-severity`, `downgradePuzzleIfUnavailable` is now
`coerceToEnabledCaptchaType`, main has landed its own (better-bounded)
mongoose paths for the puzzle render settings, and `CaptchaType` has gained
`authenticated`. Audio is folded into each of those rather than restating
them, and `coerceToEnabledCaptchaType` gains an audio case: the synthesiser
has no native dependency, so unlike puzzle rendering it cannot be
unavailable on a provider that has the code.

Brings audio to parity with the other types on client-session correlation.
It was the only type that did not bind a solve to the session id the site
rendered the widget with, so a token earned in one session could be
verified against another. The widget now records the id, the provider
compares it at verify time, and both the API client and the dapp-server
dispatch pass it through.

Adds the missing tests. `@prosopo/procaptcha-audio` shipped with none at
all, which also made `npm run test:all` fail outright, and the provider's
audio manager had none either. 159 tests now cover the widget, the player,
the manager and the provider-side issue / grade / verify path.

Two defects those tests found, in the player:

- the clip swap on a retry never paused the outgoing audio, because React
  has already committed the new `src` by the time the effect runs, so the
  `src !== clip` guard was always false. The user heard the end of the
  challenge they had just failed over the new one.
- a replay was only counted when playback had advanced far enough to move
  `currentTime`, so pressing play twice in quick succession reported zero
  replays. The count now tracks presses, which is what it is documented to
  mean.

Also aligns the audio widget's `execute()` wiring with the other widgets,
which gained container-targeted dispatch: without it a bound button could
not drive a visible audio widget.

Trims the stats and citations out of the comments and the commit message.
This is a public repository, and the arithmetic on guess probabilities and
the pointers to published attack results were doing more for an attacker
than for a maintainer. The engineering rationale stays; the numbers go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`audio-explicit.html` wrote the name and email straight into `innerHTML`,
which CodeQL flags as a high-severity `js/xss-through-dom`. The three
sibling audio pages already use `textContent`; this one was copied from an
older demo that did not. Same output, whitespace preserved with CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
forgetso and others added 4 commits September 11, 2026 13:17
`cypress.image.config.js` has no `specPattern`, so it runs every spec that
is not explicitly excluded, and `audio.cy.ts` was never added to that list.
The image step therefore ran the audio spec with the image sitekey, the
image demo page, and without the `audioAnswer` cy.task that only the audio
config registers — so it failed before reaching any audio behaviour, and
took the whole cypress job down with it before the audio step could run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vite's `%VAR%` HTML substitution and `import.meta.env.X` both read the env
object that `define` populates, and `PROSOPO_SITE_KEY_AUDIO` was never added
there. Every audio demo page therefore sent the literal string
`%PROSOPO_SITE_KEY_AUDIO%` to the provider as its sitekey, which answered
API.INVALID_SITE_KEY — so the widget never got as far as asking for an audio
challenge and the cypress spec timed out waiting for one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The provider consumes a frictionless session when it issues a challenge, so
the widget's retry — `manager.start()` on the same session — could only ever
answer NO_SESSION_FOUND. A user who mistyped a digit got a 400 instead of
another clip.

Uses the reload delegation the image widget already has: the manager asks
the wrapper to mint a fresh session and re-mount against it, rather than
restarting frictionless itself (which drops the user back to an unticked
checkbox) or refetching against a spent one. A direct-React consumer, which
has no wrapper to ask, keeps the old behaviour.

Also adds `audio` to the demo dapp server's derived-keypair loop. It walks
the captcha types to find the key a token was minted under, and without an
audio entry every audio token was rejected as "site key does not match the
server configuration" before it reached the verify call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dispose the manager on destroy so expiry timers and late answers cannot
reach a dead widget, show checkbox errors in the widget's language, and
await render() on the explicit demo pages as main's pages now do.
…and trim comments

Move the code icon-order copied from puzzle into procaptcha-common: the
lazy mount wrapper, manager expiry/dispose lifecycle, spent-session guard,
behavioural-data encryption and trusted click coordinates. Puzzle and
icon-order now both use them. Frictionless mounts the reloadable solvers
from one table. Ignore non-primary mouse buttons on the icon-order frame,
fix frictionless's CJS project reference, and cut the comments down to
the ones that explain a non-obvious why.
…end, fix three icon-order gaps

Puzzle and icon-order now share one challenge handler, one solution
handler, one verify route builder, one submit pipeline in
InteractiveCaptchaManager and one set of database record methods; the
icon-order files are thin specs over them. Fixes found on the way:
icon-order event arrays were unbounded, icon-order solves were not
counted by the per-email submission limit, and routing-machine
icon-order overrides were never written to the session. Comments cut to
the ones that explain a non-obvious why.
…end on the shared interactive code

Audio now sits on the shared interactive-captcha backend: thin challenge,
solution and verify specs, a database descriptor, the shared API client
helpers, and AudioCaptchaChallenge built from RenderedAudioClip. The
"offers audio" check used by both frictionless paths is one helper.

Fixes:
- verified audio solves were left out of the per-email submission count
  (covered by countCommitmentsByNormalisedEmail.integration.test.ts)
- audioEvents was validated item by item before its length cap, so a huge
  array produced one error per item (covered by requestArrayLimits.test.ts)

Also: audio appended after icon-order in saveCaptchas so icon-order keeps
its position, unused audio request-body aliases removed, the maintenance
audio builder moved off the puzzle builder's comment, comment-only edits to
powTasks/captchaTypeSelection reverted, audio-assets dead code and unused
exports removed, tests that duplicated the shared pipeline or the grader
dropped, a server dispatch test for audio tokens added, and comments cut to
the ones that explain a non-obvious why.
…et on the shared plumbing

Move the audio widget onto the helpers the icon-order review put in
procaptcha-common: lazyMount for the lazy loader, createManagerLifecycle,
createSpentSessionGuard and encryptBehavioralDataForSubmit in the manager,
trustedClickCoords and PROCAPTCHA_EXECUTE_EVENT in the widget, with the same
reportError/returnToCheckbox/beginChallenge helpers. Its test harnesses now
match icon-order's (defaultProcaptchaState, typed deferred<T>()).

"Use audio instead": the image component and the canvas footer share one
slot that shows or hides the button, and the button takes a Theme rather
than a light/dark string the footer had to derive. Frictionless keeps the
user's audio choice as one flag until a re-mint has no visual challenge to
swap, instead of re-arming a one-shot flag in every recovery path, and
mounts audio through the same branch as the other reloadable solvers.

The audio demo pages are rebuilt from the icon-order pages (dead
onLoggedIn handler, bogus fetch option and narration gone), the cypress
spec shares one helper to reach audio, and comments across the slice are
cut to the ones that explain a non-obvious why.
@HughParry HughParry changed the title DO NOT MERGE - NOT READY FOR REVIEW: audio accessibility alternative feat: audio accessibility alternative Oct 1, 2026
# Conflicts:
#	package-lock.json
#	packages/procaptcha-frictionless/package.json
#	packages/provider/package.json
#	packages/provider/src/tasks/puzzleCaptcha/puzzleTasks.ts
# Conflicts:
#	packages/provider/src/api/captcha/getPuzzleCaptchaChallenge.ts
#	packages/types-database/src/types/provider.ts
#	packages/types/src/provider/database.ts
# Conflicts:
#	packages/types/src/decisionMachine/index.ts
Icon-order is now switched on per site by Prosopo through
captchaTypeFeatureFlags.iconOrder, which defaults to false.
frictionlessTypes.iconOrder becomes an ordinary owner preference that
defaults to true, like image and puzzle, and resolveAllowedCaptchaTypes
allows icon-order only when both agree. A site pinned to icon-order is
held to the flag alone.

The icon-order challenge endpoint's own opt-in check is gone:
isValidRequest's feature-flag check already refuses icon-order, with or
without a session, on a site without the flag.
Audio is now served only when the site owner has audioAccessibilityEnabled
on AND Prosopo has switched on captchaTypeFeatureFlags.audio, which
defaults to false. isAudioAlternativeEnabled holds that rule and feeds
both the /frictionless "audio alternative available" advert and
isValidRequest, which now refuses audio up front, before the visual
session it trades is spent. That replaces the audio endpoint's own
isEnabled check. Audio stays out of frictionlessTypes, so it is still
never selectable or routable.
# Conflicts:
#	packages/provider/src/api/verify.ts
# Conflicts:
#	packages/procaptcha-frictionless/src/procaptchaFrictionless.ts
# Conflicts:
#	packages/procaptcha-frictionless/src/procaptchaFrictionless.ts
@HughParry
HughParry merged commit b299a91 into main Oct 7, 2026
21 checks passed
@HughParry
HughParry deleted the feat/audio-captcha branch October 7, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants