# @popcomputer/web 1.9.0: the agent rules and documentation in one file The package's own AGENTS.md and docs/, as 1.9.0 ships them. Source: https://github.com/PatrickOgilvie/popcomputer-web. Quick start: https://web.popcomputer.com/llms.txt. --- # @popcomputer/web for coding agents Rules for writing an application on `@popcomputer/web`. Each rule names the one API to use; the files in `docs/` beside this one explain each task, one short file per task, and `docs/which-api.md` names the file for each. Every TypeScript example here and in `docs/` type-checks against the package. To use this file, point the application's own `AGENTS.md` or `CLAUDE.md` at `node_modules/@popcomputer/web/AGENTS.md`. Replace a `CLAUDE.md` written for another setup, such as the one `bun init` writes for `Bun.serve` and `bun:sqlite`: its instructions contradict these. Keep one that already points here, as the demo's does. ## Rules 1. **Declare once, in `setupWeb`, and register it.** `export const web = setupWeb(new Hono(), config)` in `src/app.ts` owns rendering, the database, auth, route binding parsers, pages, shared props, flash and the error page, and declares their types as values. Register it once with `declare module '@popcomputer/web' { interface Register { web: typeof web } }`; every other type derives from it. Never declare a type the config already declares. Declare the Worker's vars and secrets once, in `src/env.ts`, with `defineEnv` from `@popcomputer/web/env`: `secret(schema, { generate: randomSecret })` for a secret the app can make itself, `secret(schema)` for one someone sets, `variable(schema)` for a var, `{ optional: true }` for one the Worker runs without. Type the bindings as `EnvBindings` and the other bindings beside it, and pass it as `setupWeb`'s `env`, which checks each isolate's bindings before it serves. Do not add framework middleware or register routes on the Hono app by hand. 2. **Every handler is an action.** Wrap an `Effect.gen` in `action(...)` and register it on the `routes` that `setupWeb` returns, or that `web.services(...)` returns with the application's own services: `routes.get/post/put/patch/delete(paths.project, action, { name })`. Declare every path once in `src/paths.ts` (`project: '/projects/{project}'`) and pass it as `setupWeb`'s `paths`, so `routes`, `redirect`, `back` and `href` accept only declared paths. Build every URL from it: `redirect(paths.project, { project: id })` in actions, `href(paths.project, { project: id })` from `@popcomputer/web/paths` in React. Registrations chain. `routes` accepts only actions whose services the setup provides. 3. **Declare every page's props as an Effect Schema** in `src/pages.ts` and pass the object as `setupWeb`'s `pages`. Take a table's columns from the table: `tableSchema(schema.projects).mapFields(Struct.pick(['id', 'name']))` from `@popcomputer/web/schema`, and declare the signed-in user as `auth.session: betterAuthSession(tableSchema(schema.user))` (with `tableSchema(schema.session)` second when a plugin adds session fields), with Better Auth's `additionalFields: betterAuthFields(schema.user, { role: { input: false } })`. Type each React page as `WebPage<'Projects/Show'>` from `@popcomputer/web/react`; its props, the shared `auth.user` and `errors` follow. Type a form's fields as `FormInput`, from the schema its action validates. Declare props shared with every page in `shared`, computed when a page renders by `routes.provide(shareProps(effect))`, and flash values in `flash`. 4. **End every branch with a response helper:** `return yield* render(...)`, `redirect(...)`, `back(...)`, `json(Schema, value)`, `jsonOrRender(...)`, `file(...)`, `notFound(...)`, `forbidden(...)`, `invalid(...)` or `httpError(status, message, Schema, body)`. Declare an application cookie once, `cookie('cart', Schema, { path })`, and read, set and clear it through the declaration; sign one the server must trust (`signed: { secret: 'COOKIE_SECRET' }`), since an unsigned one holds whatever the visitor sends. `yield* flash(key, value)` before a redirect hands a message to the next page; a helper or guard layer that must send the visitor elsewhere fails with `redirectNow(path, params)`. Expected failures are typed Effect failures: turn a domain error into a response with `Effect.catchTags`, such as `invalid({ amount: error.message })` to show it beside its form field. A failure the action cannot answer, such as another system being down, is a defect: pipe that call through `Effect.orDie`, which answers 500 and hands the failure to the error observer; never over `authorize()`, `bound()`, `validateRequest` or the database, whose failures are answers. Give a page that reports a failure its status, `render('Signing/LinkInvalid', {}, { status: 404 })`. A handler that ends differently per branch branches inside `Effect.gen`, whose branches TypeScript combines. Do not `throw` in an action. An endpoint's action (rule 13) returns its success value instead. 5. **Read request input only through `validateRequest(Schema)`,** or, in an endpoint's action, through its contract (rule 13). Build schemas from `@popcomputer/web/schema` validators (`requiredString`, `nullableString`, `coercedNumber`, `checkbox`, `email`, `uuid`, `isoDate` and `isoDateTime` for a day or a moment with its zone, ...), which accept form posts as well as JSON (an API rejects undeclared query parameters with `request: { order: ['query'] }, parseOptions: { onExcessProperty: 'error' }`), `nullable(...)` for a filter a GET form may send empty, and `upload({ types: [...] })` for files, served back with `file(...)`; a body over 1 MiB is refused, so an action taking larger files raises `validateRequest(Schema, { bodyLimit })`. A form that fails validation goes back to the page it came from (its Referer; without one, the page its route names, `routes.post(path, action, { form: paths.page })`, or its own path's page), which receives the field errors as `errors`; a JSON client gets a `422`. A key that route params, query and body provide with different values fails validation. Read headers and a raw body (a webhook signature) with `RequestService`. 6. **Load route models with `{param}` placeholders and `bound('param')`, and say in each binding who may load its record.** Name each model's Drizzle table in `setupWeb`'s `bindings` with `routeBinding` and one option: `routeBinding(schema.projects, { ownedBy: 'userId' })` for its owner only, a column that references `user.id` (anyone else gets the 404 a missing record gets, and a signed-out visitor is asked to sign in), `{ publicWhen: { visibility: 'public' } }` for anyone when its columns hold those values, `{ public: true }` for anyone, or `{ guarded: true }` when a guard layer decides, such as an organization's membership guard. Never check ownership in the action instead: a page anyone may see binds the table a second time, under its own name and path (`showcase: routeBinding(schema.projects, { publicWhen: { visibility: 'public' } })`). The model is the table's row, checked against its columns. Bind every record a path names, nested ones scoped to their parent with `scope`, whose option then decides, a membership by its user too (`/orgs/{organization:slug}/members/{member:userId}` with `member: routeBinding(schema.memberships, { scope: { organization: { foreignKey: 'organizationId' } } })`), and never read a record's id from the path to query for it yourself (a plain parameter, such as an invitation token, or the id of a record served from the data cache, is read with `validateRequest` and looked up like any input); to lock a bound record inside `dbTransaction`, read it again by its id with `.for('update')`. When the action needs the signed-in user, call `authorize()` before `bound()`: models load on the first `bound()` call, and a missing row is a 404. A record the user may not see answers 404, so its existence stays private; use `authorize((auth) => rule)` (a 403) for a record the user can see but not change. `bound('project')` type-checks only on a route whose path or prefix has `{project}`, in an action or in a layer guarding it. 7. **Authorize with `authorize()` or `authorize((auth) => rule, message)`.** Use `currentUser` when signing in is optional. A rule a group of routes shares is a `guard(...)` provided to it, `routes.provide(guard(effect))`; a membership guard finds the visitor's membership, `guard(CurrentMember, effect)`, and each action states the role or permission it needs, `yield* can('members.role')`, as `docs/guards.md` shows. Guard a group of routes from signed-out visitors with `routes.provide(RequireAuthLayer)`, which sends a signed-out visitor to `auth.signInPath`, or `routes.provide(RequireGuestLayer)`, which sends a signed-in one to `auth.homePath`. Validate a sign-in's password with `currentPassword`, which never trims it, and a new one with `password(...)`. Sign users in and up with an action that validates its form and returns `betterAuthRedirect('signInEmail', { body: input }, { redirectTo, errors })` from `@popcomputer/web/auth`, which sends the call through Better Auth's handler so the limits apply; call Better Auth for a result the action uses with `betterAuthCall(endpoint, { body })`. Never call `auth.api` from an action: it skips them. Both find the endpoint on Better Auth's instance, so give `auth.client` the instance, not a wrapper that loads Better Auth on first use. Limit password guessing with `setupWeb`'s `auth.signInLimits: { limiter: 'SIGN_IN_LIMITER', table: schema.rateLimit }` (a Workers Rate Limiting binding, and Better Auth's `rateLimit` table for each account's failures), sweep the table with `routes.scheduled(() => sweepSignInFailures)`, and keep `betterAuth({ rateLimit: { enabled: true, storage: 'memory' } })`. Keep `emailAndPassword: { enabled: true, requireEmailVerification: true }` and an `emailVerification.sendVerificationEmail` whose email tells a recipient who did not sign up to ignore it, with `autoSignInAfterVerification` off, as docs/auth.md's `createAuth` does: a password whose address nobody has proven then signs no one in. Its link opens a page whose button posts the token to the action that proves the address, so a mail scanner's fetch of the link proves nothing. Better Auth's plugins (organizations, two-factor, magic links, OAuth providers) stay Better Auth's: call their endpoints by name the same way, and email a magic link, as the verification link, to a page whose button posts its token. Sign in through an OAuth provider (`socialProviders`, or `genericOAuth` for a company's own) with `betterAuthRedirect('signInSocial', ...)`, and serve its callback alone, `routes.get(paths.authCallback, betterAuthHandler())`, with `account: { accountLinking: { disableImplicitLinking: true } }` and `onAPIError: { errorURL }` in `betterAuth`; register `routes.all(paths.authApi, betterAuthHandler({ settable }))` only for Better Auth's client SDK, naming the user fields visitors may set there, the application's `input: true` ones and plugins' such as `phoneNumber`. On D1, Better Auth writes each row in its own statement: write rows that must exist together, such as an organization and its owner, in your own `dbBatch`. Make the application's own record of a new member on their first visit, in a guard layer, since users from OAuth providers and magic links never pass through a sign-up action. A role a user holds per team or workspace lives on a membership row a guard layer reads (the organization plugin's `member` table), not on the user. Register a JSON API's routes on `routes.jsonApi()`, with their declared paths (`routes.jsonApi().provide(KeyAuth).get(paths.apiInvoices, ...)`), so every failure there answers JSON (a 401, not a redirect to sign in), and authenticate its keys in a layer that fails with `new UnauthorizedError({ message, challenge: 'Bearer' })`. 8. **Use the database only through `dbQuery`, `dbMutation`, `dbTransaction` and `dbBatch`:** `yield* dbQuery((db) => db.select()...)` to read, `dbMutation((db) => db.insert(...))` to write. Operations that must succeed together, with any Effect logic between them, go in `dbTransaction(Effect.gen(...))` (Postgres, MySQL, libsql); on Cloudflare D1, put the writes in one `dbBatch` and enforce limits inside the write statement, inserting a row only while a condition holds with `db.insert(table).select(db.select(rowOf(table, values)).from(...).where(condition))`. Write only validated input and values the server derives, and scope `where` clauses with bound models. State an invariant concurrent requests could break as a database constraint (`CHECK (stock >= 0)`, a unique idempotency key or webhook event id, or a trigger raising a named abort on D1) and handle `DatabaseConstraintViolation` by its `constraint` and `constraintName`, or show a named rule beside its fields with `constraintFieldErrors({ name: { field: 'message' } })`. Pipe a write through `uniqueFieldErrors({ column: 'message' })` to show a duplicate as a field error. 9. **Give the application's own services with `web.services({ app, request })`,** after registering `web`, in place of `export const { app, routes } = web`: `export const { app, routes } = web.services({ app: ProjectsLive, request: (c) => auditLogFor(c.env.KV) })`. The `routes` it returns accept actions that use them. `app` is for services built once and shared (configuration, in-memory caches), `request` for anything that holds the request's bindings (D1, KV, Hyperdrive). Define them with `Context.Service` and `Layer`, and read vars and secrets with `config.string('NAME')` and `config.redacted('KEY')`, Effect `Config` named by the Worker's bindings type, which reads the request's bindings. A layer's type is inferred, even when it uses `dbQuery`, `config` or `bound`. 10. **Run work that outlives the response with `background('name', effect)`.** Give the Worker's `scheduled` and `queue` handlers as `routes.scheduled((event) => effect)` and `routes.queue(MessageSchema, (message) => effect)`, which run the work with the application's services, never through a hidden route; test them with `testBatch` and `testEnv`. 11. **Give `cache: 'private'` to routes whose page reads something the framework cannot see,** such as an external `fetch()` or the current time, and `cache: 'no-store'` to one no cache may keep, such as a signed preview. Pages that read bindings, headers or bound models are already rendered per request. A route that changes records cached pages show purges them with `purges: true`, and an action adds other records it changed with `purgeCache(recordCacheTag('listings', id))`. Give data kept with `cache(key, effect, schema, { ttl, tags })` the same tags, so the same purges invalidate it. An API that authenticates each request (a bearer token) caches its data this way, after authenticating, never its response. 12. **Test through the app.** `app.request(path, init, testEnv(layer, bindings))` from `@popcomputer/web/testing` runs the real route; `signedIn(row)` signs the request in (pass the seeded user row when the user table has columns of its own), `testBrowser(app, env)` carries a visitor's cookies across requests, and `readPage(res, 'Name')` reads an `X-Inertia` response typed by the page registry. Test against the production database family: PGlite for Postgres (`testEnv`'s `database`), migrated with `applyMigrations`, and for D1 a database of each test's own from `testD1({ migrations: './migrations' })`, the folder wrangler applies, stopped by `afterAll(() => databases.dispose())` in `bunfig.toml`'s preload. Replace a service that reaches another system with `Layer.succeed(Service, fake)` in the layer, wherever the application gives that service (`web.services` or `routes.provide`), stand in for an OAuth provider with `testOAuthProvider({ ... })` and sign people in through it with `signIn(browser, path, person)`, and move time with `testClock()`, which works when rules read the time through Effect's `Clock` or `DateTime.now`; `time.settle(request)` runs a request whose retries wait on the clock. A new test must fail when the behaviour it covers is reverted: plant the bug first (undo the fix, or break the rule), watch the test fail, then restore the code. A test that passes either way checks nothing. Fuzz the application's routes with `fuzzRequest({ paths, params, people, forms })` from `@popcomputer/web/fuzz`, sending each request with `sendFuzzRequest` and checking its answer with `brokenPromises`, `leaks`, `strangerWrites` and `refusedWrites`, as `docs/fuzzing.md` shows, and add the application's own rules beside them; give each new action a form there, and each new placeholder its values. Check any other rule on generated inputs with `fuzz(arbitrary, holds)`. 13. **Declare every JSON endpoint once, as a contract,** in `src/endpoints.ts`: `endpoint.post(paths.apiProjects, { body, success, status: 201, errors: { 409: SlugTaken } })` from `@popcomputer/web/endpoint`, with `query`, `body` and `headers` schemas for its input, `success` for its body and each failure a client tells apart as an `S.TaggedError` under its status. Declare a JSON number with Effect's schemas (`S.Int.check(S.isGreaterThan(0))`), not the form validators, which accept `"12"` for a form's sake. Give it `auth: true` when it needs a signed-in user, which the handler then receives as `auth`, rather than calling `authorize()` in it. Implement it with `action(endpoints.createProject, ({ auth, params, body }) => Effect.gen(...))`, which receives the input decoded and returns the success value, and register it with `routes.endpoint(createProject)`, which takes the method and path from the contract. Do not call `validateRequest` or `json` in it. Test it with `call(endpoints.x, input, { fetch: (request) => app.request(request, undefined, env) })`, and publish `openApi(endpoints, { title, version, security })`. Pages' forms stay ordinary actions. Every name has one entrypoint: `@popcomputer/web` for `setupWeb`, template helpers and Hono middleware; `@popcomputer/web/effect` for actions, routes, responses, pages, the database, services and errors; `@popcomputer/web/schema` for validators; `@popcomputer/web/auth` for the Better Auth integration; `@popcomputer/web/cache` for the KV cache; `@popcomputer/web/drizzle` for `rowOf`; `@popcomputer/web/endpoint` for `endpoint`, `call` and `openApi`, on the server and in clients alike; `@popcomputer/web/env` for `defineEnv`, `secret`, `variable` and `checkEnv`, in the Worker and in scripts alike; `@popcomputer/web/lint` for `webLint`, in `oxlint.config.ts`; `@popcomputer/web/vite` for `reloadWorkerModules`, in `vite.config.ts`; `@popcomputer/web/fence` for `commandPolicy` and `disableDirectives`, behind the `popweb-fence` command an agent's hook and the commit gate run; `@popcomputer/web/react` for page component types; `@popcomputer/web/paths` for `href`, in React and actions alike; `@popcomputer/web/testing` in tests; `@popcomputer/web/fuzz` for `fuzz`, `fuzzRequest` and the rules answers keep, in tests. ## A complete action ```ts // src/actions/projects/update.ts import { eq } from 'drizzle-orm' import { Effect, Schema as S } from 'effect' import { action, bound, dbMutation, flash, redirect, validateRequest } from '@popcomputer/web/effect' import { nullableString, requiredString } from '@popcomputer/web/schema' import { paths } from '../../paths' import { projects } from '../../schema' const UpdateProject = S.Struct({ name: requiredString, description: nullableString, }) export const updateProject = action( Effect.gen(function* () { // Its owner's only: the binding is ownedBy 'userId'. const project = yield* bound('project') const input = yield* validateRequest(UpdateProject) yield* dbMutation((db) => db.update(projects).set(input).where(eq(projects.id, project.id)) ) yield* flash('success', 'Project saved.') return yield* redirect(paths.project, { project: project.id }) }) ) ``` Register it beside the other routes in `src/routes.ts`: ```ts routes.put(paths.project, updateProject, { name: 'projects.update' }) ``` Test it as `docs/testing.md` shows: request `PUT /projects/project-1` with `testEnv(signedIn({ id: 'user-1' }), testBindings)` and expect a `303` to `/projects/project-1`, a `404` for another user, and, for an empty name, a `303` back to the form whose next page has `errors.name` (a JSON request gets a `422`). ## Do not use - `c.req.json()`, `c.req.parseBody()` or `c.env` inside actions. - `DatabaseService` or raw `db` calls outside `dbQuery`, `dbMutation`, `dbTransaction` and `dbBatch`. - `app.get(...)` or other routes on the Hono app; register on `routes`. - Names from the old `honertia` package (`setupHonertia`, `HonertiaService`, `c.var.honertia`, ...): `UPGRADING.md`'s table lists their replacements. ## Before finishing Run the application's type check (`tsc --noEmit`), the package's lint rules (`bunx oxlint`, with `oxlint.config.ts` exporting `defineConfig(webLint())` from `@popcomputer/web/lint`) and `bun test`. Let the type check run the Effect language service, whose `floatingEffect` fails it on any Effect neither yielded nor assigned, where the lint rule sees only the framework's calls: `bun add -d @effect/tsgo`, `"plugins": [{ "name": "@effect/language-service" }]` in `tsconfig.json`'s `compilerOptions`, and `"prepare": "effect-tsgo patch"` in `package.json`, since `tsc` ignores the plugin until it is patched (`docs/lint-rules.md` has the details). Each lint message names the API to use instead; fix the code rather than disabling the rule, and when a line breaks one on purpose, say why in the disable comment. A type error at `routes.get(...)` names the problem: `missingServices` lists services the setup does not provide (configure them in `setupWeb`, or `routes.provide(layer)`), `unhandledErrors` lists failures that are not the package's errors (answer them with `Effect.catchTag`, or pipe a call whose failure the action cannot answer through `Effect.orDie`; `"noErrorTruncation": true` in `tsconfig.json` shows every one), and `missingRouteModels` lists models the action or a provided guard reads with `bound()` that the route's path does not bind. A type error at `render(...)` means the page or its props differ from `src/pages.ts`. `Argument of type 'string' is not assignable to parameter of type 'readonly [never, ...never[]]'` at an `Effect.catchTag` means the Effect no longer fails with that tag: remove the handler. The same error, or `... | { readonly _tag: unknown }` is not assignable, at a `catchTag` written apart from an Effect, as a reusable helper, means TypeScript cannot see the errors it handles: write it inside `.pipe(...)`, or use `Effect.mapError` with an `instanceof` check (`Effect.catchIf((error) => error instanceof X, ...)` when the error type is a type parameter). `not assignable to type '{ EntitlementRequired: never; }'` at an `Effect.catchTags` means the same of that tag: remove its handler. A registration, `routes.run` or an `action(...)` reporting an `Effect<..., unknown, unknown>`, or `missingServices: "unnamed service"`, means an earlier type error in the action made its types unknown: fix that one first. `'web' implicitly has type 'any'` (TS7022) means a value in `setupWeb`'s configuration has a type that reads the registration, such as one using `dbQuery`: give services to `web.services(...)`, after the registration, as rule 9 shows. Many errors naming `DatabaseNotConfigured`, or `(c) => never` at `database`, while `setupWeb` has a database factory, mean one value in its configuration has a type error, such as a page schema using a deleted import: TypeScript then infers none of the configuration's types. Fix the first error inside the `setupWeb` call. ## The fence The rules that matter most are also checks, which `docs/agent-fence.md` sets up: the commit gate runs the type check, the lint rules, the tests and `popweb-fence debt --check` before every commit, and the agent's command hook refuses a command that skips the gate and asks the owner before one that needs them. When an enforced check and a paragraph disagree, the check wins: follow it, and fix the paragraph. When a check fails, fix what it reports; never skip the gate (`git commit --no-verify`, `-n`, `HUSKY=0`, a changed `core.hooksPath`) or loosen a check to get a green result. The debt report counts every comment that turns a rule off, and the count only shrinks: `popweb-fence debt --update` records a lower count, and only the owner raises it. When a bad pattern recurs, add the lint rule that reports it before cleaning it up, so the cleanup cannot come undone: in the application's `oxlint.config.ts` for its own code, in `webLint` for a mistake any application could make. ## Boundaries and sign-off What an agent may change alone, and what needs the owner. Copy this section into the application's own `AGENTS.md` and add the application's own steps; until then, these hold. - **An agent may, alone:** write and change actions, routes, pages, services, schemas, migrations and their tests; run them against the local database (`wrangler d1 migrations apply --local`, `wrangler dev`) and build with `wrangler deploy --dry-run`; add or tighten a test, a lint rule or a binding's access; fix the code a rule reports, lowering the debt; edit the documentation and `AGENTS.md`, except this section; commit locally once the gate passes. - **Needs the owner's sign-off:** a deploy (`wrangler deploy`, `wrangler versions deploy`, `wrangler rollback`); remote D1 migrations, and any other `--remote` read or write; secrets (`wrangler secret put`); publishing a package; a push; loosening `oxlint.config.ts` or a `webLint` severity, or raising the debt count; changing the commit gate or the agent's hooks; and widening access: who may load a record (a binding moved from `ownedBy` to `publicWhen`, `public` or `guarded`), the user fields `betterAuthHandler`'s `settable` names, `verifyOrigin`'s `allowedOrigins`, a raised `bodyLimit`, or `history.encrypt` turned off. The command hook asks the owner before the steps it can see. Ask before the others yourself, and say what the change widens. --- # Which API for which task Each task has one API. When the one you need is not here, the type checker and the lint rules name it where an action reaches for something else. **Requests and responses** | Task | Use | See | | --- | --- | --- | | Read form, query or JSON input | `validateRequest(Schema)` | [Validating input](validation.md) | | Read a header, or the raw body a webhook signs | `RequestService`'s `header(name)` and `raw()` | [Webhooks](webhooks.md) | | Accept a file | `upload({ types, maxBytes })` in the schema, and `validateRequest`'s `bodyLimit` past 1 MiB | [User input in](validation.md#user-input-in) | | Render a page | `render('Projects/Show', props)` | [Actions and routes](actions.md) | | Redirect after a write | `redirect(paths.project, { project: id })` | [Responses](actions.md#responses-and-failures) | | Go back to the page the request came from | `back(paths.projects)` | [Responses](actions.md#responses-and-failures) | | Send the visitor elsewhere from a helper or a guard | `redirectNow(paths.onboarding)` | [Responses](actions.md#responses-and-failures) | | Show a domain error beside a form field | `invalid({ amount: 'Too low.' })` | [Rules live in services](services.md#rules-live-in-services-actions-decide-what-a-failure-means) | | Hand a message to the next page | `flash('success', 'Saved.')` | [Flash and failed forms](forms.md#flash-messages-and-failed-forms) | | Answer a JSON API | an `endpoint` contract, implemented with `action(endpoint, handler)` | [JSON endpoints](endpoints.md) | | Answer JSON from a page's action | `json(Schema, value)` or `jsonOrRender('Page', props)` | [JSON responses](json.md#json-responses-declared-by-a-schema) | | Answer a failure with a body a client reads | `httpError(status, message, Schema, body)` | [Failures a client reads](json.md#failures-a-client-reads) | | Hide a record the visitor may not see | Its binding's `ownedBy` or `publicWhen`, or `notFound('Project')` in a guard | [Who may load a record](route-models.md#who-may-load-a-record) | | Refuse a visitor who can see the record | `authorize((auth) => rule)`, or `forbidden(message)` | [Auth](auth.md) | | Serve a file or a download | `file(body, { contentType, filename })` | [Serving an upload back](responses.md#serving-an-upload-back) | | Answer a poll that has nothing new | `ifChanged(etag, response)` | [Files, headers and polling](responses.md#files-headers-and-polling) | | Add a header to every response of some routes | `responseHeader(name, value)` in a guard layer | [Files, headers and polling](responses.md#files-headers-and-polling) | | Keep a value in the visitor's browser | `cookie(name, Schema, options)`, declared once | [Cookies](responses.md#cookies) | | Answer a CORS preflight | `routes.options(path, action(noContent(headers)))` | [Files, headers and polling](responses.md#files-headers-and-polling) | **Routes, models and pages** | Task | Use | See | | --- | --- | --- | | Declare a URL, and build links to it | `paths` in `src/paths.ts`, `href(paths.project, params)` | [Actions and routes](actions.md) | | Load the record a URL names | `bindings: { project: routeBinding(schema.projects, { ownedBy: 'userId' }) }`, then `bound('project')` | [Route model binding](route-models.md) | | Say who may load it | `ownedBy: 'userId'` for its owner, `publicWhen: { isPublic: true }`, `public: true`, or `guarded: true` when a guard decides | [Who may load a record](route-models.md#who-may-load-a-record) | | Guard a group of routes | `routes.provide(guard(effect))`, or `RequireAuthLayer` | [A guard guards a group of routes](guards.md#a-guard-guards-a-group-of-routes) | | Check a role or a permission | `yield* can('members.role')` in the action, after a membership `guard(CurrentMember, ...)` | [Roles belong to memberships](guards.md#roles-belong-to-memberships-read-by-a-guard) | | Show the same props on every page | `shared` in `setupWeb`, computed by `shareProps(effect)` | [Props every page shows](pages.md#props-every-page-shows) | | Declare a page's props | a schema in `src/pages.ts`, typed in React as `WebPage<'Name'>` | [Describe the pages](pages.md#describe-the-pages) | | Take a page's fields from a table | `tableSchema(table).mapFields(Struct.pick([...]))` | [Describe the pages](pages.md#describe-the-pages) | | Type a form | `useForm>`, `FormErrors` | [Forms](forms.md) | | Type the error page | `WebErrorPage` | [The error page](pages.md#the-error-page) | | Edit routes and actions while Vite's dev server runs the Worker | `reloadWorkerModules()` from `@popcomputer/web/vite` in `vite.config.ts`'s plugins | [Installation](installation.md) | **Auth** | Task | Use | See | | --- | --- | --- | | Require a signed-in user | `authorize()` | [Auth](auth.md) | | Read the user when signing in is optional | `currentUser` | [Auth](auth.md) | | Sign in or sign up | `validateRequest`, then `betterAuthRedirect('signInEmail', { body }, { redirectTo, errors })` | [Signing in and out](sign-in.md) | | Return to the page the visitor wanted | `intendedUrl(fallback)` | [Signing in and out](sign-in.md) | | Call Better Auth for a result | `betterAuthCall('enableTwoFactor', { body }, { errors })` | [Signing in and out](sign-in.md) | | Sign out | `betterAuthLogoutAction({ redirectTo, clear })` | [Signing in and out](sign-in.md) | | Limit password guessing | `auth.signInLimits`, `sweepSignInFailures` | [Limiting password guessing](auth.md#limiting-password-guessing) | | Use a Better Auth plugin: organizations, two-factor, magic links | its endpoints by name with `betterAuthRedirect` and `betterAuthCall` | [Better Auth's plugins](auth-plugins.md) | | Sign in through an OAuth provider, a company's own or GitHub's | `betterAuthRedirect('signInSocial', ...)`, and `betterAuthHandler()` on the callback alone | [Signing in with an OAuth provider](auth-plugins.md#signing-in-with-an-oauth-provider) | | Add a field to the user | a column on the user table, with `betterAuthFields` | [Auth](auth.md) | | Authenticate an API key | a layer failing with `new UnauthorizedError({ message, challenge: 'Bearer' })` | [Caching an API](data-cache.md#caching-an-api-that-authenticates-each-request) | **The database** | Task | Use | See | | --- | --- | --- | | Read | `dbQuery((db) => ...)` | [The database](database.md) | | Write | `dbMutation((db) => ...)` | [The database](database.md) | | Write several things together (Postgres, MySQL, libsql) | `dbTransaction(Effect.gen(...))` | [The database](database.md) | | Write several things together on Cloudflare D1 | `dbBatch((db) => [...])` | [Constraints](constraints.md) | | Insert only while a condition holds, on D1 | `db.insert(table).select(db.select(rowOf(table, values))...)` | [Constraints](constraints.md) | | Keep a rule concurrent requests could break | a constraint (`CHECK`, unique, exclusion, trigger) | [Constraints](constraints.md) | | Show a duplicate beside its field | `uniqueFieldErrors({ slug: 'Taken.' })` | [The database](database.md) | | Show a named rule beside its fields | `constraintFieldErrors({ name: { field: message } })` | [Constraints](constraints.md) | | Select exactly the columns a page shows | `columnsOf(table, Schema)` | [Describe the pages](pages.md#describe-the-pages) | **Services, background work and caching** | Task | Use | See | | --- | --- | --- | | Provide a service | `web.services({ app, request })`, after registering `web` | [Application services](services.md) | | Declare the Worker's vars and secrets | `defineEnv` with `secret(schema, { generate })` and `variable(schema)`, passed as `setupWeb`'s `env` | [Vars and secrets](env.md) | | Check vars and secrets in a script, before a deploy | `checkEnv(env, values)` | [Vars and secrets](env.md#checking-them-before-a-deploy) | | Read a var or a secret | `config.string('NAME')`, `config.redacted('KEY')` | [Application services](services.md) | | Work after the response | `background('name', effect)` | [Work after the response](background.md#work-after-the-response) | | Run a Cron Trigger or a Queue consumer | `routes.scheduled(job)`, `routes.queue(Schema, handle)` | [Scheduled and queued work](background.md#scheduled-and-queued-work) | | Cache computed data | `cache(key, effect, Schema, { ttl, tags })`, with `kvCache` in `web.services` | [The data cache](data-cache.md) | | Cache a public page for a time | the route's `cache` option | [Workers Cache](page-cache.md#time-based-caching-with-workers-cache) | | Refresh cached pages and data after a write | `purges: true`, `purgeCache(recordCacheTag(table, id))` | [Workers Cache](page-cache.md#time-based-caching-with-workers-cache) | | Tag a cached page with records it read | `tagCache(recordCacheTag(table, id))` | [Workers Cache](page-cache.md#time-based-caching-with-workers-cache) | **Testing** | Task | Use | See | | --- | --- | --- | | Run a request through the application | `app.request(path, init, testEnv(layer, bindings, { database }))` | [Testing](testing.md) | | Sign a request in | `signedIn(user)` in `testEnv`'s layer | [Testing](testing.md) | | Read a page, JSON, a failure or a cookie | `readPage`, `readJson`, `readError`, `readErrorPage`, `readCookie` | [Reading a response](testing.md#reading-a-response) | | Make several requests as one visitor, or follow a redirect | `testBrowser(app, env)` and its `follow(res)` | [One visitor](testing.md#one-visitor-across-requests) | | Replace a service that reaches another system | `Layer.succeed(Service, fake)` in `testEnv`'s layer | [Fakes](testing.md#fakes-for-other-systems) | | Move time | `testClock()` | [Time](testing.md#time) | | Use the production database family | PGlite, migrated with `applyMigrations`, or a D1 database of each test's own from `testD1` | [A database for tests](test-database.md) | | Check a rule on generated inputs | `fuzz(arbitrary, holds)` from `@popcomputer/web/fuzz` | [Fuzzing](fuzzing.md) | | Fuzz the application's routes | `fuzzRequest({ paths, params, people, forms })`, `sendFuzzRequest`, and `brokenPromises`, `leaks`, `strangerWrites`, `refusedWrites` | [Requests to the application](fuzzing.md#requests-to-the-application) | | Sign in through an OAuth provider in a test | `testOAuthProvider({ ... })`, then `signIn(browser, path, person)` | [Signing in with an OAuth provider](auth-plugins.md#signing-in-with-an-oauth-provider) | | Run a Queue consumer | `testBatch(bodies)` | [Scheduled and queued work](background.md#scheduled-and-queued-work) | **The agent fence** | Task | Use | See | | --- | --- | --- | | Run the checks before every commit | `.githooks/pre-commit`, installed with `git config core.hooksPath .githooks` | [The commit gate](agent-fence.md#the-commit-gate) | | Refuse a command that skips the gate, and ask the owner before a deploy | `popweb-fence hook` in `.claude/settings.json` or `.cursor/hooks.json` | [The command hook](agent-fence.md#the-command-hook) | | Check a command or an edit from an agent's own code | `commandPolicy(command, { scripts })`, `filePolicy(path)` | [Other agents](agent-fence.md#other-agents) | | List every disable directive, and keep the count from rising | `popweb-fence debt`, and `--check` against `lint-debt.json` | [The debt report](agent-fence.md#the-debt-report) | --- # Reading the type errors The framework reports most mistakes as type errors where they are made, and each names what is wrong. Fix the first error in a file before reading the rest: one error inside an action or `setupWeb`'s configuration can make TypeScript give up on the types that later errors are about. | Where, and what it says | What it means | What to do | | --- | --- | --- | | A registration, `missingServices: "@popcomputer/web/Database"` | The action uses a service the setup does not provide | Configure it in `setupWeb` (`database`, `auth.client`, `services`), or `routes.provide(layer)` | | A registration, `unhandledErrors: "PaymentDeclined"` | The action can fail with an error the boundary cannot answer | Turn it into a response with `Effect.catchTag`: `invalid`, `notFound`, `httpError`, `flash` then `back`. A failure the action cannot answer, such as another system being down: pipe that call through `Effect.orDie`, which answers 500. A list cut off with `... 7 more ...` shows whole with `"noErrorTruncation": true` in `tsconfig.json` | | A registration, `missingRouteModels: "project"` | The action, or a guard it is registered under, calls `bound('project')`, and the path has no `{project}` | Register it on a path with the placeholder, or read the record by an id from `validateRequest` | | A registration or `action(...)`, `Effect<..., unknown, unknown>` or `missingServices: "unnamed service"` | An earlier type error inside the action made its type unknown | Fix the first error inside the action | | `render(...)`, props not assignable | The page's name or props differ from `src/pages.ts` | Change the props, or the page's schema | | A path literal, `Not one of setupWeb's paths`, with `declaredPaths` | The path is not declared | Declare it in `src/paths.ts` and pass `paths.x` | | `bound('...')`, `config.string('...')`, a key not assignable | A misspelled binding, or a var the bindings type does not declare | Use a name the error lists, or declare it in `src/env.ts`'s `defineEnv` | | `Effect.catchTag('X', ...)`, `not assignable to parameter of type 'readonly [never, ...never[]]'` | The Effect no longer fails with `X` | Remove the handler | | `Effect.catchTags({ X: ... })`, `not assignable to type '{ X: never; }'` | The same, for one of the handlers | Remove that handler | | A `catchTag` in a helper written apart from its Effect, `{ readonly _tag: unknown }` not assignable | TypeScript cannot see the errors the helper handles | Write it inside `.pipe(...)`, or use `Effect.catchIf((error) => error instanceof X, ...)` | | `routeBinding(table, { ... })`, `No overload matches this call`, naming `Codec` | TypeScript reports the last overload, the one for an Effect Schema, though the options are what's wrong: two of `ownedBy`, `public`, `publicWhen` and `guarded`, a column the table lacks, or a `publicWhen` value of another type than its column's | Give one option, naming the table's columns by their property keys, with values of their columns' types, as `{ publicWhen: { visibility: 'public' } }` | | `setupWeb(...)` given `services` | Services are given after the registration | Move them to `export const { app, routes } = web.services({ app, request })`, after the `Register` declaration | | Inside `setupWeb(...)`, many errors naming `DatabaseNotConfigured`, or `(c) => never` at `database` | One value in the configuration has a type error, so TypeScript infers none of its types | Fix the first error inside the `setupWeb` call | | A database result, `This result's type is any` | The driver's types name Cloudflare's global types, which the program cannot find | Add `@cloudflare/workers-types`, or the file `wrangler types` writes, to `compilerOptions.types` | | `dbTransaction(...)`, `Use dbBatch for atomic writes, or a driver with transactions.` | The database has no interactive transactions: Cloudflare D1, or a synchronous driver such as `bun:sqlite` | Write the statements as one `dbBatch` | | A `dbBatch` on D1, `Drizzle cannot run db.run(sql) with parameters in a Cloudflare D1 batch` | Drizzle's D1 batch fails at runtime on a raw statement | Write the statement with the query builder | What types cannot see, such as an update without a `where` or a network call inside a transaction, the [lint rules](lint-rules.md) report. --- # Actions and routes Each route's path is declared once, and every URL to it is built from that declaration: the route registration, a server redirect and a client link. The module holds only data, so server and React code both import it, and `setupWeb`'s `paths` takes it, so `routes` registers only on declared paths, and a path literal given to `redirect`, `back`, `href` or `intendedUrl` must be one of them or an absolute URL: a misspelled path is a type error. A URL computed at runtime, such as one `href` built, is a string and passes. ```ts // src/paths.ts export const paths = { login: '/login', logout: '/logout', verifyEmail: '/verify-email', authApi: '/api/auth/*', authCallback: '/api/auth/callback/{provider}', loginWithAcme: '/login/acme', dashboard: '/dashboard', billing: '/billing', pricing: '/pricing', explore: '/explore', projects: '/projects', project: '/projects/{project}', projectName: '/projects/{project}/name', projectBySlug: '/p/{showcase:slug}', organizationMembers: '/orgs/{organization:slug}/members', organizationMember: '/orgs/{organization:slug}/members/{member:userId}', task: '/projects/{project}/tasks/{task}', apiProjects: '/api/projects', apiProject: '/api/projects/{project}', apiDescription: '/api/openapi.json', } as const ``` ```ts // src/actions/projects/show.ts import { Effect } from 'effect' import { action, bound, render } from '@popcomputer/web/effect' export const showProject = action( Effect.gen(function* () { // The binding is ownedBy 'userId': another member's project answers 404, // as a missing one does, so its existence stays private. const project = yield* bound('project') return yield* render('Projects/Show', { project }) }) ) ``` ```ts // src/routes.ts import { routes } from './app' import { paths } from './paths' import { showProject } from './actions/projects/show' routes.get(paths.project, showProject, { name: 'projects.show' }) ``` `redirect(paths.project, { project: project.id })` and, in React, `href(paths.project, { project: project.id })` from `@popcomputer/web/paths` fill the path's placeholders; the parameters are typed from them, so a renamed placeholder is a type error at every URL built from it. A value fills one path segment: an empty one, `.` or `..` would turn the path into another route's, or `//host`, another site, and a URL reads `%2e` as a dot, so no escape keeps one, and no URL can carry text with a lone surrogate, which a JSON body can hold. `href` and `redirect` leave such a placeholder unfilled, as a missing value, `/orgs/{organization}`: the link stays on its route and site, and one stored slug of `..` breaks only its own link, never a page or job listing others. An endpoint's `call` throws on it. Validate a name a URL will carry with `alphaDash`, as for a slug, which also refuses `{` and `}`. A redirect leads to a path or an `http:` or `https:` URL: one to any other scheme, such as a `javascript:` URL a form's `next` field names, is a defect that answers 500, and an Inertia visit is sent to another site only at an `http:` or `https:` URL. Take the page to return to from `intendedUrl`, which returns only a path on this site. `routes` accepts an action only when the setup provides every service the action uses and the action fails only with the package's errors. Otherwise the registration is a type error that names the problem, for example `missingServices: "@popcomputer/web/Database"` when no `database` is configured, `unhandledErrors: "PaymentDeclined"` for a domain error the action has not handled, or `missingRouteModels: "project"` for an action that calls `bound('project')` on a path without `{project}`. `routes.provide(layer)` adds services for the routes registered through it. Routes that share a path prefix and a guard are grouped with `routes.prefix(...)`. The prefix's placeholders bind models for every route under it, so a guard layer provided on the group reads them with `bound()`, and each route under it is registered with its whole declared path. A path under a prefix that is not declared, or does not start with it, is a type error. [Roles belong to memberships](guards.md#roles-belong-to-memberships-read-by-a-guard) groups an organization's routes this way. `action` does not hide another execution model. It preserves the Effect's success, error, and requirement channels, while documenting that the program is an HTTP action. ## Responses and failures Choose the helper that matches the current action branch. These are alternative endings, not statements to run in sequence: | Intent | Action ending | |---|---| | Render a page | `return yield* render('Dashboard', { stats })` | | Redirect | `return yield* redirect(paths.project, { project: project.id })` | | Redirect to the page the request came from | `return yield* back(paths.project, { project: project.id })`, falling back to that path | | Redirect from a helper or guard layer, ending the action | `yield* redirectNow(paths.dashboard)` | | Return JSON declared by a schema, encoded as page props are | `return yield* json(ProjectList, { projects })` | | Return JSON no client decodes, such as a webhook's acknowledgement | `return yield* json({ received: true })` | | Return JSON when requested, otherwise render a page | `return yield* jsonOrRender('Projects/Index', { projects })` | | Fail with `NotFoundError` | `return yield* notFound('Project', projectId)` | | Fail with `ForbiddenError` | `return yield* forbidden('You cannot edit this project')` | | Send a form back with field errors | `return yield* invalid({ name: 'That name is taken.' })` | | Render a page that reports a failure, with its status | `return yield* render('Signing/LinkInvalid', {}, { status: 404 })` | | Answer 500 for a failure the action cannot answer | `yield* crm.export(project.id).pipe(Effect.orDie)` | Flash messages and a failed form's errors are in [Forms](forms.md#flash-messages-and-failed-forms); cookies, files and headers in [Cookies, files and headers](responses.md); JSON responses and the failures a client reads in [JSON clients](json.md). ## Failures an action cannot answer Some failures have no answer the action can give: another system is down, a storage write failed. They are defects. Pipe the call that fails through `Effect.orDie`, and the request answers 500, with a generic message in production and the failure's tag on the development error page, and the error observer receives the failure itself, as `kind: 'defect'`: ```ts class CrmUnavailable extends S.TaggedError()('CrmUnavailable', {}) {} /** Another system the application hands its projects to. */ declare const crm: { readonly export: (projectId: string) => Effect.Effect } const exportProject = action(Effect.gen(function* () { yield* authorize() const project = yield* bound('project') yield* crm.export(project.id).pipe(Effect.orDie) return yield* redirect(paths.project, { project: project.id }) })) ``` Never pipe `authorize()`, `bound()`, `validateRequest` or a database call through `Effect.orDie`: their failures are answers, a redirect to sign in, a 404 or the form's errors, and the `no-catch-all-in-action` lint rule reports it. ## Reporting failures Typed failures, defects, Hono exceptions, and not-found responses share the configured error boundary. Use `EffectErrorObserverService` as the integration point for Sentry, PostHog, or another reporting system; observation does not change the failure channel. It receives every failure, a form's 422 included: a failed validation carries each field's message, path and code but never the value sent, which can be a password, a cookie or a token. Its context names the route, the URL's origin, route pattern and query names, and the Referer's origin, never a value a URL carries, such as a reset link's token. --- # The agent fence `AGENTS.md` asks an agent to run the type check, the lint rules and the tests, to say why it turns a rule off, and to leave deploys to the owner. An agent can skip a paragraph; it cannot skip a check that refuses to pass. The fence turns those rules into three checks: - the commit gate runs the checks before every commit; - the agent's command hook refuses a command that skips the gate, and asks the owner before one that deploys, touches remote data, changes secrets, publishes or pushes; - the debt report counts every comment that turns a rule off, so the count only shrinks. When an enforced check and a paragraph disagree, the check wins and the paragraph gets fixed. The checks ship in the package as the `popweb-fence` command and the `@popcomputer/web/fence` entrypoint, which import only Node's built-ins. ## The commit gate The gate is the checks `AGENTS.md`'s "Before finishing" names, as a git pre-commit hook. Commit it as `.githooks/pre-commit`: ```sh #!/bin/sh # The commit gate: the checks an agent runs before finishing, before every commit. set -e bunx tsc --noEmit bunx oxlint bun test bunx popweb-fence debt --check ``` Install it once, in your own terminal: ```bash chmod +x .githooks/pre-commit && git config core.hooksPath .githooks ``` The command hook refuses an agent's `git config core.hooksPath`, so the owner installs the gate, and every worktree of the repository shares it. A team on [Lefthook](https://lefthook.dev) commits the same checks as `lefthook.yml` instead, and runs `bunx lefthook install`: ```yaml pre-commit: parallel: false commands: types: run: bunx tsc --noEmit lint: run: bunx oxlint test: run: bun test debt: run: bunx popweb-fence debt --check ``` When the gate fails, fix what it reports and commit again. `git commit --no-verify`, `git commit -n`, `HUSKY=0`, `LEFTHOOK=0` and a changed `core.hooksPath` all skip it, and the command hook refuses each one. ## The command hook `popweb-fence hook` reads the command an agent is about to run, as the agent's hook passes it, and answers in that agent's format. | It finds | For example | It | | --- | --- | --- | | A way around the commit gate | `git commit --no-verify`, `-n`, `HUSKY=0`, `LEFTHOOK=0`, `-c core.hooksPath=...`, `lefthook uninstall`, a write into `.git/hooks/` | refuses it | | A deploy | `wrangler deploy`, `wrangler versions deploy`, `wrangler rollback` | asks the owner | | Remote data | `wrangler d1 migrations apply --remote`, `wrangler d1 execute --remote`, any `wrangler ... --remote` | asks the owner | | A secret | `wrangler secret put`, `bulk` or `delete` | asks the owner | | A release | `npm publish`, `bun publish`, `pnpm publish` | asks the owner | | A push | `git push` | asks the owner | | A change to the fence | `oxlint.config.ts`, `lefthook.yml`, `.githooks/`, `.claude/settings.json`, `lint-debt.json`, `popweb-fence debt --allow-increase` | asks the owner | `wrangler deploy --dry-run`, `npm publish --dry-run`, `git push --dry-run` and D1's local database (`--local`, the default) need no one. The hook reads a command line as the shell would: through quotes and escapes, `&&`, `||`, `;` and pipes, `sh -c` and `bash -lc`, `eval`, `$(...)`, heredocs, env prefixes and `env`, `npx`, `bunx`, `pnpm exec` and `node_modules/.bin`, the variables the line sets itself, and the `package.json` scripts a runner names, so `bun run deploy` asks when its script deploys. It also reads a shell script the command runs, and the strings of a JavaScript or TypeScript one. ### Claude Code Commit `.claude/settings.json`: ```json { "hooks": { "PreToolUse": [ { "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit", "hooks": [ { "type": "command", "command": "bun \"$CLAUDE_PROJECT_DIR/node_modules/@popcomputer/web/dist/fence/cli.js\" hook" } ] } ] } } ``` Claude Code shows a refusal's reason to the agent. It puts a question to you in its permission prompt, auto mode included: approve the one command, or decline it and run it yourself. In a `claude -p` run, where nobody can answer, it refuses the command. The `Edit` and `Write` matchers send file edits through the same policy, so an edit of `.git/hooks/` is refused and an edit of a fence file asks. `node` runs the hook as well as `bun`. ### Cursor Commit `.cursor/hooks.json`; a project's hooks run from its root: ```json { "version": 1, "hooks": { "beforeShellExecution": [ { "command": "bun node_modules/@popcomputer/web/dist/fence/cli.js hook", "failClosed": true } ] } } ``` `failClosed` refuses a command when the hook itself fails, and until `bun install` has run, it fails: run the first install yourself. Claude Code has no such setting and lets the command through. ### Other agents An agent whose hook pipes the command line to a program and blocks on exit status 2 uses the command as it is: `popweb-fence hook` reads a plain command line on stdin, and exits 2 with the reason on stderr when the command must not run. Check it by hand: ```bash echo 'sh -c "git commit --no-verify"' | bunx popweb-fence hook ``` An agent whose hooks are code, such as a Pi extension, calls the policy itself: ```ts // scripts/agent-policy.ts import { readFileSync } from 'node:fs' import { commandPolicy, filePolicy } from '@popcomputer/web/fence' const { scripts } = JSON.parse(readFileSync('package.json', 'utf8')) as { scripts?: Record } export const beforeCommand = (command: string) => commandPolicy(command, { scripts }) export const beforeEdit = (path: string) => filePolicy(path) ``` Each returns `{ decision: 'allow' }`, or a `decision` of `'deny'` or `'ask'` with the `rule`, the `subject` and the `reason`. ### What it cannot see The hook reads the command line, not the system it runs on. It cannot see a git alias or a shell alias set outside the line, a `make` or `just` target, a program in another language, or a command a program builds at run time. When a spelling gets past it, add the case to the policy's tests before anything else, as the gardener rule in `AGENTS.md` says. ## The debt report `popweb-fence debt` lists every comment in the application's source that turns a rule or a check off, with the reason it gives, and counts them: ```text src/actions/reports.ts:41 oxlint-disable-next-line popcomputer-web/no-sql-interpolation -- the column names are constants src/email.ts:12 @ts-expect-error (no reason given) 2 disable directives, 1 without a reason. ``` It counts Oxlint's and ESLint's `disable` comments, TypeScript's `@ts-expect-error`, `@ts-ignore` and `@ts-nocheck`, and Effect's `@effect-diagnostics` comments that lower a rule below `error`, in the files git tracks or would track. A reason follows ` -- `, as `// oxlint-disable-next-line popcomputer-web/no-sql-interpolation -- the column names are constants`. `popweb-fence debt --update` writes the ledger, `lint-debt.json`: each directive without its line, so moving code changes nothing. Commit it. `popweb-fence debt --check`, the gate's last step, then fails: - when the code holds more directives than the ledger, naming the new ones. Fix the code each rule reports. A directive the owner accepts goes in with `popweb-fence debt --update --allow-increase`, which the command hook asks the owner about; - when the code holds fewer, or different ones, until `--update` records them, so the baseline follows the debt down and a later directive cannot take the freed place unnoticed. `--update` never raises the count, and a deleted ledger keeps the count committed before it. A generated file that turns rules off, such as Wrangler's `worker-configuration.d.ts`, goes in the ledger's `ignore`, a list of paths and globs (`src/generated/**`): ```json { "count": 0, "ignore": ["worker-configuration.d.ts"], "directives": [] } ``` `--json` prints the directives with their lines, and `--ledger ` names another ledger. ## Boundaries and sign-off The hook enforces the steps it can see. `AGENTS.md`'s "Boundaries and sign-off" section lists all of them: what an agent changes alone, and what needs the owner. Copy it into the application's own `AGENTS.md`, add the application's own steps, and keep the hook's list and the section's in step. --- # Better Auth's plugins Better Auth's plugins (organizations and teams, two-factor, magic links, OAuth providers, passkeys, admin) stay Better Auth's: add them to `betterAuth({ plugins })` and their tables to the schema, and call their endpoints by name, as `betterAuthRedirect` and `betterAuthCall` call any other, typed from the registered client. Where they meet the application: - A plugin's session fields, such as the organization plugin's `activeOrganizationId` or the admin plugin's `impersonatedBy`, reach `authorize()` once `betterAuthSession` is given the `session` table. - Two-factor: signing in a member with two-factor on answers `{ twoFactorRedirect: true }` rather than a session, so the sign-in's `redirectTo` is `(result) => 'twoFactorRedirect' in result ? paths.twoFactor : paths.dashboard`, and the code form's action calls `betterAuthRedirect('verifyTOTP', { body: input }, { redirectTo, errors })`. - An OAuth provider, a company's own or GitHub's: see [Signing in with an OAuth provider](#signing-in-with-an-oauth-provider). - A magic link, or another link Better Auth emails: email a link to a page whose button posts its token, as the verify-email link does ([Proving an address](auth.md#proving-an-address)), never Better Auth's own `url`. Its token signs in once, whoever sends it, so a mail scanner's or a link preview's fetch would hold the member's session and leave their click an expired link. The post's action, `betterAuthRedirect('magicLinkVerify', { query: input }, { redirectTo })`, fails as a rejection does once the link is used or expired, so it shows a page instead: `.pipe(Effect.catchTag('ValidationError', () => render('Auth/LinkExpired')))`. Beside password sign-up, keep `createAuth`'s `requireEmailVerification`: a link's first sign-in revokes the password and sessions of an account someone else registered with that address, not a two-factor challenge they hold or a password sign-in under way, as [Proving an address](auth.md#proving-an-address) says. - Phone numbers: `requireEmailVerification` gates passwords only. The phone-number plugin's verify signs in whoever holds a number's code, whether or not anyone has proven the address, and the owner's first sign-in by a link leaves the number on the account. So no sign-up sets one: `betterAuthHandler` refuses `phoneNumber` unless `settable` names it, as it refuses every plugin's user field, and the sign-up action's schema leaves it out. A member adds a number once signed in, through `/phone-number/verify` with `updatePhoneNumber: true`. - Organizations: the plugin's `member` table is the membership a guard reads, as [Roles belong to memberships](guards.md#roles-belong-to-memberships-read-by-a-guard) shows, with the organization bound by its route, its binding saying the guard decides: `organization: routeBinding(schema.organization, { guarded: true })`. Check a role in the guard or the action before calling the plugin's endpoint, so a member without it gets a 403: the plugin's own refusal fails as a rejection does, sending the form back with its message. The plugin stores any slug, `..` and `{organization}` included, and `betterAuthHandler` forwards its create and update as they are: refuse a slug `alphaDash` would refuse in `organizationHooks.beforeCreateOrganization` and `beforeUpdateOrganization`, as your own actions do with `alphaDash`, since `href` leaves a `..` slug's link unfilled. - Writes that must happen together: Better Auth writes each row in its own statement, which on D1 is never a transaction. The organization plugin's `createOrganization` checks the slug is free, writes the organization, then its owner's membership, so a failure between leaves an organization nobody owns, and two requests for one slug end in a 500. On D1, write an organization and its owner in one `dbBatch` of your own, into the plugin's tables, and turn the plugin's off with `allowUserToCreateOrganization: false`. Give the tables the constraints its checks stand in for (a unique slug, one membership per person and organization, one pending invitation per address), so a second, simultaneous request fails rather than writes a duplicate. The package's own tests drive each of these through an application on every Better Auth release it supports. ## Signing in with an OAuth provider A provider Better Auth knows, such as GitHub or Google, goes in `betterAuth({ socialProviders })`; any other OAuth 2.0 or OpenID Connect provider, such as a company's own, goes in the `genericOAuth` plugin. Either way, signing in is Better Auth's `signInSocial` with the provider's id, and the provider sends the browser back to Better Auth's callback, `/api/auth/callback/{provider}`: ```ts // src/acme-auth.ts import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { genericOAuth } from 'better-auth/plugins' import type { Database } from './db' import * as schema from './schema' export interface CreateAcmeAuthOptions { readonly db: Database readonly secret: string readonly baseURL: string readonly clientId: string readonly clientSecret: string } export function createAcmeAuth(options: CreateAcmeAuthOptions) { return betterAuth({ database: drizzleAdapter(options.db, { provider: 'pg', schema }), secret: options.secret, baseURL: options.baseURL, plugins: [ genericOAuth({ config: [{ providerId: 'acme', clientId: options.clientId, clientSecret: options.clientSecret, authorizationUrl: 'https://id.acme.example/oauth/authorize', tokenUrl: 'https://id.acme.example/oauth/token', userInfoUrl: 'https://id.acme.example/oauth/userinfo', pkce: true, }], }), ], // A person is their account at the provider, not an email address: an // account there reporting someone's email is refused, not given theirs. account: { accountLinking: { disableImplicitLinking: true } }, // A refused sign-in lands on the application's page, with `?error=`. onAPIError: { errorURL: '/login' }, // Better Auth allows three sign-ins from one address in ten seconds, and // an office signs in through one address. rateLimit: { enabled: true, storage: 'memory', customRules: { '/sign-in/social': { window: 60, max: 30 } } }, }) } ``` A sign-in button posts to an action that starts the sign-in. The provider is another site, so the answer to an Inertia visit is the 409 and `X-Inertia-Location` Inertia asks for, and it loads the provider as a full page: ```ts // src/actions/acme.ts import { action } from '@popcomputer/web/effect' import { betterAuthRedirect } from '@popcomputer/web/auth' import { paths } from '../paths' export const signInWithAcme = action( betterAuthRedirect('signInSocial', { body: { provider: 'acme', callbackURL: paths.projects, errorCallbackURL: paths.login }, }, { redirectTo: (result) => result.url ?? paths.login }) ) ``` Register it for guests, and Better Auth's handler on the callback alone, so no other endpoint of Better Auth's is reachable past the application's guards: ```ts import { RequireGuestLayer } from '@popcomputer/web/effect' import { betterAuthHandler } from '@popcomputer/web/auth' import { signInWithAcme } from '../actions/acme' routes.provide(RequireGuestLayer).post(paths.loginWithAcme, signInWithAcme) routes.get(paths.authCallback, betterAuthHandler()) ``` Better Auth checks the callback's `state` against a cookie it set in the browser that started the sign-in, so a sign-in finished in another browser is refused. `auth.signInLimits` does not count sign-ins through a provider against the client's address: they check no password here. A refused sign-in lands on `errorURL` with Better Auth's code as `?error=`, which the sign-in page turns into words: `state_mismatch` (finished in another browser, or too late), `account_not_linked` (an account here already has that email), `unable_to_get_user_info`, and any code the application's own hooks throw. Decide what an email the provider has not verified may do: with implicit linking off, an account created from one holds that address against its real owner. To refuse one, throw from `databaseHooks.user.create.before`, as `throw new APIError('FORBIDDEN', { message: 'Your email is not verified.', code: 'email_not_verified' })` with `APIError` from `better-auth/api`; returning `false` fails the sign-in without a code. A person's name and email are the provider's at their first sign-in and stay so unless the provider's config sets `overrideUserInfo: true`, which fails a later sign-in whose email another account here holds. A test stands in for the provider with `testOAuthProvider` from `@popcomputer/web/testing`, which answers the application's `fetch` requests to the provider's token and user info endpoints as the provider would, refusing what it would refuse: a code exchanged twice, or without the client's secret or the PKCE verifier its authorization named. `signIn` starts the sign-in through the application's route and returns the callback's answer, as the provider sends the browser back once the person has signed in there: ```ts import { afterEach, beforeEach, expect, test } from 'bun:test' import { testBrowser, testEnv, testOAuthProvider } from '@popcomputer/web/testing' const acme = testOAuthProvider({ authorizationUrl: 'https://id.acme.example/oauth/authorize', tokenUrl: 'https://id.acme.example/oauth/token', userInfoUrl: 'https://id.acme.example/oauth/userinfo', client: { id: 'test-client', secret: 'test-secret' }, }) beforeEach(() => acme.install()) afterEach(() => acme.restore()) test('a person signs in with their Acme account', async () => { const browser = testBrowser(app, testEnv(testBindings, { database: testDb })) const res = await acme.signIn(browser, paths.loginWithAcme, { id: 'acme-1', email: 'ada@example.com', name: 'Ada' }) expect(res.headers.get('Location')).toBe(paths.projects) }) ``` `authorization(response)` and `callback(authorization, person)` take the same steps one at a time, for a test of a sign-in finished in another browser, and `accessTokens` holds the tokens it issued, for a test that no page shows one. A person's `emailVerified: false` tests what an unverified email at the provider can reach. --- # Better Auth's tables Better Auth reads and writes five tables, which the application's schema declares beside its own: `user`, `session`, `account`, `verification`, and `rateLimit`, where `auth.signInLimits` counts each account's sign-ins. `npx @better-auth/cli generate` writes them from the configuration; these are the tables Better Auth 1.7 needs for it, in a module `src/schema.ts` re-exports with `export * from './auth-schema'`. Their keys in the schema are the names Better Auth looks them up by; the SQL names are the application's to choose. A column the application adds to the user, such as a role, goes in `user`, and `betterAuthFields` tells Better Auth about it.
Better Auth's tables on Postgres ```ts // src/auth-schema.ts import { bigint, boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' export const user = pgTable('user', { id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), emailVerified: boolean('email_verified').notNull().default(false), image: text('image'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }) export const session = pgTable( 'session', { id: text('id').primaryKey(), expiresAt: timestamp('expires_at').notNull(), token: text('token').notNull().unique(), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull(), ipAddress: text('ip_address'), userAgent: text('user_agent'), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), }, (table) => [index('session_user_id_idx').on(table.userId)] ) export const account = pgTable( 'account', { id: text('id').primaryKey(), accountId: text('account_id').notNull(), providerId: text('provider_id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), accessToken: text('access_token'), refreshToken: text('refresh_token'), idToken: text('id_token'), accessTokenExpiresAt: timestamp('access_token_expires_at'), refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), scope: text('scope'), password: text('password'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull(), }, (table) => [index('account_user_id_idx').on(table.userId)] ) export const verification = pgTable( 'verification', { id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(), expiresAt: timestamp('expires_at').notNull(), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => [index('verification_identifier_idx').on(table.identifier)] ) // Each account's sign-ins, which auth.signInLimits counts. export const rateLimit = pgTable('rate_limit', { id: text('id').primaryKey(), key: text('key').notNull().unique(), count: integer('count').notNull(), lastRequest: bigint('last_request', { mode: 'number' }).notNull(), }) ```
Better Auth's tables on Cloudflare D1 ```ts // src/auth-schema.ts on Cloudflare D1, in place of the Postgres one import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' const createdAt = () => integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()) const updatedAt = () => integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()) export const user = sqliteTable('user', { id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), image: text('image'), createdAt: createdAt(), updatedAt: updatedAt(), }) export const session = sqliteTable( 'session', { id: text('id').primaryKey(), expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), token: text('token').notNull().unique(), createdAt: createdAt(), updatedAt: updatedAt(), ipAddress: text('ip_address'), userAgent: text('user_agent'), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), }, (table) => [index('session_user_id_idx').on(table.userId)] ) export const account = sqliteTable( 'account', { id: text('id').primaryKey(), accountId: text('account_id').notNull(), providerId: text('provider_id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), accessToken: text('access_token'), refreshToken: text('refresh_token'), idToken: text('id_token'), accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }), refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }), scope: text('scope'), password: text('password'), createdAt: createdAt(), updatedAt: updatedAt(), }, (table) => [index('account_user_id_idx').on(table.userId)] ) export const verification = sqliteTable( 'verification', { id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(), expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), createdAt: createdAt(), updatedAt: updatedAt(), }, (table) => [index('verification_identifier_idx').on(table.identifier)] ) // Each account's sign-ins, which auth.signInLimits counts. export const rateLimit = sqliteTable('rate_limit', { id: text('id').primaryKey(), key: text('key').notNull().unique(), count: integer('count').notNull(), lastRequest: integer('last_request').notNull(), }) ```
--- # Auth The Better Auth helpers cover session loading, authenticated and guest routes, form actions, logout, and public user projection. `authorize()` requires a signed-in user and returns the parsed `{ user, session }`. Given a predicate, it also requires the route's own rule: ```ts const auth = yield* authorize() const verified = yield* authorize(({ user }) => user.emailVerified) ``` Without a signed-in user, both fail with `UnauthorizedError`; when the predicate returns `false`, the second fails with `ForbiddenError`. `auth.session` decodes Better Auth's session response into the signed-in user, so a dependency response is never trusted because it looks session-shaped. `betterAuthSession(tableSchema(schema.user))` takes the user from the `user` table, so a field the application adds, such as a `role`, is declared in the table, and `authorize()`, `currentUser` and `signedIn` follow. A plugin that adds session fields, such as the organization plugin's `activeOrganizationId`, adds their columns to the `session` table: give `betterAuthSession` that table too, `betterAuthSession(tableSchema(schema.user), tableSchema(schema.session))`, and `authorize()` returns them. Better Auth learns of the field from the same table: `user: { additionalFields: betterAuthFields(schema.user, { role: { input: false } }) }` in `createAuth` takes each field's type, enum values and whether it is required from its column, and `input` says whether a visitor may set it at sign-up. Give a narrowed column an enum, so the session checks its values, and take it from the Effect schema the application validates input with, so the role is declared once: `text('role', { enum: Role.literals })` for `const Role = S.Literals(['admin', 'member'])`. The session token is `Redacted`, so serializing the auth user never exposes it; read it with `Redacted.value` where it is needed. `auth.share` names the fields pages receive as `auth.user`. Machine clients, such as a door scanner calling a JSON API, authenticate with Better Auth's `bearer()` plugin: a request with `Authorization: Bearer ` loads its user as a session cookie does, with no setup of the framework's own. ## Better Auth's client `setupWeb`'s `auth.client` builds Better Auth, on [its tables](auth-tables.md), from a small factory beside `src/db.ts`, which the [Quick start](setup.md#3-configure-the-application-once-and-register-it) shows: ```ts // src/auth.ts import { betterAuth, type BetterAuthOptions } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import type { Database } from './db' import { paths } from './paths' import * as schema from './schema' export interface CreateAuthOptions { readonly db: Database readonly secret: string /** The origin emailed links name: a declared var, never the request's URL. */ readonly baseURL: string /** Delivers an email through the application's mail provider. */ readonly sendEmail: (email: { readonly to: string; readonly subject: string; readonly text: string }) => Promise readonly advanced?: BetterAuthOptions['advanced'] } // Better Auth's tables (user, session, account, verification, rateLimit) // belong in ./schema, as auth-tables.md shows. export function createAuth(options: CreateAuthOptions) { return betterAuth({ database: drizzleAdapter(options.db, { provider: 'pg', schema }), secret: options.secret, baseURL: options.baseURL, // A password signs in only once its address is proven, so an account // registered with someone else's address holds no session, password or // two-factor challenge when its owner signs in by a link or a provider. emailAndPassword: { enabled: true, requireEmailVerification: true }, // The link proves the address and signs nobody in: the member signs in next. emailVerification: { sendVerificationEmail: ({ user, token }) => options.sendEmail({ to: user.email, subject: 'Confirm your email address', text: `Confirm your email address to finish signing up:\n${options.baseURL}${paths.verifyEmail}?${new URLSearchParams({ token })}\n\n` + 'If you did not sign up, ignore this email and do not follow the link: ' + 'whoever signed up with your address chose its password, and confirming it would let them sign in as you.', }), }, // setupWeb's auth.signInLimits limits signing in, up and every password // call by address and account. Better Auth's own limits stay on too, in // each isolate's memory (its 'database' storage deletes the accounts' // counts, so auth.signInLimits refuses it): three sign-ins or sign-ups // from one address in ten seconds, and its other endpoints. They are on // by default only when NODE_ENV is 'production', which a Worker's is not. rateLimit: { enabled: true, storage: 'memory' }, advanced: { ...options.advanced, // Cloudflare's header, which a client cannot write; X-Forwarded-For it can. ipAddress: { ipAddressHeaders: ['cf-connecting-ip'], ...options.advanced?.ipAddress }, }, }) } export interface CreateStatelessAuthOptions { readonly secret: string readonly advanced?: BetterAuthOptions['advanced'] } // Better Auth without a database, for an application without accounts of its own. export function createStatelessAuth(options: CreateStatelessAuthOptions) { return betterAuth({ secret: options.secret, advanced: options.advanced }) } ``` The `backgroundTasks` object has Better Auth's `advanced.backgroundTasks` shape. Passing it through makes session refreshes, email callbacks, and plugin work part of the request lifecycle: Workers use `waitUntil`, while local and test runtimes finish the work before teardown. Actions call Better Auth with `betterAuthRedirect` and `betterAuthCall`, as [Signing in and out](sign-in.md) shows: they name the endpoint and send the call through Better Auth's request handler, so its rate limits, origin checks and plugins' hooks apply. An action reads the signed-in user with `authorize()` or `currentUser`, never with a `getSession` call. Code outside an action, such as a Durable Object reading the session from the headers a request handed it, calls Better Auth's own `auth.api`, as Better Auth documents. A refusal it throws is Better Auth's `APIError`, which code can recognise without importing it: its `name` is `'APIError'`, its `statusCode` the HTTP status and its `body.code` the reason, such as `INVALID_EMAIL_OR_PASSWORD`. ## Proving an address `createAuth` signs no one in by a password whose address nobody has proven. Signing up sets no session: Better Auth emails the link `sendEmail` delivers, to `paths.verifyEmail`, and the sign-up action redirects to a page asking the visitor to check their inbox; a password sign-in before then is refused as `Email not verified`. A sign-up for a registered address is answered as a new one is, and sends nothing, so the form tells no one who has an account. The mailer is the application's own: `src/email.ts` exports `sendEmail(env, { to, subject, text })`, which calls its mail provider's API. The link opens a page whose button posts its token back, so a mail scanner that fetches the link proves nothing. The post proves the address and signs nobody in; the member signs in with a password next: ```ts // src/actions/verify-email.ts import { Effect, Schema as S } from 'effect' import { action, redirect, render, validateRequest } from '@popcomputer/web/effect' import { betterAuthRedirect } from '@popcomputer/web/auth' import { href } from '@popcomputer/web/paths' import { requiredString } from '@popcomputer/web/schema' import { paths } from '../paths' const Link = S.Struct({ token: requiredString }) const expired = () => redirect(href(paths.login, {}, { error: 'link_expired' })) export const showVerifyEmail = action( Effect.gen(function* () { const input = yield* validateRequest(Link) return yield* render('Auth/VerifyEmail', input) }).pipe(Effect.catchTag('ValidationError', expired)) ) export const verifyEmail = action( Effect.gen(function* () { const input = yield* validateRequest(Link) return yield* betterAuthRedirect('verifyEmail', { query: input }, { redirectTo: paths.login }) }).pipe(Effect.catchTag('ValidationError', expired)) ) ``` Register both for everyone, `routes.get(paths.verifyEmail, showVerifyEmail)` and `.post(paths.verifyEmail, verifyEmail)`, beside the page `'Auth/VerifyEmail': S.Struct({ token: S.String })`; a link that expired, after an hour, lands on the sign-in page with `?error=`. Email this page's link, not Better Auth's own `url`, which proves the address on any fetch. A link Better Auth's `user.changeEmail` sends to a new address completes only signed in to the account that asked to move there: Better Auth would sign anyone else who follows it in to that account, so the framework sends them to sign in. Without `requireEmailVerification`, someone who registers another person's address first is signed in, and keeps what the owner's magic-link or provider sign-in does not revoke: a two-factor challenge or a password sign-in under way. Leave `autoSignInAfterVerification` off, or an owner who follows a link for an account someone else registered is signed in to it. One risk remains, and the email warns of it: an owner who follows the link for an account they never created proves the address for whoever chose its password, which then signs in, and a later magic-link sign-in revokes nothing. A password reset takes the account back: an application that adds one sets `emailAndPassword.revokeSessionsOnPasswordReset: true`, so a reset also ends the sessions the old password began. ## Limiting password guessing Better Auth's own limits count each client address, so guesses at one account's password spread across a botnet's addresses are never slowed, and each one costs a password hash. `setupWeb`'s `auth.signInLimits` limits both, cheapest first: - A Cloudflare Workers Rate Limiting binding counts each client address's sign-in, sign-up and password calls, an IPv6 address's by its network, whose owner can send from any address in it: its first `ipv6Prefix` bits, 64 by default. It counts each account's sign-ins too, at Cloudflare's edge, writing nothing. `limiter` names it, one `src/env.ts` types `RateLimit`. Declare it in `wrangler.jsonc`; its `period` is 10 or 60 seconds: ```jsonc "ratelimits": [{ "name": "SIGN_IN_LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }] ``` - `table`, Better Auth's `rateLimit` table, counts each account's sign-ins before their passwords are checked, so guesses sent together count against each other. Past `failures.max`, 10, a sign-in is refused unhashed for `failures.cooldown`, a minute, doubled by each further one up to `failures.window`, 15 minutes, which the count lasts past its cooldown's end. A cooldown, not a lock: someone who knows an address can slow its owner down, never shut them out. A success deletes the row, whose key is a hash of the account, not its address. An account is what its sign-in's endpoint finds it by, whatever else the body sends: the email `/sign-in/email` reads, the phone-number plugin's `/sign-in/phone-number`, or the username plugin's `/sign-in/username` as its `usernameNormalization` folds it, so the spellings it folds share one count. A password sign-in whose field is missing, blank or not text is refused with a 400 before its password is checked. Each door counts apart: an account that signs in by email and by username has a budget for each. `betterAuthRedirect`, `betterAuthCall` and `betterAuthHandler` all apply the limits. A refused call fails with `AuthRateLimitError`, whose `retryAfterSeconds` the 429 sends as `Retry-After`. Rows for accounts nobody tries again stay until a sweep deletes them; run `sweepSignInFailures` from `@popcomputer/web/auth` from a Cron Trigger: ```ts import { sweepSignInFailures } from '@popcomputer/web/auth' export default { fetch: app.fetch, scheduled: routes.scheduled(() => sweepSignInFailures), } ``` In tests, give the binding `testRateLimiter({ limit, period })` from `@popcomputer/web/testing` among the bindings, and read the keys it refused from its `refused`. It counts by the wall clock, as Cloudflare's does, not by `testClock`, so a test of cooldowns gives it a limit the test never reaches. A request whose bindings lack the limiter fails with a message naming it. --- # Background, scheduled and queued work ## Work after the response For runtime-owned work that must outlive a response, name the work and pass the Effect. The name identifies the work in error reports: ```ts yield* background('audit.send', sendAuditEvent(event)) ``` In a Worker, background work starts at once and runs beside the rest of the action: the response does not wait for it, and the Worker's execution context keeps it alive after the response. Without an execution context, as in tests, it runs where it is called, before the response returns, so a test sees its effects but not a race between them; pass an execution context as `app.request`'s fourth argument to test concurrent behaviour. Tags the work names with `purgeCache` are purged once it succeeds, and a failed purge is reported as the work's failure. Work handed to `ExecutionContextService`'s `runInBackground` or `schedule` purges the same way; `purgeCache` called by work that outlived what it belongs to, such as a detached fiber, is a defect rather than a purge nothing would send. ## Scheduled and queued work A Cron Trigger's and a Queue consumer's work runs with the application's services, as an action does: the database, the layers given to `web.services`, `Config` and `background()`, and the layers a builder was given with `provide`. `routes.scheduled(job)` and `routes.queue(Schema, handle)` return the Worker's `scheduled` and `queue` handlers, and type-check the job as a registration does, so one that needs a service the routes do not provide is a `missingServices` error. ```ts import { lt } from 'drizzle-orm' import { DateTime } from 'effect' import { orders } from '../schema' const forgetAbandonedOrders = Effect.gen(function* () { const cutoff = DateTime.toDateUtc(DateTime.subtract(yield* DateTime.now, { days: 30 })) yield* dbMutation((db) => db.delete(orders).where(lt(orders.paidAt, cutoff))) }) // What the queue carries is declared once, as a schema. const Reminder = S.Struct({ orderId: S.String }) export default { fetch: app.fetch, scheduled: routes.scheduled((event) => event.cron === '0 3 * * *' ? forgetAbandonedOrders : Effect.void), queue: routes.queue(Reminder, (reminder) => Effect.log('reminder', reminder.orderId)), } ``` A scheduled job that fails rejects, so the platform records a failed run. The queue handler decodes each message's body with the schema and handles the messages one at a time: one whose work succeeds is acknowledged, and one whose body does not decode, or whose work fails, is logged and left for the queue to retry, after which the queue drops it or moves it to its dead-letter queue. The handler also receives the message's `id` and `attempts`, for work that must happen once however often a message is delivered. A job whose `purgeCache` purge fails has failed too: it rejects, and the queue retries the message. A test calls the handlers as the platform does, with `testEnv`'s bindings, so its fakes, clock and database apply. `testBatch` from `@popcomputer/web/testing` makes a batch that records which messages were acknowledged and which were left to retry: ```ts import { testBatch, testEnv } from '@popcomputer/web/testing' const worker = { scheduled: routes.scheduled(() => Effect.log('nightly sweep')), queue: routes.queue(S.Struct({ orderId: S.String }), (reminder) => Effect.log(reminder.orderId)), } const time = await testClock() await worker.scheduled({ cron: '0 3 * * *', scheduledTime: Date.now() }, testEnv(time.layer, testBindings)) const batch = testBatch([{ orderId: 'order-1' }, { orderId: 42 }]) await worker.queue(batch, testEnv(testBindings)) batch.acked() // [{ orderId: 'order-1' }] batch.retried() // [{ orderId: 42 }], which does not decode ``` `routes.run(effect, env, ctx)` runs any other Effect the same way, such as a one-off job from a script, resolving with its value. ## Other patterns - **Work after the response**, such as a confirmation email: `background('order-email', mailer.send(...))`. Its failure is reported, not shown to the visitor, so it must not be the only record of something the visitor needs. --- # Constraints keep invariants that concurrent requests would break A rule the database can state is safest stated there: a `CHECK (stock >= 0)` on the stock column, a unique index on an order's idempotency key, a unique event id for a webhook. A write that would break one fails with `DatabaseConstraintViolation`, whose `constraint` says which kind (`'unique'`, `'check'`, `'foreign-key'`, `'not-null'`, `'exclusion'`, `'trigger'`) and `constraintName` which one, and the whole `dbTransaction`, or the whole `dbBatch` on Cloudflare D1, is undone. A rule no `CHECK` or unique index can state, such as no two interviews of one interviewer overlapping, is an exclusion constraint on Postgres (`EXCLUDE USING gist`, which on a text or uuid column needs `CREATE EXTENSION IF NOT EXISTS btree_gist` in a custom drizzle-kit migration, and PGlite in tests `new PGlite({ extensions: { btree_gist } })` from `@electric-sql/pglite/contrib/btree_gist`) and a trigger on SQLite and D1 (`SELECT RAISE(ABORT, 'interviewer_double_booked')`, in a migration from `drizzle-kit generate --custom`; a time window it counts compares the rows' own timestamps with the new row's, `NEW.created_at`, rather than `unixepoch()`, so the test clock moves it), whose raised text is the `constraintName`. `constraintFieldErrors({ leases_no_overlap: { startDate: 'Another lease covers these dates.' } })` shows a named rule of any kind beside the fields it covers. The same action works on both, with no count-then-write race: ```ts import { eq, sql } from 'drizzle-orm' import { positiveInt, requiredString } from '@popcomputer/web/schema' import { orders, products } from '../schema' const Order = S.Struct({ productId: requiredString, quantity: positiveInt }) export const placeOrder = action(Effect.gen(function* () { const auth = yield* authorize() const request = yield* RequestService const input = yield* validateRequest(Order) // The client sends one key per checkout, so a double submit repeats it. const idempotencyKey = request.header('Idempotency-Key') ?? crypto.randomUUID() return yield* dbTransaction(Effect.gen(function* () { yield* dbMutation((db) => db.insert(orders).values({ id: crypto.randomUUID(), userId: auth.user.id, idempotencyKey, ...input, })) yield* dbMutation((db) => db.update(products) .set({ stock: sql`${products.stock} - ${input.quantity}` }) .where(eq(products.id, input.productId)) ) })).pipe( Effect.andThen(flash('success', 'Order placed.')), Effect.andThen(redirect(paths.dashboard)), Effect.catchTag('DatabaseConstraintViolation', (violation) => Effect.gen(function* () { if (violation.constraint === 'check') { return yield* invalid({ quantity: 'There are not that many left.' }) } // The first submit already placed this order. if (violation.constraint === 'unique') return yield* redirect(paths.dashboard) return yield* violation })) ) })) ``` A handler whose branches end differently, as this one does, branches inside `Effect.gen`: TypeScript combines the branches of one generator, but not a plain function that returns a different Effect from each branch. `columns` names the columns as the database reports them: snake_case SQL names on D1 (`['idempotency_key']`), which `uniqueFieldErrors` matches against the schema's camelCase keys too. An index on an expression, such as `lower(email)`, reports no columns, only its name: `uniqueFieldErrors({ email: 'A contact has this email.' }, { constraints: { contacts_email_lower_unique: 'email' } })` puts it on its field. SQLite and D1 report a constraint by its columns instead, so name a composite key that belongs on another field by them, in any order and in either case: `{ constraints: { 'org_id,user_id': 'email' } }`. A composite key reports each of its columns, so a message keyed by one of them, `{ sku: 'Taken.' }` for a key on `(company_id, sku)`, already matches; `constraints` is for a field that is not one of them. In development, the error page of a violation no mapping matched shows the `constraints` entry that would map it. The kind, columns and name come from the database's own report, never from the statement's values, which Drizzle's error repeats: a submitted value that reads like a constraint message changes none of them. On SQLite, only a constraint's report counts, a `SQLITE_CONSTRAINT` code or D1's message ending in that result: its other errors, such as a JSON path or a search it rejects, quote the value and stay a `DatabaseError`. An update of a row that is not there changes nothing and fails nothing: read `meta.changes` on D1, or `.returning()`, to tell. SQLite checks a `CHECK` before an upsert's conflict, so `INSERT ... ON CONFLICT DO UPDATE` whose inserted values break the check fails even when the row exists; insert a starting row with `onConflictDoNothing()` and update it after, in the same batch. On D1, write the same statements as one `dbBatch((db) => [...])`. They run in order and the first that breaks a constraint undoes the batch, so it is the one whose violation the action sees: put the statement a retry depends on, such as an optimistic lock's unique sequence, first. A violation the action does not map answers 409. A batch can also be a list built at run time, such as one statement per line of a form, `dbBatch((db) => lines.map((line) => db.update(...)))`, whose results are a list; a helper that returns statements for a batch takes the `db` the batch passes it, typed `DatabaseType` from `@popcomputer/web/effect`, and types them `BatchStatement`. A batch cannot branch on an earlier statement's result, so a write that depends on one carries its condition in its own `WHERE`: an insert selects its row, `rowOf(table, values)` from `@popcomputer/web/drizzle`, from a query that returns no row when the condition fails, and an update adds `WHERE EXISTS (...)`. `rowOf` gives every column of the table in its order, the values given bound as their columns encode them, and the columns left out their defaults, so a misnamed column is a type error; a value may be SQL or a column of the table the insert selects from. An inserting statement's `.returning()`, or an update's `meta.changes`, tells the action whether it applied: ```ts import { and, eq, lt, sql } from 'drizzle-orm' import { rowOf } from '@popcomputer/web/drizzle' import { tasks } from '../schema' // Adds the task only while its project holds fewer than 100; returns no row otherwise. const addTaskIfRoom = (projectId: string, title: string) => dbMutation((db) => db.insert(tasks).select( db.select(rowOf(tasks, { id: crypto.randomUUID(), projectId, title })) .from(projects) .where(and( eq(projects.id, projectId), lt(sql`(select count(*) from ${tasks} where ${tasks.projectId} = ${projects.id})`, 100) )) ).returning({ id: tasks.id }) ) ``` When a limit cannot be a constraint, a guarded update such as `UPDATE ... WHERE (SELECT count(*) ...) < 100` that changed no row (`meta.changes === 0`) is the limit refusing it. A statement can depend on the one before it through SQLite's `changes()`, the rows the previous statement changed: ``sql`changes() = 1` `` in an insert-select's `WHERE` writes a log row only after an update that applied, in the same batch. A value the database computes, such as the next ticket number in an organization, belongs in the insert: ``sql`(SELECT coalesce(max(number), 0) + 1 FROM tickets WHERE organization_id = ${organizationId})` ``. Pass the outer row's value as a parameter, as here: in a select list Drizzle writes a column without its table, so ``${organizations.id}`` inside that subquery names the subquery's own table and silently computes the wrong value. Name it in full, ``${organizations}.${sql.identifier('id')}``, when it must be a column; in a `WHERE` Drizzle qualifies columns itself. Write every statement with the query builder: Drizzle's D1 batch fails at runtime on `db.run(sql)` with parameters, which is a type error there. D1 binds at most 100 parameters to a statement, so an `IN (...)` over many ids, or an insert of many rows, goes in chunks. --- # The data cache The data cache keeps computed values in a store, checked against an Effect Schema when read. Provide a store with `kvCache` as a `request` layer of `web.services`; KV keeps an entry at least 60 seconds, its shortest expiry, and `cache` still treats it as fresh only for its `ttl`, counted from when the value is stored, not from when its computation read its data. `kvCache` keeps its keys under `popweb:cache:`, so its KV namespace can hold the application's other data, such as an audit log, which the cache never reads, lists or deletes, even for `cacheInvalidatePrefix('')`: ```ts import { kvCache } from '@popcomputer/web/cache' export const { app, routes } = web.services({ request: (c) => kvCache(c.env.KV), }) ``` Then wrap a computation in `cache`: ```ts import { eq } from 'drizzle-orm' import { Duration, Effect, Schema as S } from 'effect' import { action, authorize, dbQuery, render } from '@popcomputer/web/effect' import { cache } from '@popcomputer/web/cache' import { columnsOf } from '@popcomputer/web/schema' import { projects } from '../schema' export const listProjects = action(Effect.gen(function* () { const auth = yield* authorize() const summaries = yield* cache( `users:${auth.user.id}:projects`, dbQuery((db) => db.select(columnsOf(projects, ProjectSummary)).from(projects).where(eq(projects.userId, auth.user.id)) ), S.Array(ProjectSummary), { ttl: Duration.minutes(5), swr: Duration.minutes(1), version: true } ) return yield* render('Projects/Index', { projects: summaries, next: null }) })) ``` The computation is any Effect, here a `dbQuery`; it runs only when the entry is missing or stale, and its result is stored through the schema's JSON form, so a `Date` comes back as a `Date`. ## Tagging cached data A value that depends on records takes their cache tags, the names a route's `purges` and `purgeCache` use (see [Time-based caching with Workers Cache](page-cache.md#time-based-caching-with-workers-cache)). A route that purges one of them after a write invalidates the value as well as the cached responses, so what changed is stated once, by the write: ```ts const summary = yield* cache( `projects:${project.id}:summary`, summarizeProject(project.id), ProjectSummary, { ttl: Duration.minutes(5), tags: [recordCacheTag('projects', project.id)] } ) ``` A tag gets a generation in the store when an entry first uses it, and each purge of the tag replaces it, so an entry stored under an older one is a miss, for `cacheGet` too. `cache` reads the generations before it computes, so a purge during the computation leaves its value a miss. So does `cacheSet` given the Effect that reads its value, since it reads them before running that Effect: ```ts yield* cacheSet(`projects:${project.id}:summary`, summarizeProject(project.id), ProjectSummary, { ttl: Duration.minutes(5), tags: [recordCacheTag('projects', project.id)], }) ``` Given a value read earlier, `cacheSet` records them as they are when it runs, and cannot know when the value was read: a value read before a write whose purge ran before `cacheSet` stays until its `ttl`. Give a tagged `cacheSet` its read, or compute the value through `cache`. A tagged read costs one store read per tag besides the entry's own; a purge of a tag no entry uses costs a read and no write. KV accepts one write a second to a key and takes up to a minute to show a write everywhere, so tag data by the records it reads rather than by a table written many times a second. ## When the store fails `cache` never fails because of the store: an unreachable store or an entry that no longer matches the schema computes the value instead, and the failure goes to `EffectErrorObserverService`. `cacheGet` and `cacheSet` fail with `CacheError`, which renders as 503. `cacheInvalidate` and `cacheInvalidatePrefix`, which follow a write that has already happened, report a store failure instead of failing the request; the entry stays until it expires. Stale-while-revalidate refreshes in the background when the request has an execution context and inline otherwise. ## Caching an API that authenticates each request The route `cache` option never stores a response to a request with an `Authorization` header: it answers `no-store`. Workers Cache answers a hit before the Worker runs, so a stored response would reach a revoked key and skip the key's rate limit. Authenticate every request and cache the data the response is built from instead, keyed by what it depends on, such as the project an API key belongs to, so every key of that project shares one entry. Tag it with the records it reads, and the writes that purge them invalidate it: `purges: true` on `PUT /projects/{project}/tasks/{task}` purges the project's tag. ```ts export const projectTasks = action(Effect.gen(function* () { // The key's project, or new UnauthorizedError({ message: 'This API key is not valid.', challenge: 'Bearer' }), // which a JSON client receives as a 401 with WWW-Authenticate: Bearer. const project = yield* apiKeyProject const tasks = yield* cache( `projects:${project.id}:tasks`, loadTasks(project.id), S.Array(Task), { ttl: Duration.seconds(30), tags: [recordCacheTag('projects', project.id)] } ) return yield* json(S.Struct({ tasks: S.Array(Task) }), { tasks }) })) ``` --- # The database The database operations read the request's client from `DatabaseService` and give their callback the application's Drizzle client with its own types: | Operation | Use | | --- | --- | | `dbQuery((db) => ...)` | Reads. | | `dbMutation((db) => ...)` | One write, or writes that need not be atomic. | | `dbTransaction(effect)` | An Effect whose database operations must succeed together, on a driver with asynchronous transactions (Postgres, MySQL, libsql). | | `dbBatch((db) => [...])` | Writes that must succeed together on Cloudflare D1, libsql or Neon HTTP: every statement applies, or none. | Inside `dbTransaction`, `DatabaseService` is the transaction, so every `dbQuery` and `dbMutation` the Effect runs takes part, including those of the services it calls. Domain logic between the writes stays ordinary Effect code: the transaction commits when the Effect succeeds, and rolls back when it fails with the Effect's own typed error. ```ts // src/actions/projects/destroy.ts import { eq } from 'drizzle-orm' import { Effect } from 'effect' import { action, bound, dbMutation, dbTransaction, flash, redirect } from '@popcomputer/web/effect' import { paths } from '../../paths' import { projects, tasks } from '../../schema' export const destroyProject = action( Effect.gen(function* () { // Its owner's only: the binding is ownedBy 'userId'. const project = yield* bound('project') yield* dbTransaction(Effect.gen(function* () { yield* dbMutation((db) => db.delete(tasks).where(eq(tasks.projectId, project.id))) yield* dbMutation((db) => db.delete(projects).where(eq(projects.id, project.id))) })) yield* flash('success', 'Project deleted.') return yield* redirect(paths.projects) }) ) ``` `dbTransaction` is a type error on Cloudflare D1, which rejects interactive transactions, and on synchronous drivers such as `bun:sqlite`, which commit before an awaited write runs. On D1, use `dbBatch`. A batch cannot decide from a read, so a rule such as "at most 100 open tasks" belongs in the write itself (`INSERT ... SELECT ... WHERE (SELECT count(*) ...) < 100`, then check the number of rows changed); a count followed by an insert lets concurrent requests pass the limit together. A write that breaks a constraint fails with `DatabaseConstraintViolation`, which names the constraint kind (`unique`, `foreign-key`, `not-null`, `check`) and, when the driver reports them, its `columns`. Any other failure is a `DatabaseError`. `uniqueFieldErrors` turns a unique violation into field errors the form shows: ```ts yield* dbMutation((db) => db.insert(projects).values(newProject)).pipe( uniqueFieldErrors({ slug: 'A project with this slug already exists.' }) ) ``` ## Migrations The package does not run migrations. drizzle-kit generates them from `src/schema.ts`, and the demo's README shows the commands that generate and apply them, in [Changing the database](https://github.com/PatrickOgilvie/popcomputer-web-demo#changing-the-database). [A database for tests](test-database.md) applies the same migrations to a test's database. ## Lists page through with a cursor A list that grows pages through it by the last row shown, not by an offset: a row added or removed between two requests shifts every offset, and the database counts and skips every row before one. Order by a column that orders the rows and a unique one that breaks ties, read one row more than the page holds to learn whether another page follows, and render the cursor of the last row shown as the page's `next`. The query decodes through `validateRequest`, so a malformed cursor is a 422 rather than a database error, and an empty search box is `null`: ```ts // src/actions/projects/list.ts import { and, desc, eq, ilike, lt, or } from 'drizzle-orm' import { Effect } from 'effect' import { action, authorize, dbQuery, render, validateRequest } from '@popcomputer/web/effect' import { ProjectFilters } from '../../forms' import { projects } from '../../schema' const PAGE_SIZE = 25 export const listProjects = action(Effect.gen(function* () { const auth = yield* authorize() const filters = yield* validateRequest(ProjectFilters) const after = filters.beforeAt !== null && filters.beforeId !== null ? { at: filters.beforeAt, id: filters.beforeId } : null const rows = yield* dbQuery((db) => db.select({ id: projects.id, name: projects.name, createdAt: projects.createdAt }) .from(projects) .where(and( eq(projects.userId, auth.user.id), filters.q === null ? undefined : ilike(projects.name, `%${filters.q}%`), after === null ? undefined : or( lt(projects.createdAt, after.at), and(eq(projects.createdAt, after.at), lt(projects.id, after.id)) ) )) .orderBy(desc(projects.createdAt), desc(projects.id)) .limit(PAGE_SIZE + 1) ) const shown = rows.slice(0, PAGE_SIZE) const last = shown.at(-1) return yield* render('Projects/Index', { projects: shown, next: rows.length > PAGE_SIZE && last !== undefined ? { beforeAt: last.createdAt, beforeId: last.id } : null, }) })) ``` The page links to the next one with `href(paths.projects, {}, next)`, as [Forms](forms.md) shows; a search carries on through the pages when the link adds it, `{ ...next, q }`. The page's schema drops `createdAt` from each row it shows, since `ProjectSummary` does not name it. An index on the order's columns, `(user_id, created_at, id)`, lets each page start where the last one ended. On Postgres, declare a cursor's timestamp as `timestamp('created_at', { precision: 3, withTimezone: true })`. The cursor travels as a JavaScript `Date`, which holds milliseconds, while a plain `timestamp` holds microseconds, so a row created within the same millisecond as the last one shown, but before it, falls between the pages and is never listed. A `timestamp` without a time zone also reads in the process's zone: Workers run in UTC, but a test on a machine in another zone shifts every cursor by its offset. --- # Deploying The demo's README shows the commands that deploy an application with Wrangler, in [Deploying](https://github.com/PatrickOgilvie/popcomputer-web-demo#deploying). What the framework does around a deploy: - The Worker's entry imports every module of registrations, as [Wire the Worker](setup.md#6-wire-the-worker) shows. - Each deploy starts the page cache empty once `wrangler.jsonc` binds the version metadata, as [Page caching](page-cache.md) shows. - The sign-in limiter is a Rate Limiting binding declared in `wrangler.jsonc`, as [Limiting password guessing](auth.md#limiting-password-guessing) shows. - Each isolate checks the Worker's vars and secrets against `src/env.ts` before it serves, and a script checks them before a deploy with `checkEnv`, as [Vars and secrets](env.md) shows. ## Version values Three different versions may appear around an application: | Value | Meaning | Change it when | Effect of changing it | |---|---|---|---| | npm package version | Version of `@popcomputer/web` | You upgrade the dependency | Selects framework code and public API | | `setupWeb({ version })` | Inertia asset version | Client assets or their manifest change | The next mismatched GET receives an Inertia location response and reloads the current assets | | cache `version` | Namespace for one cached value shape | The cached schema or interpretation changes | Reads and writes use a fresh cache key | `createVersion(manifest)` derives the Inertia asset version from manifest asset paths. It is unrelated to the npm package version. An application without a client build yet, or a test, passes a constant (`version: '1'`) and changes it when the client does; export it so tests send the same `X-Inertia-Version`. The asset version reloads a visitor already on the site when a deploy changes the client. A page loaded during a deploy needs more: for a few seconds after `wrangler deploy`, the previous version can still answer at some edge locations, with a page naming the previous build's scripts, which the new deploy no longer serves, and the page would stay blank. `createTemplate` inlines a small script first in every page's head that reloads the page when one of the template's own scripts or stylesheets fails to load, or a chunk Vite loads does (`vite:preloadError`). It reloads at once, then after 2 and 4 seconds, and no more than three times a minute in a tab, so a file missing for another reason never reloads the page in a loop; a script the application adds through `head`, such as an analytics tag a blocker refuses, never reloads it. An application that sends its own `Content-Security-Policy` with a `script-src` allows the script with `TEMPLATE_SCRIPT_HASH` from `@popcomputer/web`: ```ts import { TEMPLATE_SCRIPT_HASH } from '@popcomputer/web' const scriptSrc = `script-src 'self' ${TEMPLATE_SCRIPT_HASH}` ``` --- # JSON endpoints A JSON API declares each endpoint once, as a contract: its method, its path from `src/paths.ts`, the query, body and headers it reads, the body of a success with its status, and the failures it declares, each with the status it answers. Everything else derives from the contract, so nothing about an endpoint is written twice: the action receives its input decoded and returns the success value, the route takes its method and path from it, a client or a test calls it with typed input and reads a typed result, and `openApi` describes every endpoint for a published reference. The module imports only `effect`, the validators and the paths, so a React bundle or a separate client imports it without the server. An application that is only an API leaves `version` and `render` out of `setupWeb`: without a template it answers only JSON, every failure an unknown path's included, whatever the client asks for, and rendering a page is a configuration error. ```ts // src/endpoints.ts import { Schema as S } from 'effect' import { endpoint } from '@popcomputer/web/endpoint' import { alphaDash, coercedNumber, nullableString, requiredString } from '@popcomputer/web/schema' import { paths } from './paths' export const ApiProject = S.Struct({ id: S.String, name: S.String, slug: S.String, createdAt: S.Date, }) export class SlugTaken extends S.TaggedError()('SlugTaken', { slug: S.String, message: S.String, }) {} export const endpoints = { listProjects: endpoint.get(paths.apiProjects, { summary: 'List your projects, newest first.', auth: true, query: S.Struct({ limit: coercedNumber, q: nullableString }), success: S.Struct({ data: S.Array(ApiProject) }), }), createProject: endpoint.post(paths.apiProjects, { summary: 'Create a project.', auth: true, body: S.Struct({ name: requiredString, slug: alphaDash }), success: ApiProject, status: 201, errors: { 409: SlugTaken }, }), showProject: endpoint.get(paths.apiProject, { auth: true, success: ApiProject }), deleteProject: endpoint.delete(paths.apiProject, { auth: true }), } ``` `action(endpoint, handler)` implements one. The handler receives `params` (the path's placeholders), the signed-in user as `auth` when the contract has `auth: true`, and the `query`, `body` and `headers` the contract declares, already decoded. A body is read through its JSON form, as `call` sends it and `openApi` describes it, so a `Date` arrives as its ISO string. A contract with `auth: true` is answered 401 for a client that is not signed in, before its input is read, so the requirement is declared once, where the API's description reads it too. Input that does not decode is answered `422`, with each invalid field's message in `errors`, before the handler runs. The handler returns the success value, which the response encodes through the `success` schema, so only the fields the schema declares leave the server, whatever the row holds; an endpoint without `success` answers `204`. A failure the contract declares is answered with its status in the error envelope, its `tag` the failure's `_tag` and its fields in `body`. The package's own failures, `notFound`, `forbidden`, `authorize()`'s 401 and the rest, are answered as everywhere else, and every response of an endpoint is JSON, failures included. `bound('project')` loads the model a placeholder in the contract's path names, as in any action, and `responseHeader` adds a header such as a created record's `Location`. ```ts // src/actions/api/projects.ts import { and, desc, eq, ilike } from 'drizzle-orm' import { Effect } from 'effect' import { action, bound, dbMutation, dbQuery, responseHeader, } from '@popcomputer/web/effect' import { href } from '@popcomputer/web/paths' import { endpoints, SlugTaken } from '../../endpoints' import { paths } from '../../paths' import { projects } from '../../schema' export const listProjects = action(endpoints.listProjects, ({ auth, query }) => Effect.gen(function* () { const data = yield* dbQuery((db) => db.select().from(projects) .where(and(eq(projects.userId, auth.user.id), query.q === null ? undefined : ilike(projects.name, `%${query.q}%`))) .orderBy(desc(projects.createdAt)) .limit(Math.min(query.limit, 100)) ) return { data } }) ) export const createProject = action(endpoints.createProject, ({ auth, body }) => Effect.gen(function* () { const [project] = yield* dbMutation((db) => db.insert(projects) .values({ id: crypto.randomUUID(), name: body.name, slug: body.slug, userId: auth.user.id }) .returning() ).pipe( Effect.catchTag('DatabaseConstraintViolation', (violation) => Effect.fail(violation.constraint === 'unique' ? new SlugTaken({ slug: body.slug, message: `The slug ${body.slug} is taken.` }) : violation) ) ) yield* responseHeader('Location', href(paths.apiProject, { project: project.id })) return project }) ) // The binding is ownedBy 'userId': another member's project is a 404. export const showProject = action(endpoints.showProject, () => bound('project')) export const deleteProject = action(endpoints.deleteProject, () => Effect.gen(function* () { const project = yield* bound('project') yield* dbMutation((db) => db.delete(projects).where(eq(projects.id, project.id))) }) ) ``` `routes.endpoint(action)` registers it on the method and path its contract declares, with the route options other registrations take. A failure the contract does not declare is an `unhandledErrors` type error at the registration, as for any action, so the contract lists every failure a client can tell apart. Group endpoints with `.provide(...)`, or under a prefix their declared paths start with, `routes.prefix('/api/orgs/{org}')`. ```ts // src/api-routes.ts import { openApi } from '@popcomputer/web/endpoint' import { action, json } from '@popcomputer/web/effect' import { routes } from './app' import { createProject, deleteProject, listProjects, showProject } from './actions/api/projects' import { endpoints } from './endpoints' import { paths } from './paths' routes .endpoint(listProjects) .endpoint(createProject, { name: 'api.projects.store' }) .endpoint(showProject) .endpoint(deleteProject, { purges: true }) const description = openApi(endpoints, { title: 'Projects API', version: '1', security: { session: { type: 'apiKey', in: 'cookie', name: 'better-auth.session_token' } }, }) routes.get(paths.apiDescription, action(json(description))) ``` `openApi(endpoints, info)` is an OpenAPI 3.1 document: each endpoint named by its key, with its path and query parameters, headers, body, success and declared failures; the `422` of an endpoint that reads input, the `401` of one with `auth: true`, and the `404` of one whose path names a record. Give `security` the ways a client signs in, as OpenAPI security schemes, and each endpoint with `auth: true` lists them, so a generated client knows to send the session cookie or a bearer token. A validator that accepts form posts as well as JSON, such as `coercedNumber`, is described as the JSON a client sends, a number, with the limits its checks add, such as a `maxLength`. Serve it from a route, as above, or write it to a file in a build step. --- # Vars and secrets An application declares its Worker's vars and secrets once, in `src/env.ts`: each one's schema, whether it is a secret, whether the Worker needs it, and how to make a value when nobody has to choose one. The declaration types the bindings, each isolate checks its bindings against it before serving, and scripts check it locally and before a deploy. ## Declaring them The Quick start's `src/env.ts`: ```ts import { Schema as S } from 'effect' import { defineEnv, randomSecret, secret, variable, type EnvBindings } from '@popcomputer/web/env' /** The Worker's vars and secrets, each declared once. */ export const env = defineEnv({ DATABASE_URL: secret(), // Signs sessions: generated the first time, and replacing it signs everyone out. BETTER_AUTH_SECRET: secret(S.String.check(S.isMinLength(32)), { generate: randomSecret }), PAYMENT_WEBHOOK_SECRET: secret(S.String.check(S.isStartingWith('whsec_'))), // The origin Better Auth's emailed links name, never the host a request reached. APP_URL: variable(S.String), TIME_ZONE: variable(S.String, { optional: true }), }) export type Bindings = EnvBindings & { KV: KVNamespace SIGN_IN_LIMITER: RateLimit } export type Env = { Bindings: Bindings } ``` - `secret(schema, options)` declares a secret: set on Cloudflare with `wrangler secret put NAME`, and in `.dev.vars` for the development server, never in `wrangler.jsonc`, where it would be committed. `secret()` alone is any non-empty string. - `variable(schema, options)` declares a plain var, set in `wrangler.jsonc`'s `vars`. `variable()` alone is any string. - `{ optional: true }` lets the Worker run without it. Every other var and secret is required. - `{ generate }` makes a value for a secret nobody has to choose, such as a signing key: `randomSecret` makes 32 random bytes in base64. A script can then write one locally and upload one before the first deploy, as the demo's do. A secret without it must be set by hand, such as an API key another service issued. The Worker receives every var and secret as a string, so each schema decodes from one: `S.Literals(['development', 'production'])`, a string with checks, or `S.FiniteFromString` for a number. A schema that does not, such as `S.Number`, is a type error. `EnvBindings` is each value as the Worker receives it, optional ones optional; the bindings that are not vars or secrets, such as D1, KV or a rate limiter, stay in the type beside it, as `wrangler.jsonc` declares them. ## Checked when an isolate starts Pass the declaration to `setupWeb` as `env`, beside the rest of the configuration, as the Quick start's [`src/app.ts`](setup.md#3-configure-the-application-once-and-register-it) does: `setupWeb(new Hono(), { ..., env })`. Each isolate checks its bindings on its first request, Cron run or queue batch, before the database or the auth client is made; a development server, which gives each request bindings of its own, has each request's checked. A var or secret that is missing, or that its schema refuses, fails every request with `EnvInvalid`, which names each one and never a value: ```text The Worker's vars and secrets do not match the env declaration: - The secret PAYMENT_WEBHOOK_SECRET is missing. - The secret BETTER_AUTH_SECRET is invalid: Expected a value with a length of at least 32. ``` The isolate logs each failure once, through Effect's logger, so the Worker's logs name each problem in production too. The request answers 500 with the error page, code `WEB_CFG_306_ENV_INVALID`: as for any server error, the page names the problems in development, and elsewhere says only that the server failed. `routes.scheduled` and `routes.run` reject with `EnvInvalid`, so the platform records the failed run, and `routes.queue` leaves each message to retry. An empty secret counts as missing. A test's bindings go through the same check, so `testEnv` takes every required var and secret; a test of the failure leaves one out. ## Checking them before a deploy `checkEnv(env, values)` checks any set of values against the declaration and lists each problem, without its value, for a script that runs outside the Worker. `@popcomputer/web/env` imports only `effect`, so a script reads the same `src/env.ts` the Worker does: ```ts // scripts/check-env.ts import { readFileSync } from 'node:fs' import { parseEnv } from 'node:util' import { checkEnv, describeEnvProblem } from '@popcomputer/web/env' import { env } from '../src/env' const problems = checkEnv(env, parseEnv(readFileSync('.dev.vars', 'utf8'))) for (const problem of problems) console.error(describeEnvProblem(problem)) process.exitCode = problems.length === 0 ? 0 : 1 ``` The development server reads `wrangler.jsonc`'s `vars` too, with `.dev.vars` over them, so a script checking what it will see passes both. Each problem is `{ name, kind, problem, expected }`: `kind` is `'secret'` or `'variable'`, `problem` is `'missing'` or `'invalid'`, and `expected` says what the schema wants. `env.entries` lists each var and secret with its `kind`, `optional` and `generate`, for a script that sets them. The demo's scripts use both, as [its README](https://github.com/PatrickOgilvie/popcomputer-web-demo#vars-and-secrets) shows: - `bun run env:check` checks `.dev.vars` and the vars. - `bun run env:check --remote` checks every required secret is on Cloudflare. - `bun run secret NAME` checks a value before uploading it. - `bun run deploy` refuses to deploy while a secret it cannot generate is missing, and uploads one it can. ## Reading them Read a var or a secret in a service or an action with `config.string('NAME')` or `config.redacted('KEY')` from `@popcomputer/web/effect`, as [Application services](services.md) shows, and in `setupWeb`'s factories from `c.env`, as the Quick start's `auth.client` reads `c.env.BETTER_AUTH_SECRET`. Both are named by the bindings `EnvBindings` gives, so a name the declaration does not hold is a type error. --- # Forms Each form's fields are a schema in a module the action and the page both import. The action validates with it; the page imports it with `import type`, so no server code reaches the bundle, and types its form with `FormInput`: ```ts // src/forms.ts import { Schema as S } from 'effect' import { alphaDash, coercedDate, currentPassword, email, nullable, nullableString, requiredString } from '@popcomputer/web/schema' export const CreateProject = S.Struct({ name: requiredString, slug: alphaDash, description: nullableString, }) export const RenameProject = S.Struct({ name: requiredString }) // The query the project list reads: a search, and where the page starts. export const ProjectFilters = S.Struct({ q: nullableString, beforeAt: nullable(coercedDate), beforeId: nullableString, }) // A password signs in exactly as it was chosen: currentPassword never trims it. export const SignIn = S.Struct({ email, password: currentPassword }) ``` A form's fields derive from the schema its action validates them with: `useForm>({ ... })`, `FormInput` from `@popcomputer/web/react`, so a renamed field is a type error in the form as well as the action, and `const errors: FormErrors = props.errors` types the errors the form reads by the same fields. Keep form schemas in a module both sides import, `src/forms.ts`, and import them in React with `import type`, so no server code reaches the browser. The page below shows a form; [Pages and props](pages.md) shows the error page and the client's page map. A page reads its props, the shared props and `errors` from its declaration in `src/pages.ts`, and its flash values from `usePage()`. A failed post comes back to the page it was sent from, with the field errors in the form's `errors`: ```tsx // src/pages/Projects/Index.tsx import { Link, useForm, usePage } from '@inertiajs/react' import type { FormEvent } from 'react' import { href } from '@popcomputer/web/paths' import type { FormInput, WebPage } from '@popcomputer/web/react' import { RenameProjectForm } from '../../components/RenameProjectForm' import type { CreateProject } from '../../forms' import { paths } from '../../paths' const Index: WebPage<'Projects/Index'> = ({ projects, next }) => { const { flash } = usePage() const form = useForm>({ name: '', slug: '', description: null }) const submit = (event: FormEvent) => { event.preventDefault() form.post(paths.projects, { onSuccess: () => form.reset() }) } return (
{flash.success &&

{flash.success}

}
    {projects.map((project) => (
  • {project.name}
  • ))}
{next && Older projects}
{form.errors.name &&

{form.errors.name}

} {form.errors.slug &&

{form.errors.slug}

}