# apphaven config reference

Backend config lives in `apphaven/*.yaml` (any number of files; they're merged). Top-level keys:
`app`, `auth`, `models`, `operations`, `frontend`. Everything is optional — an app with no config is
a plain static site.

## app

```yaml
app:
  name: Recipe Box
  description: Share recipes with friends
```

## auth

Sign-in is on by default (email + password), with Apphaven's own accounts and database.
No external auth provider setup is needed. Optional magic links use the same app accounts.
Google/Apple sign-in for apps is not currently supported by this config; Google sign-in to
the Apphaven platform is separate. Offer a supported sign-in method and explain the gap
before proposing an external provider (see the overview).

```yaml
auth:
  enabled: true          # false = no sign-in at all
  email_password: true
  magic_link: false      # true = "email me a sign-in link" (no password needed)
  signup: open           # open | closed (closed = only existing users can sign in)
  roles: [user, admin]   # default [user, admin]
  default_role: user     # role given to new sign-ups
```

Roles are assigned with the `manage_users` tool, `action: set_role` (e.g. make the app owner an admin). Rules read the
role as `$user.role`. The owner first signs up inside the app; their Apphaven account does
not automatically create an app account or grant an app role.

Every password sign-up confirms its address: the person gets a confirmation email and can't sign
in until the link is opened (see the sdk docs for what `signUp` returns and what to show). There
is no switch; an old `verify_email:` line is ignored with a warning, so remove it. Test users
(`alice@apphaven.test`…) are confirmed on creation in the preview; the live app refuses
`@apphaven.test` addresses. Test ordinary sign-up and confirmation separately without `as`,
using the walkthrough in `get_docs` topic `sdk`, "Testing sign-in in preview".

Sign-in mail needs no setup: password reset is built in (`app.auth.requestPasswordReset`), and
confirmation and `magic_link` emails send their links automatically. Emails come from
`"<App name>" <<slug>@viaapphaven.com>` (the test version adds `+preview`). From preview, only
the owner's **Apphaven account email address** is eligible for delivery. Other recipients'
mail is `captured` for inspection. Use `read_emails` to check the actual status, then pass a
message's `id` to read it and follow its link; owner mail can also fail or be limited.

## models

Each model is a table with an API at `/api/models/<Model>`. Names are PascalCase; field names are
snake_case. Every record also has `id` (text), `created_at` and `updated_at` (set automatically).

```yaml
models:
  Recipe:
    description: A recipe someone shared     # optional
    fields:
      title: { type: string, required: true, max_length: 120 }
      body: text
      servings: { type: integer, min: 1, default: 2 }
      cuisine: { type: enum, values: [italian, thai, mexican, other] }
      published: { type: boolean, default: false }
      category: Category                        # shorthand for { type: ref, to: Category }
      author: { type: user, default: $user.id, readonly: true, required: true }
    indexes: [[published, created_at]]          # optional extra indexes
    unique: [[author, title]]                   # optional multi-field uniqueness
    access:
      read: published == true || author == $user.id
      create: $user != null
      update: author == $user.id
      delete: author == $user.id || $user.role == 'admin'
  Category:
    fields:
      name: { type: string, required: true, unique: true }
    access:
      read: true
      write: $user.role == 'admin'
```

### Field types

| type | stores | notes |
| --- | --- | --- |
| `string` | short text | max 2000 chars unless `max_length` |
| `text` | long text | max 200 000 chars |
| `integer` | whole number | `min`, `max` |
| `number` | decimal number | `min`, `max` |
| `boolean` | true/false | |
| `datetime` | date + time | ISO 8601 strings, e.g. `2026-03-01T09:30:00Z` |
| `date` | calendar date | `YYYY-MM-DD` |
| `json` | any JSON | objects, arrays (e.g. a list of tags) |
| `enum` | one of `values` | `values: [a, b, c]` |
| `ref` | link to another record | `to: Model`; value is that record's id |
| `user` | link to a signed-in account | value is a user id |
| `image` | a photo someone sent the app | `many: true` for a list; see get_docs "files" |
| `file` | a document someone sent the app | `types: [pdf, document, text, image]`, `max_mb`, `many`; see get_docs "files" |

A field can be written as just its type (`body: text`) or a mapping with options:

- `required: true` — must be present (and non-empty for text).
- `default:` — a value, `$now` (datetime/date), or `$user.id` (the signed-in user).
- `readonly: true` — clients can't set or change it (it's set by its default, or by operations).
- `unique: true`, `index: true`
- `min`, `max` (numbers), `min_length`, `max_length`, `pattern` (regex), `format: email | url` (strings)
- `on_delete:` for `ref`/`user` — `cascade` (delete this record too), `set_null`, or `restrict`
  (block the delete). Default: `cascade` if required, else `set_null`.
