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

datatypesaccesspermissionsanonymousaltchadeveloper

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-<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_KEY 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).