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

You attach a policy to each resource (a KV namespace, a DB collection, a realtime channel, a media room, …). Every time user code touches that resource, Maravilla evaluates the policy against the caller’s identity and the operation’s node data. true allows; anything else denies.

Policies are opt-in. A resource with no policy is unrestricted to anyone who reaches your app. Attach one when you want per-user access control.

You can also ask “would this be allowed?” from inside your code with platform.auth.can(...) — same engine, returns a boolean.

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 },
      signup: { challenge: 'pow' },   // bot protection on the sign-up page
    },

    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 ("" if anonymous)
auth.is_admintrue when the caller is a member of the admin group
auth.statusThe caller’s account status: "active", "suspended", "deactivated", or "managed"
auth.email_verifiedtrue once the caller has verified their email
auth.groupsArray of the caller’s group slugs (a group’s slug defaults to a slugified form of its name)
auth.rolesAlias of auth.groups — the same array, under a second name
auth.circlesArray of the caller’s circle slugs
auth.profile.<field>A field from the caller’s profile (e.g. auth.profile.school_id)
auth.scopes.<field>A profile field promoted into the policy context via security.scope_fields (see below)
node.*Resource-shaped data for this specific operation (see below)

auth.groups and auth.roles are two names for the same array of group slugs — write whichever reads better. Groups, circles, and relation types are all addressed by their tenant-unique slug (a group/relation slug defaults to a slugified form of its name); ids still work everywhere a slug does.

Tip — test before you ship. Validate a policy offline against {auth, node} → expected fixtures with maravilla policy test, using the same evaluator the runtime uses (RELATES included). See Tooling.

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: a method call or property access on null returns null rather than erroring, so node.meta.published == true evaluates to false (not an error) when node.meta is missing.
  • On strings: .contains(s), .startsWith(s), .endsWith(s), .toLowerCase(), .toUpperCase(), .trim(), .substring(start[, end])
  • On arrays: .contains(x), .first(), .last(), .indexOf(x), .join(sep)
  • On any value: .length(), .isEmpty(), .isNotEmpty()
  • On paths: .descendantOf(p), .ancestorOf(p), .childOf(p), .parent(), .depth()
# Anyone signed in with a verified, active account
auth.user_id != "" && auth.email_verified == true && auth.status == "active"
# A field promoted via security.scope_fields, matched against the record
auth.scopes.school_id == node.document.school_id || auth.is_admin

What’s in node?

It depends on which op fired. node.action is a different vocabulary per service — there is no unified set. The most common mistake is writing node.action == "read" against a Storage policy, where the actual action string is "get". Match the table below exactly.

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

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

Storage node.key includes the bucket prefix

For Storage ops, node.key is the full key passed by the SDK — including any leading bucket/resource-name segment. The runtime extracts bucket = key.split_once('/').0 for resource-name lookup but does not strip it from node.key before policy eval.

If your app prepends the resource name (my-bucket/) when calling STORAGE.get/put, your startsWith() clauses must include it:

// WRONG — never matches: node.key is "my-bucket/templates/..."
'node.key.startsWith("templates/")'

// RIGHT
'node.key.startsWith("my-bucket/templates/")'

KV’s node.key does not include the namespace — KV ops pass the user’s key verbatim. Only Storage has this bucket-in-key convention.

List ops carry node.prefix, not node.key

For both KV and Storage, the list action puts the listing prefix on node.prefix. A clause that only checks node.key.startsWith(...) will never match for list ops. Write separate clauses for the per-key actions and the list action.

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.statusstring
$auth.email_verifiedboolean
$auth.groups / $auth.rolesarray of strings
$auth.circlesarray of strings
$auth.profile.<field>the profile field’s value (dot-paths allowed)
$auth.scopes.<field>a promoted scope field’s value

Any other $auth.X reference fails validation when you save the resource. An unresolved placeholder (e.g. a profile field the caller doesn’t have) substitutes to JSON null.

{ "school_id": "$auth.profile.school_id" }

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.

Typed policy builder