- `renamed_from: old_name` — rename without losing data (also works on models).
- `description:` — notes for humans.

### Access rules

Each model lists who may do each action. **No rule = nobody** (not even signed-in users).

| key | covers |
| --- | --- |
| `list` | listing/filtering records |
| `get` | fetching one record by id |
| `create`, `update`, `delete` | writes |
| `read` | shorthand for list + get |
| `write` | shorthand for create + update + delete |

Specific keys win over shorthands. A rule is `true`, `false`, or a condition:

```
author == $user.id                       the record's author is the signed-in user
$user != null                            anyone signed in
$user.role == 'admin'                    admins
published == true || author == $user.id
status in ['open', 'pending']
team.owner == $user.id                   follow a ref field to the linked record
exists(Membership, team == $row.team && user == $user.id)   a matching record exists
contains(title, 'urgent')                case-insensitive substring; also startsWith(a, b)
due < $now
```

- Bare names are fields of the record. Follow `ref` fields with dots (`project.owner`); `user`
  fields expose `.name`, `.email`, `.role`.
- `$user` is the signed-in user (`$user.id`, `.email`, `.name`, `.role`), or null when signed out.
  `$now` is the current time. `$row.x` is the record being checked (useful inside `exists`).
- `exists(Model, condition)` is true when some record of Model matches; inside it, bare names are
  that Model's fields.
- Operators: `== != < <= > >=`, `in [...]`, `not in [...]`, `&&`/`and`, `||`/`or`, `!`/`not`,
  parentheses. Strings use single or double quotes.
- Anything involving a missing value is false: a signed-out user never matches `author == $user.id`
  (even when `author` is empty), and an empty field is neither `==` nor `!=` anything. Check
  emptiness explicitly with `field == null` / `field != null`, and optional params with
  `$params.q == null || contains(title, $params.q)`.

How rules apply:
- `list`/`get`/`update`/`delete` only see records the rule allows (others look like they don't exist).
- `create` and `update` are re-checked on the **resulting** record, so a user can't create or edit a
  record into a state they wouldn't be allowed to have (e.g. move a task into someone else's project).
- Rules that don't look at the record at all (`$user != null`, `$user.role == 'admin'`) are decided
  up front: signed-out visitors get 401 and other users 403, instead of an empty list.
- Fields marked `readonly` are ignored when clients send them.

### Common patterns

Private per-user data:
```yaml
owner: { type: user, default: $user.id, readonly: true, required: true }
access: { read: owner == $user.id, create: $user != null, update: owner == $user.id, delete: owner == $user.id }
```

Public content, owner edits:
```yaml
access: { read: true, create: $user != null, update: author == $user.id, delete: author == $user.id }
```

Anyone can submit, only admins can see (contact form, waitlist):
```yaml
access: { create: true, read: $user.role == 'admin', delete: $user.role == 'admin' }
```

