Authorization

Authentication tells you who the caller is. Authorization decides what they can do. Maravilla evaluates policies you attach to your resources every time user code touches KV, the database, a realtime channel, or a media room.

// In your Maravilla admin console, on the "documents" resource, set a policy:
//   auth.user_id == node.owner || auth.is_admin
// That's it — the rule runs on every kv.get / kv.put / db.find / etc.
// scoped to that resource.

How it works

There are three layers. Most apps only think about the second one.

  1. Tenant isolation — automatic. Data from one project is never visible to another, period. You don’t configure this.
  2. Per-resource policies — you define rules for each resource. Maravilla evaluates them at every read/write. This is what you’ll write 90% of the time.
  3. App-level checks — you can also ask “would this be allowed?” from inside your code with platform.auth.can(...).

Policies are opt-in. A resource with no policy only gets Layer 1 (tenant isolation). Attach a policy when you want per-user access control.

Two ways to configure: UI or code

You can set everything up from the admin UI, or declare it in a maravilla.config.{ts,yaml,json} at the root of your project and let the deploy do it for you. Most teams end up with both — UI for quick iteration, config file for PR review and environment parity.

Config file wins for anything it declares. Anything it omits, the DB keeps. That lets you adopt incrementally: drop in just resources today, layer in groups, branding, etc. as you need them.

Supported out of the box:

FrameworkAdapterHow it picks up the config
SvelteKit@maravilla-labs/adapter-sveltekitAutomatic on vite build
React Router 7@maravilla-labs/adapter-react-routerAutomatic on vite build
Nuxt / SolidStart / TanStack Start@maravilla-labs/preset-nitroAutomatic on nitro build

If you’re on one of these, you already have @maravilla-labs/platform as a dep — the defineConfig helper lives at the /config subpath:

// maravilla.config.ts — at your project root
import { defineConfig } from '@maravilla-labs/platform/config';

export default defineConfig({
  auth: {
    resources: [
      {
        name: 'todos',
        title: 'Todos',
        actions: ['read', 'write', 'delete'],
        policy: 'auth.user_id == node.owner || auth.is_admin',
      },
    ],

    groups: [
      { name: 'moderators',
        permissions: [{ resource_name: 'todos', actions: ['read', 'delete'] }] },
    ],

    relations: [
      { relation_name: 'STEWARDS', title: 'Stewards', category: 'family',
        implies_stewardship: true, bidirectional: false },
    ],

    registration: {
      fields: [
        { key: 'email', label: 'Email', field_type: 'email',
          required: true, show_on_register: true },
        { key: 'display_name', label: 'Display name', field_type: 'text',
          required: true, show_on_register: true },
      ],
    },

    oauth: {
      google: {
        enabled: true,
        client_id: '1234567890.apps.googleusercontent.com',
        client_secret: { env: 'GOOGLE_CLIENT_SECRET' },  // resolved server-side
        scopes: ['openid', 'email', 'profile'],
      },
    },

    security: {
      password_policy: { min_length: 12, require_uppercase: true,
                         require_number: true, require_special: false },
      session: { access_token_ttl_secs: 900, refresh_token_ttl_secs: 2592000,
                 max_sessions_per_user: 5, require_email_verification: true },
    },

    branding: { app_name: 'HoneyBee', primary_color: '#f59e0b', layout: 'centered' },
  },
});

Every section is optional. Declare what you want to own in the repo; leave the rest in the UI. Runtime data (user-to-group memberships, circles, stewardship overrides) stays in the UI — it’s tied to individual users, not something you version in your repo.

OAuth secrets. Accept either "${env.VAR_NAME}" (string shorthand) or { env: "VAR_NAME" } (object). Resolved from the tenant’s environment at reconcile time — never plaintext in your repo.

What happens on deploy

  1. You run npm run build (or your framework’s build command). The adapter finds maravilla.config.*, validates it, and bakes the auth block into the framework’s output manifest.json (typically build/manifest.json; older projects may use .maravilla/manifest.json).
  2. Your deploy pipeline ships the manifest. Delivery reads the auth block on the first request to the new deployment.
  3. Declared items are upserted by natural key (resource name, group name, relation name). Singleton sections (registration fields, security, branding) replace the current config only for the fields you declared.
  4. Anything the admin UI created but your config doesn’t mention is kept, never auto-deleted. The deploy log lists it under authz.config.drift so you can reconcile manually.
  5. Malformed policy expressions fail the deploy with a line/column error before anything ships.

