# Architecture

> The parts that run in one Worker and one D1 database, and how one contact submission is handled.

This page shows the parts of goodnight-web and follows one contact submission from the request to the work done after the response. The website, the contact API, and the admin screen run in one Worker with Static Assets and one D1 database.

Three constraints shape the design:

- One Worker and one D1 database. No Queues, Cron Triggers, or KV.
- Cloudflare and GitHub are the only required services.
- Fail closed. When a setting or secret is missing, the feature that needs it is turned off.


## The parts

| Part                         | Role                                                                                                                                                                                                                          |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Worker                       | One Worker per website. It serves the static pages through Static Assets and runs the API and the admin screen.                                                                                                               |
| Static Assets                | The Astro build (`/`, `/blog/`, `/contact/`, and so on). Cloudflare serves these files without running Worker code.                                                                                                           |
| Hono app                     | The web framework that runs inside the Worker, created by `createGoodnightWorker(options)` from `@goodnight-web/server`. Only `/api/*`, `/admin`, and `/admin/*` reach it, through `run_worker_first`.                        |
| D1                           | One SQLite database for inquiries, settings, and the Better Auth tables. The migrations ship in the npm package.                                                                                                              |
| Turnstile                    | Cloudflare's bot check, used on the contact form. The Worker verifies each token on the server side.                                                                                                                          |
| Rate Limiting binding        | A Workers binding that limits contact submissions, setup attempts, and passkey sign-ins per `ip_hash` (the hashed IP address). It works on the free plan.                                                                     |
| Better Auth with passkeys    | Admin sign-in with passkeys only. Email and password sign-in is disabled.                                                                                                                                                     |
| Admin screen                 | A React app (TanStack Router, Form, and Table) built with Vite. It ships prebuilt in `@goodnight-web/server` and is served by the Worker.                                                                                     |
| Slack webhook (optional)     | Sends a short message when a new inquiry arrives. It contains no personal data.                                                                                                                                               |
| Cloudflare Access (optional) | An extra sign-in layer in front of `/admin`, `/admin/*`, `/api/admin/*`, `/api/setup`, and `/api/auth/*`. Recommended, not required. With `ACCESS_AUD` and `ACCESS_TEAM_DOMAIN` set, the Worker also checks the Access token. |
| Cloudflare WAF (optional)    | Rules you set in Cloudflare that filter requests to your domain before they reach the Worker. goodnight-web works the same without it.                                                                                        |

## Routes

