Problem
FCaptcha ships two halves of the integration in one repo:
server-node/server.js (and server-python/, server-go/) — exposes the API: /health, /api/verify, /api/score, /api/token/verify, /api/pow/challenge, /api/challenge.
client/fcaptcha.js — the 98 KB browser widget that collects behavioural signals, calls /api/challenge, solves the PoW, and returns a token.
The browser needs client/fcaptcha.js. None of the server implementations serve it. So an operator who deploys server-node is missing the widget bundle that every consumer expects to be reachable at the same origin as the API.
Why it's a problem in practice
Consumers (e.g. anti-abuse libraries that integrate FCaptcha) typically expose a single serverUrl to clients, and the browser fetches the widget from <serverUrl>/fcaptcha.js. That implicit contract — "your FCaptcha deployment serves both the API and the widget at one origin" — is what most integrators expect. Today, every operator has to either:
- Bundle
client/fcaptcha.js into their own frontend (extra build step per integrator).
- Host it as a static asset on a separate origin (CDN / nginx) and rewire the integration to point at that.
- Patch the server.
I just hit this in PanoramicRum/nimiq-simple-faucet#113 and worked around it with a one-line sed injection in our Dockerfile that adds app.use('/fcaptcha.js', express.static('/app/client/fcaptcha.js')). Verified it works end-to-end (faucet → fcaptcha widget loads inside Nimiq Pay's WebView over LAN).
Suggested fix
Implicit by default, opt-out via env.
- Server serves
/fcaptcha.js from client/fcaptcha.js automatically. Zero config. Most consumers benefit immediately, and the implicit single-origin contract gets honoured.
- An env var (
FCAPTCHA_SERVE_CLIENT=false) lets operators turn it off if they prefer to host the widget on a CDN / edge cache and serve it themselves. Rare case; opt-out keeps it simple.
Three-line change in server-node/server.js:
const path = require('path');
// ...
if (process.env.FCAPTCHA_SERVE_CLIENT !== 'false') {
app.use('/fcaptcha.js', express.static(path.join(__dirname, '..', 'client', 'fcaptcha.js')));
}
Same idea applies to server-python and server-go for parity.
Docs change
Even with this merged, INSTALLATION.md should explicitly document the layering — both the implicit single-origin default and the CDN-hosting escape hatch. Today the README and INSTALLATION.md don't say anything about how the widget bundle reaches the browser, which is why every integrator hits this.
Happy to send a PR
I can send a PR for server-node with the change above plus the INSTALLATION.md note. Wanted to file this first so you can push back on the design (FCAPTCHA_SERVE_CLIENT name, default value, opt-in vs opt-out) before I write code. Parity changes for server-python and server-go can follow as separate PRs.
Found while integrating FCaptcha into a Nimiq Pay Mini App — full integration notes at PanoramicRum/nimiq-simple-faucet#113.
Problem
FCaptcha ships two halves of the integration in one repo:
server-node/server.js(andserver-python/,server-go/) — exposes the API:/health,/api/verify,/api/score,/api/token/verify,/api/pow/challenge,/api/challenge.client/fcaptcha.js— the 98 KB browser widget that collects behavioural signals, calls/api/challenge, solves the PoW, and returns a token.The browser needs
client/fcaptcha.js. None of the server implementations serve it. So an operator who deploysserver-nodeis missing the widget bundle that every consumer expects to be reachable at the same origin as the API.Why it's a problem in practice
Consumers (e.g. anti-abuse libraries that integrate FCaptcha) typically expose a single
serverUrlto clients, and the browser fetches the widget from<serverUrl>/fcaptcha.js. That implicit contract — "your FCaptcha deployment serves both the API and the widget at one origin" — is what most integrators expect. Today, every operator has to either:client/fcaptcha.jsinto their own frontend (extra build step per integrator).I just hit this in PanoramicRum/nimiq-simple-faucet#113 and worked around it with a one-line
sedinjection in our Dockerfile that addsapp.use('/fcaptcha.js', express.static('/app/client/fcaptcha.js')). Verified it works end-to-end (faucet → fcaptcha widget loads inside Nimiq Pay's WebView over LAN).Suggested fix
Implicit by default, opt-out via env.
/fcaptcha.jsfromclient/fcaptcha.jsautomatically. Zero config. Most consumers benefit immediately, and the implicit single-origin contract gets honoured.FCAPTCHA_SERVE_CLIENT=false) lets operators turn it off if they prefer to host the widget on a CDN / edge cache and serve it themselves. Rare case; opt-out keeps it simple.Three-line change in
server-node/server.js:Same idea applies to
server-pythonandserver-gofor parity.Docs change
Even with this merged,
INSTALLATION.mdshould explicitly document the layering — both the implicit single-origin default and the CDN-hosting escape hatch. Today the README and INSTALLATION.md don't say anything about how the widget bundle reaches the browser, which is why every integrator hits this.Happy to send a PR
I can send a PR for
server-nodewith the change above plus theINSTALLATION.mdnote. Wanted to file this first so you can push back on the design (FCAPTCHA_SERVE_CLIENTname, default value, opt-in vs opt-out) before I write code. Parity changes forserver-pythonandserver-gocan follow as separate PRs.Found while integrating FCaptcha into a Nimiq Pay Mini App — full integration notes at PanoramicRum/nimiq-simple-faucet#113.