YAML — same schema, no TS types:

# maravilla.config.yaml
auth:
  resources:
    - name: todos
      title: Todos
      actions: [read, write, delete]
      policy: auth.user_id == node.owner || auth.is_admin

Sync without a full deploy

While iterating, a full build-and-deploy cycle is slow. The CLI has two subcommands that go straight to the admin API:

# Show what's different between your maravilla.config.* and the project
maravilla auth diff --project <project-id>

# Apply it (same logic as a deploy's reconcile)
maravilla auth sync --project <project-id>
  • diff exits non-zero when there are differences — wire it into CI to gate PRs.
  • sync is safe to re-run — it’s an upsert, not a replace. Drift (admin-UI-only items) is reported, not deleted.
  • Both read your built manifest.json (default build/manifest.json, fallback .maravilla/manifest.json) — so run your build first. Keeps the CLI thin; it never has to re-parse TypeScript configs.
  • Requires maravilla login first.

Defining a resource (UI)

Resources live in your project’s Auth Settings → Resources tab.

  1. Click Add Resource.
  2. Title: a human name like “Documents” or “Messages”.
  3. Slug: used in code. Typically the KV namespace or DB collection name (e.g. documents).
  4. Service type (optional): which platform service this resource gates — kv, database, realtime, media, vector, storage, queue, push, workflow, transforms. When set, the editor offers per-service action presets and the reconciler validates that the policy only references legal node.* fields for that service. Omit for legacy / cross-service resources.
  5. Actions: the operations you care about — read, write, delete, etc.
  6. Policy (optional): a REL expression (see below). Leave empty to skip policy checks.

Save. The policy is live the moment you hit save — existing deployments pick it up on their next request.

Why the type matters

Without a type, a KV namespace called todos and a DB collection also called todos will silently share a policy — the policy can disambiguate by checking node.namespace vs node.collection, but that’s a footgun. Setting type: 'kv' (or 'database') makes the binding explicit, lets the UI offer service-correct action presets and policy snippets, and lets the reconciler reject obvious mismatches before they ship.

Writing a policy

Policies are boolean expressions. true → allow; anything else → deny. Maravilla makes two things available:

VariableWhat it is
auth.user_idThe caller’s user id ("" if no one’s signed in)
auth.emailThe caller’s email
auth.is_admintrue if the caller has the admin flag
auth.rolesArray of role names on the current project
auth.groupsArray of group names the caller belongs to
auth.circlesArray of circle ids the caller belongs to
node.*Resource-shaped data for this specific operation (see below)

Examples

# Anyone signed in can read, only admins can write
auth.user_id != "" && (node.action == "read" || auth.is_admin)
# Only the owner of the row, or someone in the "editors" group
auth.user_id == node.owner || auth.groups.contains("editors")
# Public read for published items, owner-only writes
(node.action == "read" && node.status == "published") || auth.user_id == node.owner
# Admins from any project, plus users whose email ends in your domain
auth.is_admin || auth.email.endsWith("@acme.com")

Operators & methods

  • Comparison: ==, !=, >, <, >=, <=
  • Logic: &&, ||, !
  • Null-safe chaining: node.meta.published == true returns false (not an error) if node.meta is missing.
  • On strings: .contains(s), .startsWith(s), .endsWith(s)
  • On arrays: .contains(x)
  • On paths: .descendantOf("/content/blog")

What’s in node?

It depends on which op fired. For all of them, node.action is a string like "read" / "write" / "delete" / "list".