| Path                             | Used by          | What it does                                                                                                                                                                                                                                       |
| -------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/`, `/blog/*`, `/contact/`, ... | Visitors         | Static pages. No Worker code runs.                                                                                                                                                                                                                 |
| `GET /api/contact/form`          | The contact page | Returns the current form fields and whether the form is available. Public and read-only.                                                                                                                                                           |
| `POST /api/contact`              | The contact form | Checks a submission, applies the rate limit, and saves it.                                                                                                                                                                                         |
| `/api/setup`                     | The first admin  | Creates the first admin. Closes once a passkey is registered.                                                                                                                                                                                      |
| `/api/auth/*`                    | Admins           | Better Auth: passkey registration and sign-in.                                                                                                                                                                                                     |
| `/api/admin/*`                   | The admin screen | Listing, viewing, and deleting inquiries, marking them unread/read or suspected spam, CSV export, and the form editor with its versions. Also a status endpoint that reports missing contact form settings and whether Slack notifications are on. |
| `/admin`, `/admin/*`             | Admins           | The prebuilt admin screen (a React app). Every data request needs a session.                                                                                                                                                                       |

The inquiry list in the admin screen shows 50 inquiries per page, sorted by received time. The list API also returns the unread and suspected spam counts, and you can search it by email address.

## A contact submission, step by step

`POST /api/contact` runs these steps in this order:

1. **Origin and size.** The request must come from the website's own origin, use the expected `Content-Type`, and be at most 32 KB. Anything else is rejected before the body is parsed.
2. **Honeypot.** The form has a field that people cannot see, `gnw_hp_x`. The Worker notes whether it is filled in and continues. A filled honeypot never changes the response. It only flags the submission in step 5.
3. **Turnstile.** The Worker sends the token to the Turnstile siteverify API and checks `success`, `hostname`, and `action`. If the check fails, the Worker returns 400 and saves nothing.
4. **Rate limit.** The Rate Limiting binding counts requests per `ip_hash`. Over the limit, the Worker returns 429.
5. **Validation and spam score.**
   - zod checks the email address and consent.
   - The form fields are checked with a zod schema built from the current form definition in D1, never from a definition the browser sent. Unknown fields and unknown keys in the request body are rejected.
   - If the visitor's page showed an older version of the form, the Worker returns 409 `form_outdated`.
   - Simple rules add to a spam score. A score of 3 or more, or a filled honeypot, flags the submission as suspected spam. A flagged submission is still saved, with the status `unread`.
6. **Save, then respond.** If 500 or more inquiries were stored in the last hour (all senders together), the Worker returns 503 `contact_busy` and stores nothing. Otherwise the row is inserted into D1. The visitor gets a 200 response only after the insert succeeds. Nothing after this step can change the response.
7. **After the response (in `ctx.waitUntil`).**
   - If the submission is not suspected spam and a Slack webhook is set, the Worker posts a notice with the site name, the page path, the received time, and the admin URL, and records `notified_at`.
   - If the last cleanup was more than an hour ago, the Worker deletes inquiries older than the retention period (180 days by default), expired passkey challenges, and expired sessions.

The contact API fails closed. In any of these cases it returns 503, and the form says "The contact form is not available yet":

- `TURNSTILE_SECRET` or `TURNSTILE_SITE_KEY` is not set.
- `APP_SECRET` is missing, shorter than 32 characters, or clearly not random, and `IP_HASH_SECRET` is not set either. A set `IP_HASH_SECRET` that is too short or clearly not random also turns it off.
- The Rate Limiting binding is missing.

To set them up, see [Set up Turnstile](https://goodnight-web.dev/docs/setup/turnstile.md).

## In the code

The website's Worker is three lines. It only passes the default language to the factory function from `@goodnight-web/server`:

```ts title="templates/starter/src/worker.ts"
import { createGoodnightWorker } from '@goodnight-web/server';
import { siteConfig } from '../site.config';

export default createGoodnightWorker({ locale: siteConfig.defaultLocale });
```

`wrangler.jsonc` sends only the API and admin paths to the Worker. Static Assets serves every other path without running Worker code:

```jsonc title="templates/starter/wrangler.jsonc"
"assets": {
  "directory": "./dist",
  "binding": "ASSETS",
  "not_found_handling": "404-page",
  // The contact API and the admin screen run in the Worker. Every other
  // path is a page from ./dist.
  "run_worker_first": ["/api/*", "/admin", "/admin/*"],
},
```

The factory builds one Hono app. Middleware for security headers, HTTPS, and Cloudflare Access runs first, then the routes of each feature. A request that matches no route falls through to the `ASSETS` binding:

```ts title="packages/server/src/app.ts"
app.use('*', securityHeaders);
// The admin UI and /api/*: HTTPS only (except on localhost). Then the
// admin UI, admin API, setup, and Better Auth: Cloudflare Access when
// ACCESS_AUD and ACCESS_TEAM_DOMAIN are set.
app.use('*', requireHttps);
app.use('*', requireAccess);
app.route('/api/contact', contactRoutes);
app.route('/api/setup', setupRoutes);
app.route('/api/auth', authRoutes);
app.route('/api/admin', adminRoutes);
app.all('/api/*', (c) => apiError(c, 404, 'not_found'));
app.on(['GET', 'HEAD'], ['/admin', '/admin/*'], (c) =>
  serveAdminFile(c.req.raw),
);
```

The code for contact, setup, auth, the admin API, and the admin screen lives in one folder per feature under `packages/server/src/features/`.

## Secrets and bindings

| Name                   | Kind                  | Purpose                                                                                                                                  |
| ---------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `DB`                   | D1 binding            | All stored data                                                                                                                          |
| `ASSETS`               | Static Assets binding | The pages built by Astro                                                                                                                 |
| `CONTACT_RATE_LIMITER` | Rate Limiting binding | Rate limits per `ip_hash` (contact submissions, setup attempts, passkey sign-ins)                                                        |
| `TURNSTILE_SITE_KEY`   | Variable              | The public Turnstile site key, in `wrangler.jsonc`. Without it the contact API is off.                                                   |
| `APP_SECRET`           | Secret                | The setup token for the first admin, and the source of the session key and the IP hash key. At least 32 characters. Keep it after setup. |
| `ADMIN_EMAILS`         | Secret or variable    | Email addresses allowed to become admins                                                                                                 |
| `TURNSTILE_SECRET`     | Secret                | The Turnstile secret key, used for siteverify. Without it the contact API is off.                                                        |
| `SLACK_WEBHOOK_URL`    | Secret, optional      | New-inquiry notification                                                                                                                 |
| `ACCESS_AUD`           | Variable, optional    | Cloudflare Access application audience tag. Set together with `ACCESS_TEAM_DOMAIN`.                                                      |
| `ACCESS_TEAM_DOMAIN`   | Variable, optional    | Cloudflare Access team domain, such as `your-team.cloudflareaccess.com`.                                                                 |
| `SETUP_TOKEN`          | Secret, optional      | A separate setup token. When set, setup accepts only this value. Delete it after setup.                                                  |
| `BETTER_AUTH_SECRET`   | Secret, optional      | A separate Better Auth signing key, used instead of the one derived from `APP_SECRET`.                                                   |
| `IP_HASH_SECRET`       | Secret, optional      | A separate key for `HMAC-SHA256(ip, key)`, used instead of the one derived from `APP_SECRET`.                                            |

When `BETTER_AUTH_SECRET` or `IP_HASH_SECRET` is not set, the Worker derives that key from `APP_SECRET` with [HKDF](https://goodnight-web.dev/docs/glossary.md#hkdf) in Web Crypto, with a different fixed label for each purpose. A secret shorter than 32 characters, or clearly not random (a single character repeated, for example), counts as missing, and the features that use it are turned off; a set value that is too short is not replaced by a derived key. Secrets are used only inside the Worker. They are never sent to the browser or written to D1.

`/admin`, `/admin/*`, and every path under `/api/`, including the contact API, work only over `https://` (except on `localhost`). See [Security model](https://goodnight-web.dev/docs/security.md#https).

## Styles and fonts [#styles-and-fonts]

The website, the admin screen, and this documentation site load no web fonts. Text uses the fonts the visitor's device already has, for example Hiragino on macOS and iOS, and Yu Gothic or Meiryo on Windows.

Each of the three defines a system font stack once, as a CSS token. There are no font files and no font services, so the Content Security Policy lists no font hosts.

The starter has its own design tokens (CSS variables). Its three theme colors come from `theme.primary`, `theme.background`, and `theme.foreground` in `site.config.ts`, and are set on `<html>` as CSS variables.

The other shades are mixed from these three colors. Change the three values and the whole site changes color:

```css title="templates/starter/src/styles/global.css"
:root {
  --site-primary: #2f6b4e;
  --site-background: #fbfaf5;
  --site-foreground: #253229;
}

@theme inline {
  /* Fonts: the visitor's system fonts. The site loads no web fonts. */
  --font-sans:
    system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans',
    'Hiragino Kaku Gothic ProN', 'Noto Sans JP', 'Yu Gothic UI', Meiryo,
    sans-serif;

  --color-background: var(--site-background);
  --color-foreground: var(--site-foreground);
```

The admin screen has its own tokens in `@goodnight-web/server`, with a light and a dark mode. Changing the site's colors does not change the admin screen.

## Related pages

- [How it works](https://goodnight-web.dev/docs/how-it-works.md): each design decision, one by one.
- [Distribution and updates](https://goodnight-web.dev/docs/distribution.md): which files you own.
