Search the docs
1.1.0GitHub
RecipesA Public Page and Form

A Public Page and Form

On this page

Introduction

This recipe adds a guestbook to an app made from the starter kit: a public page that lists the entries a moderator approved, a form on it that any guest may send, and a page for one approved entry. Nobody signs in, so the routes say everyone and the code has to be careful instead. It protects against these mistakes:

  • A script that fills the table or guesses. The route that saves an entry carries throttle('guestbook'): five entries a minute and twenty a day per client address, then a 429. Without it marmeon check warns.
  • A binding that makes every record public. The page of one entry binds :entry with { public: true }, and that is only right because ApprovedEntries finds approved entries and nothing else. An entry that nobody approved answers 404, exactly as an id that does not exist does, so the answer tells a guest nothing about what is waiting for approval.
  • Script in a guest's text. Every value is shown as text, which React escapes. Nothing in the module sets HTML.
  • Forged forms. The routes sit in the web group, so a form sent from another site is refused before the controller runs. There is no csrfExcept.
  • Taking over a column. The request's schema lists the two fields the form may send, and the controller writes only those two. An approved_at in the form never reaches the table, so a guest can not approve their own entry.
  • Private data on a public list. The list reads id, name, message and created_at, and the page gets nothing more. The form asks for no e-mail address, and the limiter keeps the client's address only for the minute or the day it counts.

The generated code is the start; the parts that decide these things are the ones written by hand below. Approving entries belongs to an admin area and is not part of this recipe: until something sets approved_at, no entry is shown.

Generate

pnpm marmeon make:module guestbook
pnpm marmeon make:migration create_guestbook_entries_table --module=guestbook
pnpm marmeon make:controller ListGuestbookEntries --module=guestbook --request --view
pnpm marmeon make:controller StoreGuestbookEntry --module=guestbook --request
pnpm marmeon make:controller ShowGuestbookEntry --module=guestbook --view
pnpm marmeon make:test guestbook --module=guestbook --path=/guestbook

make:module writes the module and adds it to modules in bootstrap/app.ts. make:migration writes the migration with the current time in its file name, so yours has another name than the one below; put the code below into your file. make:controller writes the three controllers, with a page for the list and the entry and a request for the list and the form, and make:test the test, which stays red until the code below replaces it. Do not migrate yet: the table is still the generator's stub until you have replaced the files.

There is no generator for the repository, the binder ApprovedEntries or the service provider that defines the limiter. The sections below show them as the pages on routing and rate limiting show them.

What it writes

FilePurpose
modules/guestbook/index.tsThe module: its migrations, its provider and its routes.
modules/guestbook/routes.tsThe three routes, all for everyone; the one that saves is throttled, the one that shows an entry binds it publicly.
modules/guestbook/GuestbookServiceProvider.tsDefines the limiter guestbook: five a minute and twenty a day per client.
modules/guestbook/migrations/<timestamp>_create_guestbook_entries_table.tsThe table: a UUIDv7 key, name, message, a nullable approved_at and the timestamps.
modules/guestbook/GuestbookRepository.tsThe queries; approvedPage() reads one page of the approved entries.
modules/guestbook/ApprovedEntries.tsThe binder of :entry: an approved entry or nothing.
modules/guestbook/controllers/ListGuestbookEntriesController.tsThe approved entries, twenty to a page.
modules/guestbook/controllers/StoreGuestbookEntryController.tsSaves an entry that nobody approved and sends the guest back.
modules/guestbook/controllers/ShowGuestbookEntryController.tsOne approved entry.
modules/guestbook/views/ListGuestbookEntries.tsxThe list page, with the form and the pagination.
modules/guestbook/views/ShowGuestbookEntry.tsxThe page of one entry.
modules/guestbook/guestbook.test.tsThe tests of the module.

The code

The routes come first. The comment on authorize(everyone) says why no check is needed: nothing in the module acts on a user. The route that saves has throttle('guestbook') in front of it and takes no body over 32 KB, and the route that shows one entry says { public: true } on its binding. A larger form is refused with a 413, before any middleware when it declares its length and at the CSRF check's read when it does not; see the body.

modules/guestbook/routes.ts
import { throttle } from '@marmeon/cache';
import { defineRoutes, everyone } from '@marmeon/http';
import { ApprovedEntries } from './ApprovedEntries.ts';
import { ListGuestbookEntriesController } from './controllers/ListGuestbookEntriesController.ts';
import { ShowGuestbookEntryController } from './controllers/ShowGuestbookEntryController.ts';
import { StoreGuestbookEntryController } from './controllers/StoreGuestbookEntryController.ts';