Hand-written policy strings are fine, but the config package ships a small typed builder that makes the common patterns terse, composable, and impossible to typo. Import the helpers from @maravilla-labs/platform/config and pass the result as a resource’s policydefineConfig serializes it to the underlying expression at build time.

import {
  defineConfig, ownsIt, isStaff, isAdmin, relatesVia, publicWhen, fragment,
} from '@maravilla-labs/platform/config';

export default defineConfig({
  auth: {
    relations: [{ relation_name: 'STEWARDS', title: 'Stewards', implies_stewardship: true }],
    groups: [{ name: 'editors' }],

    // Reusable named fragments — referenced from any policy with fragment('name').
    fragments: {
      staffOrAdmin: isStaff('editors').or(isAdmin()),
    },

    resources: [
      {
        name: 'documents',
        title: 'Documents',
        type: 'database',
        actions: ['read', 'write', 'delete'],
        // owner OR public OR (editor/admin) OR a steward of the owner
        policy: ownsIt()
          .or(publicWhen())
          .or(fragment('staffOrAdmin'))
          .or(relatesVia('STEWARDS', { depth: [1, 2] })),
      },
    ],
  },
});
HelperEmits
ownsIt(field = 'owner')auth.user_id == node.<field>
isStaff(group = 'staff')auth.roles.contains('<group>')
isAdmin()auth.is_admin
publicWhen(field = 'public')node.<field> == true
relatesVia(name, { subject?, object?, depth? })<object> RELATES <subject> VIA '<name>'[ DEPTH a..b] (defaults node.owner RELATES auth.user_id)
fragment(name)The named fragment from auth.fragments, inlined in parens
.and(p) / .or(p)Combine two policies
Policy.raw(expr)Wrap a hand-written string (linted for the footguns below)

What it protects you from. defineConfig validates as it serializes:

  • relatesVia('NAME') is cross-checked against the declared relations[] — an unknown relation name throws.
  • When you declare groups[], an isStaff('x') / auth.roles.contains('x') referencing an undeclared group throws.
  • fragment('x') referencing an undeclared (or cyclic) fragment throws.
  • Policy.raw(...) rejects the shapes the runtime would silently fail closed on: bare is_admin (write auth.is_admin or use isAdmin()), auth.isAdmin / auth.admin, and a VIA that isn’t followed by a single-quoted relation name.

Field redaction

Sometimes a caller may read a record but shouldn’t see every field on it. A resource’s redact map nulls individual fields per-caller:

{
  name: 'profiles',
  title: 'Profiles',
  type: 'database',
  actions: ['read', 'write'],
  policy: 'auth.user_id != ""',          // any signed-in user can read a profile
  redact: {
    // The SSN is visible only to the owner or an admin; nulled for everyone else.
    ssn: '!(auth.user_id == node.owner || auth.is_admin)',
    'contact.phone': 'auth.user_id != node.owner && !auth.is_admin',
  },
}

Each value is a REL expression evaluated against the row. The field is redacted (replaced with null) unless that expression is definitively false — so write the expression to mean “redact when this is true.” A parse error or non-boolean result fails closed: the field is redacted. Keys are dot-paths (contact.phone reaches into a nested object). Redaction runs on read results after the policy allows the read.

Audit

Set audit: true on a resource to record every policy decision made against it — allow and deny alike, with the caller’s identity:

{ name: 'payouts', title: 'Payouts', type: 'database',
  actions: ['read', 'write'], policy: 'auth.is_admin', audit: true }

Useful for sensitive resources where you need a trail of who was checked for what.

Tenant-wide security defaults

A few knobs apply across the whole project rather than per-resource. They live under the security block (admin UI: Auth Settings → Security).

defineConfig({
  auth: {
    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 },
      signup: { challenge: 'pow', rate_limit: true, honeypot: true },
    },
  },
});

Three policy-engine defaults round out the picture:

SettingEffect
default_policyThe fallback decision for a resource that has no policy (and no per-resource default_policy). allow by default; set to deny to make unpoliced resources closed-by-default.
deny_non_activeWhen true, any signed-in caller whose status isn’t active (suspended, deactivated, or managed) is denied at every authorization check, regardless of the per-resource policy.
scope_fieldsA list of profile dot-paths promoted into the policy context as auth.scopes.<field> — e.g. ["school_id"] makes auth.scopes.school_id available. Keeps policies terse and avoids repeating auth.profile.….

