Apps & Projects
An app is a tenant inside a single Anvil server: it owns a schema (app_<slug>), a membership list that links back to the global auth.users, and its own settings and email templates. Users stay global - one account can belong to many apps with a different privilege in each. Everything below is served from the same server, the same data directory, and the same auth system you already have.
Concepts
| Term | Meaning |
|---|---|
| App | A tenant with an app_id (UUID), an immutable slug, a display name, and an enabled flag. Stored in system.apps, mirrored as :App nodes. |
| App schema | app_<slug>. Labels are bound to it explicitly; collections are named app_<slug>.<name>. |
| Member | A row in auth.app_members linking a user to an app with a privilege. Mirrored as (:User)-[:MEMBER_OF {privilege}]->(:App). |
| Privilege | reader < editor < admin - the same names as the server roles. reader reads, editor writes and manages labels, admin manages members, settings and email templates. |
| Server admin | Users with the server-wide admin role create and delete apps and implicitly hold admin privilege in every app. |
Quickstart
# 1. Create an app (server admin). The creator becomes an app admin.
curl -X POST http://localhost:7474/apps \
-H "Authorization: Bearer $ADMIN_JWT" \
-d '{"slug": "crm", "name": "Customer CRM"}'
# => { "id": "3f2c...", "slug": "crm", "name": "Customer CRM", "enabled": true, "privilege": "admin" }
# 2. Add a member by username (or user id)
curl -X PUT http://localhost:7474/apps/crm/members/alice \
-H "Authorization: Bearer $ADMIN_JWT" \
-d '{"privilege": "editor"}'
# 3. Bind the labels the app will use to its schema
curl -X PUT http://localhost:7474/apps/crm/labels/Lead \
-H "Authorization: Bearer $ADMIN_JWT"
# 4. Work inside the app - either scope the request with a header ...
curl http://localhost:7474/db/query \
-H "Authorization: Bearer $ALICE_JWT" \
-H "X-Anvil-App: crm" \
-d '{"query": "CREATE (:Lead {name: \"Acme\"})"}'
# ... or name the schema explicitly
curl http://localhost:7474/db/query \
-H "Authorization: Bearer $ALICE_JWT" \
-d '{"query": "MATCH (l:Lead) RETURN l.name", "database": "app_crm"}'Request Scoping
Send X-Anvil-App: <id or slug> on any authenticated request. The middleware resolves the app, checks membership, and attaches app_id, app_slug, app_schema and app_privilege to the session - usable from RLS predicates as session('app_privilege'). An unknown app is a 404; a non-member is a 403. Cypher requests without an explicit database then run in the app's schema. Server admins pass membership checks with implicit admin privilege.
Schema Isolation
| Rule | Behaviour |
|---|---|
| Labels are bound explicitly | PUT /apps/{id}/labels/{label} (editor+). Creating a node with an unbound label inside an app schema fails with the usual “reserved by the 'public' schema” error - nothing is claimed by accident. Bindings persist in system.app_labels. |
| Visibility | An app scope returns only data associated with that app: its own nodes plus the :User nodes of its members - not every user on the server, and not the server-wide :Role nodes (use the auth scope for those). This holds for server admins and on every graph read: MATCH, traversals, graph-document joins and full-text search. public / auth / system scopes never see app nodes. |
| Membership gates access | Any app_* schema requires membership: readers may read, editor+ may run mutating statements; otherwise 403. |
| Cross-schema edges | A relationship must have both endpoints in the same schema, except that anything may link to auth nodes - e.g. (:Order)-[:PLACED_BY]->(:User). From inside an app that means a member's :User: a non-member is not visible, so it cannot be matched to link to. Applies to CREATE, MERGE, MATCH…CREATE and MATCH…SET…CREATE. |
| Collections | app_<slug>.* collections need editor+ to create or write (Cypher and /docs), admin to drop. For /docs writes that editor is the caller's privilege in the app, not their server role - an OTP or OAuth sign-up (server reader) can be made an editor of an app's documents; schema DDL and Cypher writes still need the server role. Row-level security applies on top, and app admins bypass it inside their own app. |
| DELETE /apps/{id} | Cascades: DETACH-deletes every node bound to the schema, drops app_<slug>.* collections and their sync rules, clears bindings and memberships, removes the :App node. (Cypher DROP APP requires CASCADE for a non-empty schema.) |
Note: the bare UPSERT DOCUMENT IN <c> <key> {…} and MATCH DOCUMENT forms parse the collection as a plain identifier, so a dotted app_crm.leads can only be addressed via the WHERE forms, DELETE DOCUMENT, or the /docs REST API.
App-Scoped Settings
A subset of runtime settings can be overridden per app. Anything not overridden inherits the server-wide value; app overrides never change the global config. Keys are stored as app.<app_id>.<key> in system.settings; a slug in the key is canonicalised to the app id.
# REST (app admin): set, list, reset
curl -X PUT http://localhost:7474/apps/crm/settings \
-H "Authorization: Bearer $JWT" \
-d '{"auth.email.smtp_from": "CRM <noreply@crm.example.com>", "server.base_url": "https://crm.example.com"}'
curl http://localhost:7474/apps/crm/settings -H "Authorization: Bearer $JWT"
# => [{ "key": "auth.email.smtp_from", "value": "CRM <noreply@crm.example.com>", "source": "app" },
# { "key": "auth.email.smtp_host", "value": "smtp.resend.com", "source": "global" }, ...]
curl -X DELETE http://localhost:7474/apps/crm/settings/server.base_url -H "Authorization: Bearer $JWT"
# Cypher (server admin)
SET SETTING 'app.crm.auth.email.otp_ttl_secs' = '120'| Scopable keys |
|---|
| auth.require_email_verification, auth.allow_otp_registration |
| auth.email.smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from, smtp_starttls, webhook_url |
| auth.email.otp_ttl_secs, otp_max_attempts, verification_ttl_secs, template_dir |
| server.base_url (used for verification / reset links) |
Custom Email Templates
Each app can override the verification, otp and password_reset emails instead of using the generic Anvil ones. Any subset of subject / html / text can be overridden; the rest falls back to the server template. Stored in system.app_email_templates.
curl -X PUT http://localhost:7474/apps/crm/email-templates/otp \
-H "Authorization: Bearer $JWT" \
-d '{"subject": "{{app_name}}: your code {{otp_code}}"}'
# Render with sample values to check the result
curl -X POST http://localhost:7474/apps/crm/email-templates/otp/preview \
-H "Authorization: Bearer $JWT" -d '{}'
# => { "subject": "Customer CRM: your code 123456", "html": "...", "text": "..." }
# Revert to the server default
curl -X DELETE http://localhost:7474/apps/crm/email-templates/otp -H "Authorization: Bearer $JWT"| Template | Variables |
|---|---|
| verification | {{verification_link}}, {{verification_code}}, {{token}} |
| otp | {{otp_code}}, {{ttl_minutes}} |
| password_reset | {{reset_link}}, {{token}} |
| all | {{app_name}}, {{app_slug}}, {{app_id}} |
Sending On Behalf of an App
/auth/register, /auth/resend-verification and /auth/otp/request accept an optional app field (id or slug). The email, its TTLs and the link's base URL then come from that app's settings and templates; an unknown app returns 404. A login code also remembers its app: that app's auth.allow_otp_registration decides whether verifying the code may register a new user.
curl -X POST http://localhost:7474/auth/otp/request \
-d '{"email": "alice@example.com", "app": "crm"}'Cypher DDL
Everything in the quickstart can also be done from the Cypher editor. Slugs, names and usernames may be quoted or bare; labels may be written with or without the leading colon.
CREATE APP 'crm' NAME 'Customer CRM' -- server admin; NAME defaults to the slug SHOW APPS -- apps you can access (admins: all) APP 'crm' ADD MEMBER 'alice' PRIVILEGE editor -- PRIVILEGE defaults to reader APP 'crm' REMOVE MEMBER 'alice' APP 'crm' BIND LABEL :Lead -- editor+ APP 'crm' UNBIND LABEL :Lead -- app admin SHOW APP MEMBERS 'crm' SHOW APP LABELS 'crm' DROP APP 'crm' -- refused while the schema holds nodes, labels or collections DROP APP 'crm' CASCADE -- detach-deletes the schema contents first
Querying Membership
Because apps and memberships are mirrored into the graph, they are queryable with plain Cypher from the auth or system schema:
MATCH (u:User)-[m:MEMBER_OF]->(a:App {slug: 'crm'})
RETURN u.username, m.privilege
ORDER BY m.privilegeBackup
GET /apps/{id}/export (app admin) returns a self-contained JSON backup of everything the app owns: metadata, members, label bindings, settings overrides, email templates, collections with their documents, the app-schema subgraph (relationships to auth nodes are annotated with the external endpoint's identity), sync rules, RLS policies and triggers. The Hammer Apps page has a one-click Download backup (.json) button.
POST /apps/import (server admin) restores a backup as a new app - pass ?slug=… to import under a different slug. Collection names and settings keys are rewritten to the new schema/app id; members are matched against auth.users by id then username; relationships to auth nodes are re-linked by identity (id / username / …). Anything that can't be matched is skipped and counted in the returned summary. Hammer's Apps page has an Import button that takes the backup file.
Hammer
The Apps page in Hammer manages all of the above - create apps, members, label bindings, settings overrides and email templates with a live preview. Every app you can access also appears in the sidebar's schema dropdown as app_<slug>, so the Cypher, Documents and Schema pages can work inside it directly.