Challenge-Gated Creation
Gate anonymous (or any) record creation behind a proof-of-work / CAPTCHA challenge via creationAccess.requireChallenge — the generic, config-driven replacement for bespoke spam-protected form endpoints
Any datatype write access operation can require a challenge (proof-of-work / CAPTCHA) before the operation runs. This is how public, anonymous forms (contact, sign-ups, registrations) are protected from bots — by configuration, not a bespoke endpoint per form.
requireChallenge on an access operation
AccessOperationMembers (used by creationAccess and the RLS create / update / delete operations) accepts an optional requireChallenge:
{
"creationAccess": {
"permission": "anonymous", // who may create
"requireChallenge": "altcha" // …but only with a solved challenge
}
}
- The only challenge type today is
altcha(self-hosted, GDPR-friendly proof-of-work). The set is extensible via a server-side registry, so new types can be added without touching the generic create/update/delete code. readignoresrequireChallenge(reads also flow through sync; gating them is nonsensical).- The field is optional — existing datatypes omit it and behave exactly as before. No migration is needed to adopt it.
Set it in the backoffice datatype access editor: a requireChallenge select appears on creationAccess and on RLS read/update/delete.
The header contract
The solved challenge travels as a request header — never as a field on the entity:
x-<challengeType>-challenge: <solution>
e.g. x-altcha-challenge: <base64 solution>. On a gated write the server verifies this header after the access check; a missing or failing solution → 403, and nothing is mutated.
Endpoints
| Method & path | Auth | Purpose |
|---|---|---|
GET /api/projects/:projectId/challenges/:challengeType |
public | Issue a fresh, solvable challenge (404 for an unknown type). |
POST /api/projects/:projectId/types/:typeId/data |
public | Generic create. Access is decided by the type's creationAccess; the requireChallenge precondition is enforced here. Anonymous forms POST the raw entity JSON with the x-<type>-challenge header. |
Building a public form
Use DatatypeCreateFormComponent (client): give it a typeId + projectId and it renders the datatype's generated Input form, fetches + renders the challenge widget directly above the submit button when the operation requires one, blocks submit until solved, and POSTs to the create route with the header. No per-form code.
Downstream processing (notify sales, create a CRM lead) is a workflow on the datatype (onCreate document trigger → actions), not endpoint code.
ALTCHA_HMAC_KEY (required in production)
ALTCHA challenges are stateless — signed with an HMAC key at issue and re-verified from the signature, so every pod must share the same key.
- Non-production: falls back to a fixed insecure dev key, so local dev + E2E work with no configuration.
- Production: set
ALTCHA_HMAC_KEYto a strong random secret (e.g.openssl rand -hex 32). If it is unset in production, issuance throws and verification fails closed (every gated submission is rejected).