Skip to content

Public forms

A public form creates a record of a resource for anyone, signed in or not: a contact form, a quote request, a sign-up for an event. .form(...) on the resource’s view declares it, and the anonymous role grants create of its fields. Visitors never read the records back, and the form is the only way an anonymous caller creates.

config/contact_requests.ts
import { f, resource, view } from '@protobase/schema'
// A shop's contact form: anyone, signed in or not, sends a request at /-/forms/contact; staff answer them in the app.
/** contact_requests */
export const contactRequests = resource('contact_requests')
.table('contact_requests')
.fields({
id: f.integer().readOnly().filterable().sortable(),
name: f.text().max(100),
email: f.text().max(200),
topic: f.enum(['order', 'repair', 'other']).default('other'),
message: f.text().max(4000),
handled: f.boolean().default(false).filterable(),
created_at: f.timestamp().readOnly().sortable(),
})
.primaryKey((r) => r.id)
.validate((request) => (request.email.includes('@') ? undefined : [{ field: 'email', message: 'Enter an email address' }]))
// contact_requests.created: a job emails the shop about each one (see notify.ts)
.events((e) => [e.created()])
export const contactRequestsView = view<typeof contactRequests>('contact_requests')
.names({ singular: 'Contact request', plural: 'Contact requests' })
.fields((r) => ({ name: r.name.label('Your name'), topic: r.topic.valueLabels({ order: 'An order', repair: 'A repair', other: 'Something else' }), message: r.message.multiline() }))
.list((r) => ({ columns: [r.created_at, r.name, r.topic, r.handled], sort: [[r.created_at, 'desc']] }))
.form((r) => ({
name: 'contact',
title: 'Contact us',
fields: [r.name, r.email, r.topic, r.message],
thanks: 'Thanks, we will reply within a day.',
}))
config/roles.ts
import { defineRoles } from '@protobase/schema'
export const roles = defineRoles<'contact_requests'>({
admin: ['*'],
// Visitors create a contact request through the form, with these fields only, and never read one back
anonymous: [{ 'contact_requests.create': ['name', 'email', 'topic', 'message'] }],
})

.form({ name, fields, thanks, title?, perMinute? }) takes:

name Its address, /-/forms/<name>: lowercase letters, digits, - and _, unique across the app.
fields The fields a visitor fills in, in order.
thanks What the page shows once the form is sent.
title The page’s heading; the view’s singular name by default.
perMinute How many submissions one client may send per minute; 5 by default.

Each call adds a form, so a view can have several.

/-/forms/<name> in the admin app shows the form’s title over a card with its fields, labelled, with their help and checks as the view gives them, and a Send button. It needs no sign-in and opens the same for someone who is signed in; link to it from your website. On Send the browser solves the bot check, about a second of work, then sends the form; the card then shows the thanks text. A field the server refuses shows its problem under the field, and any other refusal, such as too many submissions, shows above the button with what was typed kept.

A form takes plain fields: text, numbers, booleans, dates, enums, currencies and countries. The server refuses to start on a form with:

  • a relation, since a visitor cannot read the records to pick one;
  • a file field, since a visitor cannot upload;
  • a sensitive, read-only or generated field;
  • a field the anonymous role’s <resource>.create does not list; the error names the entry to add;
  • a field the resource does not have, or two forms of the same name;
  • a field every create must give left out, since no submission could then be saved.

A submission is a create like any other, made by the anonymous role and limited to the form’s fields: the resource’s .validate() rules, record rules and write hooks run, and events, webhooks and history see it. History names its maker Visitor.

There is no mail built into forms: the record’s created event enqueues a job, and the job sends the mail.

config/notify.ts
import { defineJob, FatalJobError, subscription } from '@protobase/server'
import { z } from 'zod'
// Email me on a new submission: the record's event enqueues a job, and the job sends the mail.
export const emailContactRequest = defineJob({
name: 'email-contact-request',
payload: z.object({ id: z.number() }),
run: async ({ id }, { db, mail }) => {
if (!mail) throw new FatalJobError('Mail is off: set PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM')
const request = await db.selectFrom('contact_requests').select(['name', 'email', 'message']).where('id', '=', id).executeTakeFirst()
if (!request) return
await mail.send({ to: '[email protected]', subject: `Contact request from ${request.name}`, text: `${request.email} wrote:\n\n${request.message}` })
},
})
export const newContactRequests = subscription('newContactRequests', {
events: ['contact_requests.created'],
run: async (event, { db }) => {
await emailContactRequest.enqueue(db, { id: Number(event.data.key) }, { key: `contact-request:${event.data.key}` })
},
})

Each client may send a form perMinute times a minute, 5 by default, as many as it may try to sign in. Past that, the form answers 429 with Retry-After. A client is known by its address in X-Forwarded-For, read as the sign-in limit reads it: a header with one address, an IPv6 address by its /64. Requests without one, or with several addresses, as a proxy makes when it appends to a header the client sent, share a single limit. The count is kept in each server process’s memory, so with several instances each counts on its own, and a restart starts over.

Every submission carries a solved proof-of-work challenge (ALTCHA): the browser fetches a fresh one when the visitor presses Send and counts SHA-256 hashes until it finds the one the challenge asks for, about a second on a current laptop. There is no third-party service, cookie or visible puzzle, and it works offline. Sending many forms costs a bot that second each time, on top of the rate limit.

A challenge is made for one form, expires after 5 minutes and verifies once. The server signs challenges with PROTOBASE_FORMS_SECRET, or BETTER_AUTH_SECRET when it is not set, and does not start with a form and neither variable, so give every instance of an app the same one. The browser solves the check with Web Crypto, which needs https or localhost.

The form’s page reads and sends it through three routes beside the API, which need no token:

Route
GET /api/forms/<name> { name, title?, fields, thanks, resource, view }: the resource restricted to the form’s fields and the view’s hints for them, as /api/meta gives them; 404 for a form that does not exist.
GET /api/forms/<name>:challenge A fresh bot check.
POST /api/forms/<name> { values, proof: { challenge, solution } } creates the record: 204, or the 400 with field errors a create answers; 400 bot-check-failed for a missing, wrong, expired or used check; 429 too-many-submissions.

From code, @protobase/client has form(name), formChallenge(name) and submitForm(name, values, proof); solve the challenge with solveChallenge from altcha-lib, as the page does.