maravilla-policies
Maravilla Policies (Layer 2)
Maravilla enforces authorization in two layers:
- Layer 1 — tenant + owner isolation. Always on. Cannot be disabled. A request can never see another tenant's data.
- Layer 2 — per-resource policies. Declarative expressions you write in
maravilla.config.ts. Evaluated on every KV / DB / realtime / media op against that resource. Configurable per-resource and toggleable per-request.
This skill is the reference for the Layer-2 expression language. For where policies are declared, see maravilla-config.
Where policies live
// maravilla.config.ts
export default defineConfig({
auth: {
resources: [
{
name: 'invites',
title: 'Birthday Invites',
actions: ['read', 'write', 'delete'],
policy: 'auth.user_id == node.owner || auth.is_admin || node.public == true',
},
],
},
});
name is the resource key — for KV that's the namespace, for DB that's the collection name, for storage it's the bucket. The runtime maps every op to a resource and runs that resource's policy string.
Resources without a policy skip Layer-2 entirely (Layer 1 still applies). Resources with a policy must have their expression evaluate truthy or the op fails.
The two scopes: auth.* and node.*
Every policy sees exactly two top-level objects.
auth.* — the bound caller
This is whatever platform.auth.getCurrentUser() would return for this request:
| Field | Type | Notes |
|---|---|---|
auth.user_id |
string | "" when anonymous |
auth.email |
string | "" when anonymous |
auth.is_admin |
boolean | Admin flag from session |
auth.roles |
string[] | Project-scoped role names |
auth.is_anonymous |
boolean | true if no identity bound |
Reminder: for auth.user_id to be non-empty you must run the 3-step contract: validate(token) → setCurrentUser(token) → op. Skipping setCurrentUser is the root cause of "logged-in but seeing empty data" bugs.
node.* — the resource payload for this op
Shape depends on the op:
- DB write — the document being inserted/updated.
- DB read — each candidate document from the result set (rows are filtered post-policy if denied).
- KV put —
{ key, value, namespace }. - KV get —
{ key, namespace, ...value-fields-if-known }. - Storage put / get —
{ key, contentType, size }. - All ops carry
node.action—"read" | "write" | "delete"— so you can branch per-action in a single expression.
Common patterns
Owner-only
'auth.user_id == node.owner || auth.is_admin'
The classic. Every doc has an owner field set to a user id; the owner and admins can do anything.
Owner with a public escape hatch
'auth.user_id == node.owner || auth.is_admin || node.public == true'
Used by capability-link sharing — the owner stores some records under unguessable ids (e.g. inv:{nanoid}) with public: true, so anyone with the link can read but the owner's full list stays private.
Per-action branches
'(node.action == "read" && node.status == "published" && node.visibility == "public") '
+ '|| auth.roles.contains("teacher") '
+ '|| auth.is_admin'
Reads are public when published-and-public; teachers and admins do anything.
Authenticated-only writes, anyone-reads
'node.action == "read" '
+ '|| (node.action == "write" && auth.user_id != "") '
+ '|| (node.action == "delete" && (auth.user_id == node.author_id || auth.is_admin))'
Comment-board pattern: anyone reads, any signed-in user can write, only the author or an admin can delete.
Membership in a group
'auth.is_admin || auth.user RELATES "g_coordinators" VIA "MEMBER_OF"'
Resolves at evaluation time against the user-group-membership table.
Stewardship via a custom relation
Given relations: [{ relation_name: 'STEWARDS', implies_stewardship: true }] in your config:
'auth.user_id == node.owner '
+ '|| auth.user RELATES node.owner VIA "STEWARDS" '
+ '|| auth.is_admin'
Lets a steward (e.g. a parent) act on a minor's records.
Self-only with a guarded write source
'auth.user_id == node.user_id && node.source == "self" '
+ '|| auth.is_admin'
Used in self-enroll endpoints where the doc must be tagged source: "self" — admin-assigned variants are blocked from this writer and handled by a separate path.
Read-only collection (audit logs)
'auth.is_admin'
Only admins can read or write the admin_audit collection.
Operators and built-ins
- Comparison:
==,!=,<,<=,>,>= - Logical:
&&,||,! - Membership:
auth.roles.contains("teacher"),node.tags.contains("featured") - Existence:
auth.user_id != ""for "is signed in" - Relations:
auth.user RELATES <target> VIA "<RELATION_NAME>"— relation name must match aRelationTypeDefinition.relation_namein your config
The expression language is intentionally narrow — if you need wall-clock checks or external lookups, do it in your handler before the op (or use platform.auth.can() to test ahead of time).
Per-request opt-out (admin paths only)
For trusted in-app flows — first-run seeders, admin batch jobs — you can disable Layer 2 for the remainder of the current request:
const platform = getPlatform();
platform.policy.setEnabled(false); // Layer 1 still applies
try {
await runSeeder();
} finally {
platform.policy.setEnabled(true);
}
Every flip is audit-logged server-side with the caller's identity. Do not branch on untrusted input — only on stable conditions like caller.is_admin.
Pre-checking with can()
Before running a UI action, ask the policy engine:
const ok = await platform.auth.can('delete', 'documents', {
owner: doc.owner,
status: doc.status,
});
if (!ok) return new Response('Forbidden', { status: 403 });
The can() evaluator is the same engine that gates direct ops, so its answer is authoritative.
Debugging "logged in but empty list"
Symptoms: cookie is present, event.locals.user is set, but find() returns 0 rows.
Almost always caused by skipping setCurrentUser(token) in your hook. The 3-step contract is non-negotiable; see maravilla-auth. Quick checklist:
- Is
platform.auth.setCurrentUser(token)called in yourhooks.server.ts(or RR7 / Nitro equivalent) right aftervalidate(token)? - Inside the loader, log
platform.auth.getCurrentUser()— does it show the rightuser_id, or""? - If
user_idis set but reads still return empty, the policy is denying — logawait platform.auth.can('read', '<resource>', sample_node)to see what's blocking.
Related skills
- maravilla-config — where to declare resources, groups, relations
- maravilla-auth — the 3-step request binding
- maravilla-db, maravilla-kv, maravilla-storage — the ops gated by policies
Full reference: https://www.maravilla.cloud/llms-full.txt.
More from maravilla-labs/maravilla-cli
maravilla-auth
Maravilla Cloud authentication. Use whenever wiring login/register/session, OAuth callbacks, resource policies, or hitting `platform.auth.*` APIs. Critical: the 3-step request-scoped contract (validate → setCurrentUser → can) — skipping any step silently breaks Layer-2 policies and owner-scoped reads return empty with no error.
12maravilla-events
Maravilla Cloud event handlers — files in `events/*.ts` auto-discovered by the framework adapter. Use to react to data changes (`onKvChange`, `onDb`), auth lifecycle (`onAuth`), schedule (`onSchedule`), queue messages (`onQueue`), realtime publishes (`onChannel`), deploy phases (`onDeploy`), object storage (`onStorage`), or arbitrary REN events (`defineEvent`). Run inside the Maravilla runtime with full platform access via `ctx`.
12maravilla-workflows
Maravilla Cloud durable workflows — replay-based, multi-step processes that survive restarts. Use whenever you need sleeps spanning minutes/hours/days, multi-step pipelines where each step's output feeds the next, waiting for external events, or strict step-history audit. `defineWorkflow` from `@maravilla-labs/functions/workflows/runtime` with `step.run`, `step.sleep`, `step.sleepUntil`, `step.waitForEvent`, `step.invoke`.
12maravilla-media-transforms
Async media + document derivations via `platform.media.transforms` and the declarative `transforms` block in `maravilla.config.ts`. Media: transcode video, thumbnail extraction, image resize/variants, OCR. Documents (.docx/.odt/.pptx/.xlsx/...): convert to PDF, render page thumbnails, generic format conversion, Markdown extraction (RAG-ready), single-file HTML with inlined images, image-replacement templating ({{TAG}} swap + named-object swap), QR-code injection. Use when ingesting user uploads that need normalised renditions, generating contracts/invoices from templates, or extracting structured content for LLMs. Critical: derived keys are content-addressed — `keyFor(srcKey, spec)` is known up front, before the worker starts, so clients can render placeholder UI without round-trips. Declarative config is the default; imperative `transforms.*` calls are for one-offs.
12maravilla-db
Maravilla Cloud document database — MongoDB-style queries, secondary indexes, and vector search. Use for structured app data, multi-field queries, sorting, semantic search via `findSimilar` / hybrid `find` with `options.vector`. Exposed as `platform.env.DB`. Vector indexes support int8/bit quantization, matryoshka, and multi-vector (ColBERT) out of the box.
11maravilla-config
The `maravilla.config.ts` declarative project file. Use whenever creating or modifying auth resources, groups, relations, registration fields, OAuth providers, password/session policy, branding, database indexes, or media transforms. Reconciled into delivery on every deploy — partial adoption is supported (omit a section to leave it untouched).
11