Teams / shared spaces:
```yaml
Team:
  fields:
    name: { type: string, required: true }
    owner: { type: user, default: $user.id, readonly: true, required: true }
  access:
    read: owner == $user.id || exists(Member, team == $row.id && user == $user.id)
    create: $user != null
    update: owner == $user.id
    delete: owner == $user.id
Member:
  fields:
    team: { type: Team, required: true }
    user: { type: user, required: true }
  unique: [[team, user]]
  access:
    read: user == $user.id || team.owner == $user.id
    create: team.owner == $user.id
    delete: team.owner == $user.id || user == $user.id
Task:
  fields:
    team: { type: Team, required: true }
    title: { type: string, required: true }
  access:
    read: team.owner == $user.id || exists(Member, team == $row.team && user == $user.id)
    write: team.owner == $user.id || exists(Member, team == $row.team && user == $user.id)
```

To invite someone by email, have them sign up first, then add a `Member` record with their user
id — or store an `Invite` model with `email` and a rule like `email == $user.email`. Every
signed-in user has confirmed their address, so `$user.email` is theirs.

## operations

Named endpoints for anything beyond plain CRUD: preset queries, server-set values, actions that
need different permissions. Called with `POST /api/ops/<name>` (or `app.ops.<name>(input)`).

```yaml
operations:
  myDrafts:
    model: Recipe
    action: list                 # list | get | count | aggregate | create | update | delete
    where: author == $user.id && published == false
    sort: [-updated_at]
    limit: 100                   # max page size (clients can pass smaller limit/offset)
  publishRecipe:
    model: Recipe
    action: update
    params: { id: string }       # types: string, integer, number, boolean, datetime, date
    where: id == $params.id && author == $user.id
    set: { published: true }     # server-set values: literals, $now, $user.id, $params.x
  quickNote:
    model: Note
    action: create
    input: [text]                # fields the client may send (as `data`)
    set: { pinned: false }
  publicRecipeCount:
    model: Recipe
    action: count
    where: published == true
    access: true                 # replaces the model's rule for this operation
  leaderboard:
    model: Run
    action: aggregate            # grouped totals
    params: { since: date }
    where: date >= $params.since
    group_by: [runner]
    sum: [km]                    # also: avg, min, max; count is on by default
    sort: [-sum_km]
    limit: 20
```

### Marking meaningful results

An operation may declare the result that means someone got value from the app. Ask what the
owner wants people to accomplish, then mark a small number of actions such as completing a
booking or publishing a project. Do not mark every save, read, seed or background refresh.

```yaml
operations:
  complete_task:
    model: Task
    action: update
    params:
      id: { type: string, required: true }
    where: id == $params.id && done == false
    set: { done: true }
    access: $user != null
    outcome:
      id: task_completed
      version: 1
      kind: meaningful
      when: changed
```

The usual model access rules still apply. `outcome` supports create, update and delete
operations only, with at most 32 declarations per app. `id` is a stable 3–64 character lowercase
identifier (letters, numbers, underscores) that starts with a letter, such as `booking_completed`;
never put a person's name, address or record id in it. `version` is an integer from 1 to 10000. Use `kind: meaningful` for useful activity or
`kind: conversion` for a completed business goal. `when: changed` requires at least one affected
record; it counts one completed invocation, separately from the affected-row count.

Use a condition such as `done == false` when repeating a request must not count another
completion. PostgreSQL can report an affected row even when an update writes the same value.
Generic CRUD requests do not become meaningful outcomes just because they succeed. Increase the
version when the result's meaning changes; each event keeps the declaration from its immutable
deployment. A later email failure does not undo a committed action or prove delivery.

Apphaven observes account creation, address confirmation and completed sign-in on the server.
No tracking snippet or external analytics package is needed for these outcomes. Collection may
not be switched on yet: a valid declaration does not mean data already exists. Test through
Apphaven's preview tools; recognized tests are kept separate from live adoption. Do not invent
customer traffic to populate charts.

### Sending email from an operation

A create or update operation can send an email after it succeeds, once per record it touched:

