Title: Challenge-Gated Creation Description: 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 Tags: datatypes, access, permissions, anonymous, altcha, developer --- 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. read ignores requireChallenge (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--challenge: e.g. x-altcha-challenge: . 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--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. ALTCHAHMACKEY (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 ALTCHAHMACKEY to 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).