export default defineRoutes((Route) => {
  // Guests read approved entries and leave one; nothing here acts on a user.
  const guests = Route.prefix('/guestbook').authorize(everyone).name('guestbook.');
  guests.get('/', ListGuestbookEntriesController).name('index');
  guests.middleware(throttle('guestbook')).post('/', StoreGuestbookEntryController).maxBodyBytes(32 * 1024).name('store');
  // ApprovedEntries finds approved entries only, so every visitor may read what it finds.
  guests.bind('entry', ApprovedEntries, { public: true }).get('/:entry', ShowGuestbookEntryController).name('show');
});

The limiter is defined in the module's service provider, as rate limiting describes. The limits are counted per client address, with clientKey(). A limit without .by() would be one counter for every client, and one script would lock all other guests out. Behind a proxy the address is the client's only when the proxy is in SERVER_TRUSTED_PROXIES; otherwise every guest shares one budget. See the client's address.

modules/guestbook/GuestbookServiceProvider.ts
import { clientKey, Limit, RateLimiter } from '@marmeon/cache';
import type { Application, ServiceProvider } from '@marmeon/core';

export class GuestbookServiceProvider implements ServiceProvider {
  boot(app: Application): void {
    // Five entries a minute and twenty a day per client address: enough for a person, too few for a script.
    app.container.make(RateLimiter).for('guestbook', (ctx) => {
      const key = clientKey(ctx.ip);
      return [Limit.perMinute(5).by(key), Limit.perDay(20).by(key)];
    });
  }
}

make:module writes the module's index.ts without providers, so the module's entry lists it in providers:

modules/guestbook/index.ts
import { defineModule } from '@marmeon/core';
import { GuestbookServiceProvider } from './GuestbookServiceProvider.ts';
import routes from './routes.ts';

export default defineModule({
  name: 'guestbook',
  migrations: new URL('./migrations/', import.meta.url),
  providers: [GuestbookServiceProvider],
  routes,
});

The table keeps approved_at empty until somebody approves the entry. The key is a UUID, so an address in the URL can not be guessed from the one before it.

modules/guestbook/migrations/2026_10_14_000000_create_guestbook_entries_table.ts
import { defineMigration } from '@marmeon/database';

export default defineMigration({
  up: (schema) =>
    schema.create('guestbook_entries', (table) => {
      // A UUIDv7 as the primary key: the id is in URLs, and the next one cannot be guessed.
      table.uuidId();
      table.string('name', 60);
      table.string('message', 500);
      // Empty until somebody approves the entry; only approved entries are shown.
      table.timestamp('approved_at').nullable();
      table.timestamps();
    }),
  down: (schema) => schema.drop('guestbook_entries'),
});

The repository reads the approved entries and selects only the columns the page shows.

modules/guestbook/GuestbookRepository.ts
import { Repository, type PageRequest } from '@marmeon/database';

export class GuestbookRepository extends Repository<'guestbook_entries'> {
  static table = 'guestbook_entries' as const;

  /** One page of the approved entries, the newest first. Only what the page shows. */
  approvedPage(page: PageRequest) {
    const query = this.query()
      .select(['id', 'name', 'message', 'created_at'])
      .where('approved_at', 'is not', null)
      .orderBy('created_at', 'desc')
      .orderBy('id', 'desc');
    return this.paginate(query, page);
  }
}

ApprovedEntries is the reason { public: true } is safe here. A binding that finds any record by its key and is marked public would show the whole table to anyone who tries ids. This one hands the route an entry only when approved_at is set. findByRouteKey() looks the entry up as a repository binds it, and a value that is no UUID finds nothing without a query:

modules/guestbook/ApprovedEntries.ts
import type { Row } from '@marmeon/database';
import type { RouteBinder } from '@marmeon/http';
import { GuestbookRepository } from './GuestbookRepository.ts';

/** `/guestbook/:entry` — an approved entry by its id. An entry nobody approved is a 404, like an id that does not exist. */
export class ApprovedEntries implements RouteBinder<Row<'guestbook_entries'>> {
  readonly #entries: GuestbookRepository;

  constructor(entries: GuestbookRepository) {
    this.#entries = entries;
  }

  async resolveRouteBinding(id: string) {
    const entry = await this.#entries.findByRouteKey(id);
    return entry?.approved_at ? entry : undefined;
  }
}

