@laioutr/app-cap
v0.1.2
Published
Self-hosted proof-of-work bot protection for Laioutr actions, built on Cap
Readme
app-cap
Bot protection for Laioutr actions without a vendor account. The app makes Cap, an open-source proof-of-work check with browser instrumentation, the bot-protection provider of a storefront. Everything runs in the storefront's own Nitro server, and the browser contacts no third party.
How it works
- A visitor starts a protected action. frontend-core asks the app for a proof.
- A hidden widget fetches a challenge from
POST /api/app-cap/challenge, solves it in a web worker, runs the instrumentation, and redeems the solution atPOST /api/app-cap/redeem. - The redeem route checks the solution, claims the challenge once, and returns a signed proof token, valid for 10 minutes.
- The action request carries the token in
x-cap-token. The verifier checks the signature and the expiry, and claims the token once. - After each use the app solves the next proof in the background, so only the first protected action of a visit waits for a solve.
Nothing loads before the first protected action.
Setup
- Add the app to the project's
laioutrrcand list the protected actions inconfig.botProtection.actions, as for any bot-protection provider. The project needs@laioutr-core/frontend-core0.72.0 or later. - Make sure the project has Redis for orchestr's cache. Laioutr Cloud has it. On Vercel, the project needs
redisUrl. Without Redis, a built server rejects every protected action and logs why, because a proof could not be made single-use. The same happens when Redis refuses a claim, for example withNOPERM; the log names the reply code. - The project secret key signs the proofs. Cockpit writes a stable one into the
laioutrrc. The signing keys are also bound to the options, so a proof from an environment with weaker options, such as a test environment with the automation check off, does not pass an environment with stricter ones.
Options
| Option | Default | Effect |
| --- | --- | --- |
| blockAutomatedBrowsers | true | Reject a browser that Cap's instrumentation identifies as automated |
| difficulty | 1000000 | Expected hashes per solve, clamped to 50000–5000000 |
A value of the wrong type falls back to the default, with a warning that names the option.
Content Security Policy
Measured in Chromium on a production build, a solve needs these sources in addition to 'self':
script-src 'wasm-unsafe-eval' 'unsafe-eval'
worker-src blob:Without 'unsafe-eval' the instrumentation cannot run and every redeem fails.
Test suites
Headless Chromium fails the automation check, so Playwright suites cannot solve. Send frontend-core's
signed bypass, x-laioutr-bypass-bot-protection, from
signBotProtectionBypass(projectSecretKey, action, nowSeconds). Or switch blockAutomatedBrowsers off
in a test environment; its proofs do not pass an environment with the check on.
Limits
- A browser that cannot finish a solve cannot do a protected action, even with
whenUnavailable: 'open'. This includes a false positive of the automation check. Tor Browser and setups with screens of mixed DPI are not measured. - The automation check raises the cost for a bot. It does not stop every bot. undetected-chromedriver, nodriver and headless Firefox pass it.
- While Redis is down, replay protection is off under
whenUnavailable: 'open'. A proof redeemed during the outage is marked. While the outage lasts, the project's policy decides about it. When Redis is back, the verifier claims the proof's challenge, so one solution passes once. - The UI shows the generic action error when a solve fails. The bot-protection contract has no error class for it yet.
- Solve times are measured on an Apple M4 Pro only (p50 about 330 ms at the default difficulty).
Consent
The app adds no consent integration. Before the first protected action, nothing loads. At that action, the browser loads the widget and its WASM from the storefront's own origin, and the instrumentation script reads browser and screen properties. The customer judges the consent question for their storefront.
Development
Requires Node.js 22.12 or newer and pnpm 10 or newer. The committed .npmrc maps the Laioutr scopes to
npm.laioutr.cloud; log in once per machine with
npm login --registry https://npm.laioutr.cloud.
pnpm install
pnpm dev:prepare # copies the widget assets, stubs the module, prepares the playground
pnpm dev # playground on http://localhost:3000, page /check
pnpm test # unit tests; set REDIS_URL to include the Redis claim tests
pnpm test:e2e:build && pnpm test:e2e # two production builds and Playwright; needs redis-server
pnpm lint && pnpm test:typespnpm run assets copies the widget, both WASM files and pako's inflate from the pinned packages into
src/runtime/app/public/app-cap/<widget version>/, and the license texts into third-party-licenses/.
It refuses a widget or WASM version other than the pinned pair.
Publishing
Releases run through changesets and publish to npmjs.org with
npm trusted publishing. Add a changeset with pnpm changeset.
On main, the release workflow opens a release PR, and merging that PR publishes the new version.
The workflows need two repository secrets: NPM_LAIOUTR_TOKEN (read access to npm.laioutr.cloud) and
RELEASE_TOKEN (a fine-grained PAT with Contents and Pull requests read and write on this repository).
License
The app's own code is MIT. The package ships third-party files with their license texts in
third-party-licenses/: Cap's widget and WASM (Apache-2.0) and pako's inflate (MIT and Zlib).
capjs-core is a dependency under Apache-2.0.
