Forms in Notes
A note can declare a form right in its frontmatter. trip2g embeds the spec into the page as <script id="form-spec">, accepts submissions via GraphQL, and stores them next to the note. This is not a separate module — it is a property of the note. Everything lives in the shared database and flows through the same machinery (roles, frontmatter patches, webhooks).
Data Flow
Frontmatter RawMeta FormSpec <script id=form-spec>
form: ─────► RawMeta["form"] ─► formspec.ParseFromRawMeta ─► templateviews.Note.FormSpecJSON
│
▼
JSON in HTML
│
▼
JS client → submitForm mutation
│
▼
submitform.Resolve → form_submits + EAV
│
▼
EnqueueSendFormSubmitEmail
Key Files
| File | Role |
|---|---|
internal/formspec/spec.go |
FormSpec / FormField types, ParseFromRawMeta, ParseFormRef |
internal/templateviews/note.go |
Note.FormSpecJSON() — JSON for <script> in custom layouts |
internal/defaulttemplate/template.go |
Ctx.FormSpecJSON() — same for the default template, plus form_ref resolution via NVS |
internal/defaulttemplate/views.html |
Emits <script id="form-spec"> |
internal/case/submitform/resolve.go |
Resolve, validateFields, checkCanSubmit |
internal/graph/schema.graphqls |
submitForm, SubmitFormPayload, FormSubmitDeniedPayload, ErrorPayload, admin.formSubmits, admin.formNotes, markFormSubmitProcessed |
cmd/server/main.go |
submitform.Env implementation (GetFormSpec, IsAdmin, UserID, RequestIP, EAV-insert methods, email enqueue) |
| db migrations | form_submits, form_string_values, form_int_values, form_bool_values, form_file_values |
Frontmatter
Three shapes:
# 1. Single form
form:
can_submit: admin # guest | admin | paid_user
success_url: /thanks # redirect target after success
fields:
- { name: email, type: email, required: true }
# 2. Multiple named forms
forms:
contact: { fields: [{ name: email, type: email }] }
survey: { can_submit: admin, fields: [{ name: rating, type: int, min: 1, max: 5 }] }
# 3. Reference to a shared spec
form_ref: "[[templates/comment_form]]" # or: form_ref: templates/comment_form.md
Parsing: formspec.ParseFromRawMeta handles a single form: block and each entry of forms:. form_ref is resolved at render time through NVS (Ctx.FormSpecJSON in the default template). Submissions are always attached to the referencing note, not the spec source.
Field Types
type |
Stored in | Validators (in validateFields) |
|---|---|---|
text |
form_string_values |
required, min_length, max_length, enum: ["a","b"] |
email |
form_string_values |
required (format via net/mail.ParseAddress) |
int |
form_int_values |
required, min, max, enum: [1,2,3] |
bool |
form_bool_values |
required, enum: [true] (consent pattern) |
file |
form_file_values |
not implemented — returns ErrorResult{Message: "file_upload_not_supported"} |
EAV schema: one row per submission in form_submits, plus one row per value in a typed table. This avoids ballooning the schema for arbitrary field sets.
Authorisation — can_submit
Enforced in submitform.Resolve.checkCanSubmit (resolve.go):
switch spec.CanSubmit {
case "", formspec.CanSubmitGuest:
return nil // allow
case formspec.CanSubmitAdmin:
if env.IsAdmin(ctx) { return nil }
return &DeniedResult{Reason: DeniedAdminRequired}
case formspec.CanSubmitPaidUser:
return &DeniedResult{Reason: DeniedNotSupported} // not implemented yet
default:
return nil // forward-compatible
}
Env.IsAdmin lives on *app (cmd/server/main.go): pulls appreq.UserToken() and checks token.IsAdmin(). The token can arrive either as a cookie (trip2g_e2e=…) or as a Bearer (t2g_…); the admin role is set by personaltoken.Resolver when the token owner has a row in the admin table.
The GraphQL return is a union:
union SubmitFormOrErrorPayload =
SubmitFormPayload # { submitId: Int! }
| FormSubmitDeniedPayload # { reason: String! }
| ErrorPayload # { message, byFields[] }
Type mapping is in internal/graph/schema.resolvers.go (mutationResolver.SubmitForm).
Reading Submissions
| Query | Access | Purpose |
|---|---|---|
admin.formSubmits(notePathId) |
admin (cookie / Bearer t2g_) | List of submissions for one note with EAV values |
admin.formNotes |
admin | All notes that have at least one submission — for dashboards |
admin.unprocessedFormSubmitsCount |
admin | Counter for the admin badge (stub right now) |
markFormSubmitProcessed(submitId, comment) |
admin | Mark a submit as processed, with an optional comment |
API keys work: personaltoken.Resolver.Resolve assigns Role: "admin" when the token's owner has an admin row, so checkAdmin lets them through.
Default Template and Custom Layouts
The default template (views.html) does not actually render the form — it only emits the JSON. Rendering is the client JS's job. That JS does not currently exist in defaulttemplate.js, which means the form is not interactive out of the box — you either render it via a custom layout, or wait for the default renderer to land.
Sample custom layout: docs/_layouts/forms/example.html — builds fields from the spec, calls submitForm, handles SubmitFormPayload / FormSubmitDeniedPayload / ErrorPayload, and performs the success_url redirect.
templateviews.Note.FormSpecJSON() returns the same JSON shape as defaulttemplate.Ctx.FormSpecJSON(), minus form_ref resolution — without NVS access, custom layouts can't resolve refs yet.
Tests
internal/formspec/spec_test.go— yaml → FormSpec parsinginternal/case/submitform/resolve_test.go— validation, success,file_upload_not_supported,can_submit: adminallow/deny,paid_user→ not_implementedinternal/templateviews/note_test.go—FormSpecJSONfor empty case, single form, named mape2e/forms.spec.js— embedded<script id=form-spec>, submission via mutation, anon →admin_requiredon an admin-only form, admin → success,success_urlpresent in spec, admin read viaadmin.formSubmits
Current Spam Mitigation
can_submit: adminremoves anonymous spam entirely- The existing user-ban system covers authenticated abusers
- Cloudflare Turnstile is on by default:
submitform.ResolvecallsEnv.VerifyTurnstilewheneverspec.Turnstileis true (the default ifturnstile:is omitted). Forms that need to opt out setturnstile: false. The server returnsTurnstileRequiredPayload{siteKey}when the token is missing or rejected — the client renders the widget keyed bysiteKeyand resubmits withturnstileTokenfilled in. Locally,turnstile-secret-keyis unset so verification is a no-op; in production the key gates every submit.
Not Implemented (Roadmap)
File field — type: file
Currently returns file_upload_not_supported. What's needed:
Uploadscalar in gqlgen with multipart support on top of fasthttp/adaptor- Upload to MinIO/S3:
forms/{note_path_id}/{submit_id}/{field_name}/{filename} - Metadata written to
form_file_values(schema already exists in the design doc) - Presigned GET URLs for admin UI and API consumers
- Validators:
accept: [pdf, jpg],max_size: 5mb
Without this, forms for homework, portfolios, and job applications stay neutered — text submits work, file attachments don't.
Payment field — type: payment
Hypothetical type: the field asks not for a value but for proof of payment. Flow:
- Before submitting, the client kicks off
createPaymentLink(already in the schema) withnoteVersionIdplus the amount from the spec - After a successful payment the provider webhook (Stripe / CloudPayments / Boosty / Patreon — we already integrate all four) tags the purchase with "form N paid"
submitFormchecks the payment and only then accepts the submission- The payload returns
submitId+purchaseId
This unlocks paid leads, paid comments, and paid newsletter signups without a separate backend.
Agent handling — submission webhooks
Largely composable from existing pieces: webhooks + submitForm. What still needs explicit wiring:
- A new event for
change_webhooks: "new submission" (alongside the current on_create/on_update/on_remove for notes) - Webhook payload: spec + field values + link to the note
- A HAT token (
docs/dev/hat.md) scoped to the note so the agent can edit it, reply to the submitter, or create a new page in the response
can_submit: paid_user
Currently returns not_implemented. What's needed: Env.HasPaidAccess(ctx, notePathID) bool, backed by ListActiveSubgraphAccessesByUserID or a direct purchase check. Full integration with the paywall (docs/dev/monetization.md).
Captcha — other providers
Cloudflare Turnstile is wired up and on by default (see "Current Spam Mitigation"). The remaining roadmap item is pluggable providers for cases where Turnstile is a bad fit:
- hCaptcha, Yandex SmartCaptcha (the last one is required for RU users where Turnstile is unreliable)
- Provider selection in admin config (site key + secret + provider id)
- Rename the spec key from
turnstile: boolto a provider-neutralcaptcha: bool— the provider isn't chosen in frontmatter - The spec JSON includes
captcha: { provider, site_key }so the client knows which widget to render - The server validates the token with the matching provider based on the admin config
Without captcha, can_submit: guest is unsafe in production. With captcha (already on by default), it is the only sane defence against anonymous spam.
Built-in JS for forms and comments
defaulttemplate.js does not render the form today — it only emits the JSON. A default client renderer would make forms work out of the box, no custom layout required. What it should do:
- Render a form from the spec — fields, validation,
submitFormcall, union handling (SubmitFormPayload/FormSubmitDeniedPayload/ErrorPayload),success_urlredirect. - List comments — for forms marked public (
can_read: guest, v2). A feed of existing submissions appears under the form: name, date, body, status (visible/pending/hidden). It would read from a not-yet-implemented publicformSubmitsquery (today onlyadmin.formSubmitsexists). - Threading — if the spec has a
parent_id: intfield, the JS nests replies. Submissions with aparent_idshow indented under the parent. - Admin moderation — when the viewer is an admin, each submission gets
approve / hide / pin / mark processedcontrols. - Captcha widget — rendered above the submit button based on
spec.captcha.
The same JS is useful as a utility for custom layouts: you can pull just the feed component (for read-only pages) or just the form renderer.
Why This Matters — Agent-Driven Micro SaaS
When file + payment + webhook land, a closed loop emerges:
- Frontend — a regular note with a form. SEO, sharing, any custom layout.
- Auth + paywall — already there.
- Submission storage — already there, typed EAV.
- Payment — once the payment field exists, payment attaches to a submission.
- Trigger — the webhook fires to an agent with a HAT token.
- Agent logic — the agent replies to the user, writes a personal note via
pushNotes, or grants access to a private folder via a subgraph. - Delivery — the user gets an email with a link to their personal note, or open access to the right subgraph.
The site owner can ship a landing page with payment intake, lead processing, and access delivery — with no separate backend. Note is the landing. Form is intake. Webhook is orchestration. Agent is business logic. trip2g is the runtime.
Roadmap
forms variable in Jet templates
Plan: .omc/plans/forms-api-and-varmap.md
forms becomes a first-class template variable alongside note and nvs:
{{ forms.Get("/leads").Count() }}
{{ range i, s := forms.Get("/leads").Submits() }}
{{ s.Field("email") }} — {{ s.CreatedAt() }}
{{ end }}
Enables read-only dashboards built entirely in Jet — no separate backend, no admin UI.
Access control: page-level gating is sufficient (if you can see the page, you see results).
userToken injection deferred — add when per-field admin-only visibility is needed.
Also: BuildVarMap(TemplateVars) unifies the 4 scattered vars[""] = reflect.ValueOf(...) sites.
Admin overlay
Floating button on every page for admins → overlay panel with form results for the current note.
Implemented as a mol component ($trip2g_form_admin_overlay).
Triggered by note.FormSpecJSON() != "" — only shown when the note has a form.
Data fetched lazily via GraphQL when overlay opens.
SQL queries in templates
db.Query("SELECT ...") as a Jet template variable — read-only, template-scoped:
{{ range i, row := db.Query("SELECT field_value FROM form_string_values WHERE ...") }}
{{ row.Get("field_value") }}
{{ end }}
Enables arbitrary analytics directly on a page. Since only admins edit templates, SQL injection risk is bounded to the template author.
Similar to Datasette / Metabase but without a separate tool — the note IS the dashboard.
Implementation: DBQuerier interface injected via TemplateVars, read-only SQLite connection only.
External SQL dashboard (alternative)
If built-in SQL feels too heavy: Datasette can serve the SQLite DB read-only with zero config. Suitable for internal analytics without custom template work.
Known Gaps
form_refis not resolved in custom layouts (templateviews.Note.FormSpecJSON) — only in the default template. Custom layouts that need a ref must inline the spec, or wait for aNVS.FormSpecJSON(note)helper.unprocessedFormSubmitsCountis a stub — it panics.- No rate-limiting on guest forms. Not an issue with
can_submit: admin, but mandatory oncepaid_useris enabled. - The
commentfield onmarkFormSubmitProcessedis in the schema but not exposed in the admin UI.