The request validates what the form sends, and the controller writes the two fields it names. It answers every valid form alike: it does not look at who else has written, and nothing in the answer depends on other guests.

modules/guestbook/controllers/StoreGuestbookEntryController.ts
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { rules as r } from '@marmeon/validation';
import { GuestbookRepository } from '../GuestbookRepository.ts';

export const StoreGuestbookEntryRequest = defineRequest({
  schema: r.object({
    name: r.string().trim().required().max(60),
    message: r.string().trim().required().max(500),
  }),
});

/** Saves an entry that nobody has approved yet. Every valid form gets the same answer. */
export class StoreGuestbookEntryController extends Controller {
  static request = StoreGuestbookEntryRequest;

  readonly #entries: GuestbookRepository;

  constructor(entries: GuestbookRepository) {
    super();
    this.#entries = entries;
  }

  async handle(ctx: ContextOf<typeof StoreGuestbookEntryRequest>) {
    await this.#entries.insert({ name: ctx.body.name, message: ctx.body.message });
    return this.redirect().route('guestbook.index').flash('toast', { kind: 'success', message: 'Thank you. Your entry appears once it has been approved.' });
  }
}

The list asks for a page, and the show controller passes on the four fields of the entry and nothing else:

modules/guestbook/controllers/ListGuestbookEntriesController.ts
import { pageQuery } from '@marmeon/database';
import { Controller, defineRequest, type ContextOf } from '@marmeon/http';
import { rules as r } from '@marmeon/validation';
import { GuestbookRepository } from '../GuestbookRepository.ts';

export const ListGuestbookEntriesRequest = defineRequest({
  query: r.query({ ...pageQuery({ perPage: 20 }) }),
});

/** The approved entries, twenty to a page, and the form for a new one. */
export class ListGuestbookEntriesController extends Controller {
  static request = ListGuestbookEntriesRequest;

  readonly #entries: GuestbookRepository;

  constructor(entries: GuestbookRepository) {
    super();
    this.#entries = entries;
  }