A resource can also set its own default_policy: "allow" or "deny" to override the tenant default just for that one resource when it carries no policy.

Sign-up bot protection

The security.signup block controls how hard it is for an automated script to create accounts on your project.

defineConfig({
  auth: {
    security: {
      signup: {
        challenge: 'pow',        // 'none' (default) | 'pow' | 'turnstile' | 'hcaptcha'
        pow_difficulty: 16,      // higher = slower for everyone, attacker and user alike
        honeypot: true,          // default
        rate_limit: true,        // default
        signup_secret: { env: 'SIGNUP_SECRET' },  // for server-to-server sign-up
      },
    },
  },
});
SettingEffect
challengenone (default) creates accounts with no challenge. pow makes the browser spend CPU on a puzzle before the form submits — no third-party service involved. turnstile and hcaptcha hand the decision to that provider instead, and need a secret plus captcha_site_key.
pow_difficultyHow much work pow demands, in bits. Defaults to 16, roughly half a second on a laptop and a few seconds on a slow phone.
honeypotAdds a decoy field a person never sees and a check that the form wasn’t submitted impossibly fast. On by default; costs real users nothing.
rate_limitCaps how many accounts can be created per client and per project in a given window. On by default.
signup_secretLets a trusted backend create accounts without solving a challenge — see below.
turnstile_secret / hcaptcha_secretRequired when challenge is set to that provider. Use { env: 'VAR' }.
captcha_site_keyThe provider’s public key, rendered into the page. Not a secret.

What each layer is actually worth

These layers are not equally strong, and it’s worth knowing which is which before you decide none is fine.

  • honeypot stops untargeted spam — scripts that scrape your HTML and fill in every field they find. Anyone who spends ten minutes looking at your sign-up page defeats it. It’s free, so it’s on by default, but treat it as a filter, not a gate.
  • rate_limit bounds how fast anyone can create accounts. It limits the damage; it doesn’t decide who’s a bot.
  • challenge is the only setting that puts a real cost on an attacker who is deliberately targeting you. pow makes each attempt cost CPU. A CAPTCHA makes each attempt cost a provider’s judgement — stronger in practice, at the price of depending on that provider.

None of this is a wall. It is depth. A determined, funded attacker gets through all of it; the point is to make casual abuse uneconomic.

Turning on a challenge is a visible change. With pow, the sign-up button shows a brief “Verifying…” state while the browser works. With a CAPTCHA, the provider’s widget appears on the form. Neither applies to sign-in — only to creating an account.

Server-to-server sign-up

A backend has no browser, so it cannot solve a challenge. Set signup_secret and have that service send it as an x-maravilla-signup-secret header. Requests carrying the correct secret skip the challenge and get a more generous rate limit. Everything else is still enforced.

Keep the secret out of your repo and out of any browser — { env: 'VAR' } resolves it from the project’s environment.

Scope: what is and isn’t protected

This protects the hosted sign-up page at /_auth/register. It does not protect a sign-up form you build yourself.

If your app renders its own form and posts to your own route, that route calls platform.auth.register(...) directly, and no challenge, honeypot, or rate limit runs. Nothing stops a script from posting to your route all day. If you build your own sign-up, you own its abuse protection — or use the hosted page and get this for free.

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. It fails closed (returns false) when no caller is bound.

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.

Explaining a decision

When a check denies and you want to know why, use explain — same arguments as can, but it returns the reason and the clause that failed:

const result = await platform.auth.explain('write', 'documents', { owner: doc.owner });
// { allowed: false, reason: "...", failedClause: "auth.user_id == node.owner" }

if (!result.allowed) {
  console.warn('denied:', result.reason, '→', result.failedClause);
}

failedClause is populated when the engine can isolate which part of the expression caused the denial — handy when debugging a policy that denies unexpectedly.

Batching checks