```yaml
operations:
  placeOrder:
    model: Order
    action: create
    input: [item, qty, customer]
    email:
      to: [owner, customer.email]        # who gets it (see below)
      subject: "New order: {{ item }}"   # {{ placeholders }} work here too
      template: emails/order.html        # the file apphaven/emails/order.html
      reply_to: customer.email           # optional
  confirmBooking:
    model: Booking
    action: update
    params: { id: string }
    where: id == $params.id
    set: { status: confirmed }
    email:
      to: guest                          # a user field: their address
      subject: Your booking is confirmed
      body: |                            # plain text instead of a template
        Hi {{ guest.name }}, your booking for {{ date }} is confirmed.
        See it at {{ app.url }}/bookings/{{ id }}
```

- `email` can also be a list of up to 5 such mappings, when one action should send different
  messages, e.g. an alert to the owner and an acknowledgement to the customer.
- `to` (one or a list, at most 10): `owner` (the app owner's apphaven address), `$user` (whoever
  ran the operation), `$params.<name>` (a string param), a field of the record that holds an
  address (`customer_email`, ideally `format: email`), a `user` field (`assignee`), or
  `<ref field>.<field>` (`customer.email`, `customer.account`). A literal address also works.
- `template` is an HTML file under `apphaven/emails/`. It isn't served to visitors. Write a
  fragment (`<p>…</p>`): it's placed in a frame with the app's name and a footer. A full
  `<html>` document is sent as is.
  This is implemented for operation notifications. Account emails (confirmation, sign-in
  link and password reset) use Apphaven's fixed copy and the app's name; there is no custom
  auth-template setting.
- Placeholders: `{{ field }}` for the record's fields (refs and users expand one level:
  `{{ customer.name }}`, `{{ assignee.email }}`), `{{ params.x }}`, `{{ user.name }}` /
  `{{ user.email }}` (who ran it), `{{ app.name }}` / `{{ app.url }}`. Values are escaped in
  templates; dates read like "Oct 18, 2026, 9:30 AM UTC". `validate` reports a placeholder
  that doesn't match a field.
- Notification emails get a "Stop these emails" link; sign-in emails don't. An address that
  bounces, complains or unsubscribes is skipped from then on (the owner sees these in the
  console's Mail page).
- Live limits: up to 500 emails a day, but a new live app starts at 100 a day for its first 7
  days of sending and 250 until day 30 (the limit grows with a clean record). An address nobody
  confirmed (anyone but the owner, a signed-in user, or a literal address in the config) gets at
  most 5 messages an hour and 20 a day from one app. Sending pauses by itself on a spam
  complaint or too many bounces; `read_emails` says so in its first line.
- Preview: nothing is delivered except to the owner's address; `read_emails` shows what would
  have gone out, and with an `id` shows one rendered. Test the flow there first.
- Owners can preview every deployed email from the console's Mail page, before sending any:
  phone, desktop and plain text, filled with sample details. The selected test/live deployment
  supplies the templates; workspace edits appear after `deploy_preview` (and after publishing
  for live). Full HTML documents keep their own layout. Keep custom layouts fluid, use inline
  styles and presentation tables, and let long links wrap. The gallery is for appearance;
  `read_emails` still checks actual recipient resolution and the rendered data from a real flow.

- `access` (optional) replaces the model's rule for that action; without it the model's rule applies.
  Operations can therefore allow narrow things the model doesn't (e.g. anyone may count, only via
  this operation).
- `where` narrows which records are touched (`$params.x` refers to params). Required for
  update/delete operations, which may change several records at once.
- `set` values bypass `readonly`; `input` lists what clients may send.
- Params can have `default:` and `required: false`.
- Results: list → `{ items, has_more, offset, limit }`; get/create → the record;
  update → `{ count, items }`; delete → `{ count }`; count → `{ count }`;
  aggregate → `{ items: [{ <group fields>, count, sum_<f>, avg_<f>, min_<f>, max_<f> }] }`.
- Clients can also aggregate ad hoc with `app.db.<Model>.aggregate(...)` (see the sdk docs); an
  `aggregate` operation is for fixed reports or ones that need different access.

## frontend

```yaml
frontend:
  build: auto        # auto (npm if package.json has a build script) | none | vite
  output_dir: dist   # what to serve after the build (static sites: a folder to serve instead of the root)
  spa: true          # unknown paths serve index.html
```