  async handle(ctx: ContextOf<typeof ListGuestbookEntriesRequest>) {
    return this.view('guestbook/ListGuestbookEntries', { entries: await this.#entries.approvedPage(ctx.query) });
  }
}
modules/guestbook/controllers/ShowGuestbookEntryController.ts
import type { Row } from '@marmeon/database';
import { Controller, type HttpContext } from '@marmeon/http';

/** One approved entry. `entry` is bound by the route: an entry nobody approved never gets here — it is a 404. */
export class ShowGuestbookEntryController extends Controller {
  handle(ctx: HttpContext<{}, { entry: Row<'guestbook_entries'> }>) {
    const { id, name, message, created_at } = ctx.params.entry;
    return this.view('guestbook/ShowGuestbookEntry', { entry: { id, name, message, created_at } });
  }
}

The pages print the guest's words with {entry.message}. The form is a <Form> of the route guestbook.store, which sends the CSRF header by itself and works without JavaScript too.

modules/guestbook/views/ListGuestbookEntries.tsx
import type { PageProps } from '@marmeon/http';
import { Form, Head, Link, Pagination } from '@marmeon/react';
import { AppLayout } from '../../auth/layouts/AppLayout.tsx';
import type { ListGuestbookEntriesController } from '../controllers/ListGuestbookEntriesController.ts';

type Props = PageProps<ListGuestbookEntriesController>;

// Every value is rendered as text: React escapes it, so a message with markup shows the markup and never runs it.
export default function ListGuestbookEntries({ entries }: Props) {
  return (
    <main className="app-main stack stack-loose">
      <Head title="Guestbook" />
      <h1>Guestbook</h1>
      <section className="card">
        <Form route="guestbook.store" className="stack">
          {(form) => (
            <>
              <div className="field">
                <label htmlFor="name">Your name</label>
                <input id="name" maxLength={60} {...form.field('name')} />
                {form.error('name') && <small role="alert">{form.error('name')}</small>}
              </div>
              <div className="field">
                <label htmlFor="message">Your message</label>
                <textarea id="message" rows={4} maxLength={500} {...form.field('message')} />
                {form.error('message') && <small role="alert">{form.error('message')}</small>}
              </div>
              <button type="submit" className="button" disabled={form.processing}>
                Sign the guestbook
              </button>
            </>
          )}
        </Form>
      </section>
      {entries.data.length === 0 && <p className="muted">Nobody has signed yet.</p>}
      <div className="stack">
        {entries.data.map((entry) => (
          <article key={entry.id} className="card">
            <h2>
              <Link route="guestbook.show" params={{ entry: entry.id }}>
                {entry.name}
              </Link>
            </h2>
            <p>{entry.message}</p>
          </article>
        ))}
      </div>
      <Pagination meta={entries.meta} labels={{ navigation: 'Pages of entries', previous: 'Previous', next: 'Next', page: (page) => `Page ${page}` }} />
    </main>
  );
}

ListGuestbookEntries.layout = AppLayout;
modules/guestbook/views/ShowGuestbookEntry.tsx
import type { PageProps } from '@marmeon/http';
import { Head, Link } from '@marmeon/react';
import { AppLayout } from '../../auth/layouts/AppLayout.tsx';
import type { ShowGuestbookEntryController } from '../controllers/ShowGuestbookEntryController.ts';

type Props = PageProps<ShowGuestbookEntryController>;

export default function ShowGuestbookEntry({ entry }: Props) {
  return (
    <main className="app-main stack stack-loose">
      <Head title={entry.name} />
      <article className="card">
        <h1>{entry.name}</h1>
        <p>{entry.message}</p>
      </article>
      <Link route="guestbook.index" className="button-secondary">
        Back to the guestbook
      </Link>
    </main>
  );
}

ShowGuestbookEntry.layout = AppLayout;

Once the code above is in place, create the table, write its columns into the app's types, and write the types of the new routes, so the pages and Link know them:

pnpm marmeon migrate
pnpm marmeon db:types
pnpm marmeon route:types

The test

Each test names one of the dangers above, and the file is the whole of it. A guest sees approved entries only. A form saves an entry that nobody approved, whatever the form says about approved_at. An entry nobody approved answers 404, like an id that does not exist and one that is no UUID. The sixth entry of a minute from one client gets a 429, and so does the twenty-first of a day, however slowly they came. A form from another site gets a 403, and a script that sends neither of the browser's headers gets a 419. A message with a <script> tag appears in the markup as text.

modules/guestbook/guestbook.test.ts
import { createTestApp, type TestApp } from '@marmeon/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import application from '../../bootstrap/app.ts';
import type { ListGuestbookEntriesController } from './controllers/ListGuestbookEntriesController.ts';
import { GuestbookRepository } from './GuestbookRepository.ts';

// What a guest may read and send, and what stops a script.

let app: TestApp;
// Every test boots the app on a refreshed database — seconds on a busy machine, so its hook and tests get 30 s.
beforeEach(async () => {
  app = await createTestApp(application, { database: 'refresh' });
}, 30_000);

const entries = () => app.make(GuestbookRepository);
const approved = () => new Date().toISOString();

describe('the guestbook', { timeout: 30_000 }, () => {
  it('shows a guest the approved entries and nothing else', async () => {
    await entries().insert({ name: 'Ada', message: 'Lovely page', approved_at: approved() });
    await entries().insert({ name: 'Spammer', message: 'Buy now', approved_at: null });

    await app.navigating().get('/guestbook').assertPage<ListGuestbookEntriesController>('guestbook/ListGuestbookEntries', (props) => {
      expect(props.entries.data.map((entry) => entry.name)).toEqual(['Ada']);
      expect(props.entries.data[0]).not.toHaveProperty('approved_at');
    });
  });

  it('saves what a guest sends as an entry nobody approved, and sends the guest back', async () => {
    await app.post('/guestbook', { form: { name: '  Ada ', message: 'Hello', approved_at: approved() } }).assertRedirect('/guestbook');
    expect(await entries().findBy('name', 'Ada')).toMatchObject({ message: 'Hello', approved_at: null });
    // The entry is not on the page until somebody approves it.
    await app.navigating().get('/guestbook').assertPage<ListGuestbookEntriesController>('guestbook/ListGuestbookEntries', (props) => {
      expect(props.entries.data).toEqual([]);
    });
  });

  it('refuses a missing or a too long name and message', async () => {
    await app.post('/guestbook', { form: { name: ' ', message: 'Hello' } }).assertRedirect().assertInvalid(['name']);
    await app.post('/guestbook', { form: { name: 'x'.repeat(61), message: 'Hello' } }).assertRedirect().assertInvalid(['name']);
    await app.post('/guestbook', { form: { name: 'Ada', message: '' } }).assertRedirect().assertInvalid(['message']);
    await app.post('/guestbook', { form: { name: 'Ada', message: 'x'.repeat(501) } }).assertRedirect().assertInvalid(['message']);
    expect(await entries().count()).toBe(0);
  });

  it('opens an approved entry to everyone and answers 404 for any other', async () => {
    const shown = await entries().insert({ name: 'Ada', message: 'Lovely page', approved_at: approved() });
    const hidden = await entries().insert({ name: 'Spammer', message: 'Buy now', approved_at: null });
    await app.navigating().get(`/guestbook/${shown.id}`).assertPage('guestbook/ShowGuestbookEntry', { entry: { id: shown.id, name: 'Ada', message: 'Lovely page', created_at: shown.created_at } });
    // An entry nobody approved answers like an id that does not exist.
    await app.get(`/guestbook/${hidden.id}`).assertNotFound();
    await app.get('/guestbook/c0a80101-0000-7000-8000-000000000000').assertNotFound();
    await app.get('/guestbook/no-such-id').assertNotFound();
  });

  it('answers the sixth entry of a minute from one client with 429', async () => {
    const send = () => app.post('/guestbook', { form: { name: 'Bot', message: 'Hello' } });
    for (let attempt = 1; attempt <= 5; attempt++) (await send()).assertRedirect('/guestbook');
    (await send()).assertStatus(429);
    expect(await entries().count()).toBe(5);
    // The next minute opens a new window.
    app.travel({ seconds: 61 });
    (await send()).assertRedirect('/guestbook');
  });

  it('answers the twenty-first entry of a day with 429, however slowly they came', async () => {
    const send = () => app.post('/guestbook', { form: { name: 'Bot', message: 'Hello' } });
    for (let attempt = 1; attempt <= 20; attempt++) {
      (await send()).assertRedirect('/guestbook');
      if (attempt % 5 === 0) app.travel({ seconds: 61 });
    }
    (await send()).assertStatus(429);
    expect(await entries().count()).toBe(20);
  });

  it('refuses a form sent from another site', async () => {
    await app.close(); // one database app per test
    const protectedApp = await createTestApp(application, { csrf: true, database: 'refresh' });
    const form = { name: 'Ada', message: 'Hello' };
    await protectedApp.post('/guestbook', { form, headers: { 'sec-fetch-site': 'cross-site' } }).assertStatus(403);
    await protectedApp.post('/guestbook', { form, headers: { origin: 'https://evil.example' } }).assertStatus(403);
    // Neither header, as a script sends it: a guest has no token, so the answer is 419.
    await protectedApp.post('/guestbook', { form }).assertStatus(419);
    await protectedApp.post('/guestbook', { form, headers: { 'sec-fetch-site': 'same-origin' } }).assertRedirect('/guestbook');
    expect(await protectedApp.make(GuestbookRepository).count()).toBe(1);
  });

  it('shows the markup of a message as text', async () => {
    await app.close(); // one database app per test
    const rendering = await createTestApp(application, { database: 'refresh', ssr: true });
    await rendering.make(GuestbookRepository).insert({ name: 'Mallory', message: '<script>alert(1)</script>', approved_at: approved() });
    await rendering
      .get('/guestbook', { headers: { accept: 'text/html' } })
      .assertOk()
      .assertServerRendered()
      .assertSee('&lt;script&gt;alert(1)&lt;/script&gt;')
      .assertDontSee('<script>alert(1)');
    await rendering.close();
  });
});

Run the module's tests:

pnpm test modules/guestbook

The last test renders the page on the server, which starts Vite and takes a moment. The tests of the others use the page object only. See HTTP tests and testing pages for the helpers.

Check

pnpm marmeon check
pnpm marmeon route:list

marmeon check finds what this recipe is built to avoid. throttle warns about a route that anyone may call to change something and nothing limits. Without throttle('guestbook') on the route that saves, it prints:

! throttle — 1 warning
  modules/guestbook/routes.ts:12  POST /guestbook (guestbook.store) can be called by anyone and nothing limits it — add `throttle('<name>')` (or `authenticate()`), or silence this with `// marmeon-check-ignore throttle: <why it needs no limit>`  (marmeon(throttle))
  docs: docs/rate-limiting.md#applying-limits-to-routes

inner-html warns when the code sets HTML from a value it does not fix; the check page describes it, and routes without a limit the other. A route that says nothing about who may call it does not boot.

route:list shows the access of each route. The list and the form say everyone, the form's route also shows throttle:guestbook among its middleware, and the route of one entry says everyone (public: entry): the one place where a binding is open to every visitor. A public: in this column that you did not expect is the thing to look at.

Where to go next