To gate a list of items in one round trip, canMany takes an array of checks and returns results in the same order:

const results = await platform.auth.canMany([
  { action: 'read',   resourceId: 'documents', node: { owner: a.owner } },
  { action: 'delete', resourceId: 'documents', node: { owner: b.owner } },
]);
// [{ allowed: true }, { allowed: false }]

const deletable = items.filter((_, i) => results[i].allowed);

Like can, both fail closed when no caller is bound.

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 with RELATES:

# Allow if the caller is a steward (1–2 hops) of whoever owns the resource
node.owner RELATES auth.user_id VIA 'STEWARDS' DEPTH 1..2

The full shape is <object> RELATES <subject> VIA '<relation_name>' [DEPTH a..b] [DIRECTION OUTGOING|INCOMING|ANY]. The relation name must be single-quoted; DEPTH defaults to 1..1 and DIRECTION to OUTGOING. Prefer the relatesVia() builder so the clause comes out correct.

Relation types are declared in your config’s relations block; the edges between users are created at runtime. Stewardship is the built-in family/guardianship relation. The whole relation model — relation types, edges, stewardship, circles, and slugs — is covered in Relationships.

Keep DEPTH small — deep traversal is slower, and the runtime caps it at 6 hops.

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 — policies are bypassed...
platform.policy.setEnabled(true);

Important:

  • 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.

Tooling

Three things help you get policies right before they reach a user.

maravilla policy test runs policy fixtures through the exact evaluator the runtime uses, so a fixture that passes behaves identically in production (RELATES included). Each case provides an auth object and a node object and an expected outcome:

// policy.fixtures.json
{
  "policy": "auth.user_id == node.owner || auth.is_admin",
  "relations": [
    { "source": "guardian", "target": "ward", "via": "STEWARDS" }
  ],
  "cases": [
    { "name": "owner can read",  "auth": { "user_id": "u1" }, "node": { "owner": "u1" }, "expected": "allow" },
    { "name": "stranger denied", "auth": { "user_id": "u2" }, "node": { "owner": "u1" }, "expected": "deny" },
    { "name": "admin override",  "auth": { "user_id": "u9", "is_admin": true }, "node": { "owner": "u1" }, "expected": "allow" }
  ]
}
maravilla policy test                              # uses ./policy.fixtures.json
maravilla policy test --fixtures auth.fixtures.json
maravilla policy test --policy "auth.is_admin"     # policy when a case/file sets none

It exits non-zero if any case fails — wire it into CI. A policy that fails to parse counts as a deny, so you can assert that a malformed policy denies. Declaring a relations edge list feeds the fixture relation resolver so RELATES clauses resolve offline; cases declare a per-case auth/node/expected, and an optional per-case policy override.

maravilla resources doctor audits the resources in your built manifest.json for the two hazards that the (name, service_type) binding surfaces: the same resource_name declared with two different concrete service types (cross-service collision), and a name declared both typed and untyped (ambiguous fallback). --strict exits non-zero when any are found.

npm run build              # so the adapter emits manifest.json
maravilla resources doctor
maravilla resources doctor --strict      # CI-friendly

The console. The auth-settings UI (Auth Settings → Resources) edits resource policies with live REL validation — it flags parse errors and the same footguns the typed builder rejects as you type, so a bad expression can’t be saved.

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
await platform.auth.explain(action, resourceId, node);  // { allowed, reason, failedClause }
await platform.auth.canMany([{ action, resourceId, node }, ...]); // [{ allowed }, ...]

// Per-request policy toggle
platform.policy.setEnabled(false);                      // disable policies
platform.policy.isEnabled();                            // current state

In your project config:

// maravilla.config.ts
import {
  defineConfig, ownsIt, isStaff, isAdmin, relatesVia, publicWhen, fragment,
} from '@maravilla-labs/platform/config';

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

From the CLI:

maravilla login                                         # once per machine
maravilla policy test                                   # run REL fixtures, CI-safe
maravilla resources doctor --strict                     # audit resource bindings
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.*
  • Relationships — Relation types, edges, stewardship, and RELATES
  • 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