ServiceWhat node looks like
KV{ namespace, key, action, value, value_new? } (list ops: { namespace, prefix, limit, action: "list" })
Database{ collection, filter, action, document?, update? }
Realtime{ channel, action: "publish" | "subscribe" | "presence:join" | …}
Media{ room, role, action: "join" | "create" | "record:start" | …}
Vector{ collection, action: "index:read" | "index:admin" }
Storage{ bucket, key, action: "get" | "put" | "delete" | "list" | "upload-url" | …}
Queue{ queue, action: "enqueue" }
Push{ action: "subscribe" | "send" | "schedule" | …, target?, payload? }
Workflow{ workflow, input?, action: "start" | "send-event" }
Transforms{ bucket, src_key, action: "transcode" | "thumbnail" | …}

Reference the fields you care about. Policies that ignore node still work — they just act as tenant-wide rules.

Pre-fetched value/document

For ops where the platform can resolve the record before evaluating the policy, it does — and exposes the result as node.value (KV) or node.document (Database). This lets you write the natural rule:

auth.user_id == node.value.owner || node.value.public == true

Without this, on a KV get, node.value would be undefined and node.value.owner would never match — your policy would silently fall through to the rest of the expression. The pre-fetch makes ownership clauses fire correctly.

OpPre-fetched?Field exposed
kv.getyes (the read itself)node.value
kv.putyes when policy attached (extra read)node.value (existing), node.value_new (incoming)
kv.deleteyes when policy attached (extra read)node.value
kv.listno — list has no per-record context
db.findOneyes (the read itself)node.document
db.findno — list has no per-record contextuse read_filter instead, see below
db.insertOnen/a — record doesn’t exist yetnode.document (incoming)
db.updateOneyes when policy attached (extra read)node.document (existing), node.update (delta)
db.deleteOneyes when policy attached (extra read)node.document

The “extra read when policy attached” cases pay nothing when the resource has no policy — the pre-fetch is gated on a single SELECT for policy presence.

Scoping reads with read_filter

A REL policy is a per-record predicate — it answers “should this caller see this row?” That works fine for findOne and single-key get, where the platform fetches the record and runs the predicate. But for find and list returning many rows, evaluating a free-form predicate per row would be expensive. Maravilla takes a different route: declare a filter the runtime ANDs into the caller’s query before it runs.

{
  name: 'documents',
  type: 'database',
  actions: ['read', 'write', 'delete'],
  policy: 'auth.user_id == node.document.owner || node.document.public == true',
  read_filter: '{"$or":[{"owner":"$auth.user_id"},{"public":true}]}',
}

What that does:

  • A caller who runs db.find('documents', { status: 'draft' }) is silently rewritten to: { $and: [ { status: 'draft' }, { $or: [ { owner: 'u1' }, { public: true } ] } ] }
  • The caller can’t see rows they’re not allowed to see — the filter rewrite happens server-side; their query has to AND with it.
  • policy still gates per-record reads (e.g. findOne) and writes — read_filter only adds the bulk-read scoping.

Allowed $auth.X placeholders. The runtime substitutes these from the caller’s identity at request time:

PlaceholderResolves to
$auth.user_idstring
$auth.emailstring
$auth.is_adminboolean
$auth.rolesarray of strings

Any other $auth.X reference fails validation when you save the resource.

When to use which. Use read_filter when the caller can run unbounded queries against a collection. Use policy (with node.document / node.value) when single-record access decisions need richer logic than a relational predicate can express. Most apps that share data across users will want both.

Identity in your code

Policies need to know who is making the call. Three ways to bind identity:

// 1. Sign someone in — this binds identity implicitly.
await platform.auth.login({ email, password });

// 2. You already have a JWT from a session cookie or Authorization header.
await platform.auth.setCurrentUser(token);

// 3. To clear (log out).
await platform.auth.setCurrentUser(null);

platform.auth.validate(token) is pure — it verifies a token and returns the user, but does not change who policies see as the caller. Use setCurrentUser when you want the identity to apply.

// Read who policies will see right now
const caller = platform.auth.getCurrentUser();
// { user_id, email, is_admin, roles, is_anonymous }

Scope: identity binding lasts for the duration of one inbound HTTP request. Two concurrent requests on your deployment see two separate identities — they never bleed into each other.

Checking permission in code

// Can the current caller delete this document?
const ok = await platform.auth.can("delete", "documents", {
  owner: doc.owner,
  status: doc.status
});

if (!ok) {
  return new Response("Forbidden", { status: 403 });
}

