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
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).
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).
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: truemin,max(numbers),min_length,max_length,pattern(regex),format: email | url(strings)on_delete:forref/user—cascade(delete this record too),set_null, orrestrict(block the delete). Default:cascadeif required, elseset_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
reffields with dots (project.owner);userfields expose.name,.email,.role. $useris the signed-in user ($user.id,.email,.name,.role), or null when signed out.$nowis the current time.$row.xis the record being checked (useful insideexists).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 whenauthoris empty), and an empty field is neither==nor!=anything. Check emptiness explicitly withfield == null/field != null, and optional params with$params.q == null || contains(title, $params.q).
How rules apply:
list/get/update/deleteonly see records the rule allows (others look like they don't exist).createandupdateare 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
readonlyare ignored when clients send them.
Common patterns
Private per-user data:
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:
access: { read: true, create: $user != null, update: author == $user.id, delete: author == $user.id }
Anyone can submit, only admins can see (contact form, waitlist):
access: { create: true, read: $user.role == 'admin', delete: $user.role == 'admin' }
Teams / shared spaces:
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)).
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.
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:
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 }}
emailcan 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, ideallyformat: email), auserfield (assignee), or<ref field>.<field>(customer.email,customer.account). A literal address also works.templateis an HTML file underapphaven/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".validatereports 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_emailssays so in its first line.Preview: nothing is delivered except to the owner's address;
read_emailsshows what would have gone out, and with anidshows 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_emailsstill 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).wherenarrows which records are touched ($params.xrefers to params). Required for update/delete operations, which may change several records at once.setvalues bypassreadonly;inputlists what clients may send.Params can have
default:andrequired: 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); anaggregateoperation is for fixed reports or ones that need different access.
frontend
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