platform.auth.can(action, resourceId, node) runs the same policy the platform itself would run. Returns a plain boolean — no exceptions.

When you’d reach for this: the platform pre-fetches the record on kv.get / db.findOne and on policy-attached writes, so most rules fire correctly without you doing anything. But for richer per-record decisions — e.g. a custom service handler that already loaded a document for unrelated reasons, or a cross-resource check (“can this caller see this other record?”) — call auth.can(...) with the resolved data and the policy fires against your node. Same engine, you provide the shape.

How denials surface

When a policy denies a direct KV/DB/Realtime/Media call, the op throws. Catch it like any other platform error:

try {
  await platform.KV.documents.put(id, doc);
} catch (e) {
  if (String(e).includes("authz denied")) {
    return new Response("Forbidden", { status: 403 });
  }
  throw e;
}

Prefer platform.auth.can(...) when you want a boolean you can branch on cleanly.

Advanced: graph relationships

For scenarios like “guardian can read ward’s data” or “manager can update reports from their team”, policies can traverse your configured relationships:

# Allow if the caller is a steward of whoever owns the resource
node.owner RELATES auth.user_id VIA "STEWARDS" DEPTH 1..2

Set up stewardship in Auth Settings → Stewardship. The VIA name matches the relation type you configured. Keep DEPTH small — deep traversal is slow.

Escape hatch: turning policies off

Sometimes you have code paths (admin jobs, first-run seeders) that need to operate without policy checks. Inside your app code:

platform.policy.setEnabled(false);
// ...trusted work here — Layer 2 is bypassed...
platform.policy.setEnabled(true);

Important:

  • This only disables per-resource policies (Layer 2). Tenant isolation (Layer 1) is always enforced — you still can’t cross tenants.
  • It’s scoped to the current request. A new request starts with policies re-enabled.
  • Every flip is logged server-side with the caller’s identity so bypasses are auditable.
  • Don’t pass user input into this. It’s for your code, not theirs.

Groups

Groups are named sets of users managed in Auth Settings → Groups. Add users to a group from the Users tab. Groups become available to policies as auth.groups.

auth.groups.contains("moderators") || auth.user_id == node.owner

Groups can also have resource-level permissions set directly in the admin UI, no policy needed — useful for broad roles like “all editors can write all documents”.

Bad policies don’t ship

When you save a malformed policy, Maravilla rejects it with a line/column error. Your deployment never sees a broken expression.

A policy that evaluates to an error at runtime (missing field on a null, type mismatch, …) is treated as a deny — never as an allow. If your policies start denying unexpectedly, check the request logs for the parse or eval error.

Quick reference

Inside your app code:

// Identity
await platform.auth.login({ email, password });         // implicit bind
await platform.auth.setCurrentUser(token);              // explicit bind
await platform.auth.setCurrentUser(null);               // clear
platform.auth.getCurrentUser();                         // snapshot

// Checks
await platform.auth.can(action, resourceId, node);      // boolean

// Per-request policy toggle
platform.policy.setEnabled(false);                      // Layer 2 off
platform.policy.isEnabled();                            // current state

In your project config:

// maravilla.config.ts
import { defineConfig } from '@maravilla-labs/platform/config';

export default defineConfig({
  auth: {
    resources: [ /* name, title, actions, policy */ ],
    groups: [ /* name, description, permissions */ ],
    relations: [ /* relation_name, title, implies_stewardship, ... */ ],
    registration: { fields: [ /* ... */ ] },
    oauth: { google: { client_id, client_secret: { env: 'X' }, ... } },
    security: { password_policy, session },
    branding: { app_name, primary_color, layout, ... },
  },
});

From the CLI:

maravilla login                                         # once per machine
maravilla auth diff --project <project-id>              # preview, CI-safe
maravilla auth sync --project <project-id>              # apply

Next steps

  • Authentication — Sign users in to populate auth.*
  • KV Store — Policies run on every kv.get/put/delete/list
  • Database — Policies run on every db.find/insert/update/delete
  • Realtime — Policies gate publish, subscribe, and presence
  • Media — Policies decide who gets a LiveKit join token