maravilla-auth
Maravilla Cloud Auth
platform.auth exposes both the public auth surface (register / login / OAuth / refresh / password reset) and the request-scoped identity binding that every protected handler must run.
The hosted auth pages at /_auth/login and /_auth/register set a __session cookie containing a JWT access token. Your server code's job is to translate that cookie into a bound identity for the rest of the request.
The 3-step contract — read this first
Every request that needs to act as an authenticated user must run these three steps in order:
validate(token)— confirm the JWT and return theAuthUser. If invalid, treat as anonymous.setCurrentUser(token)— bind that identity to this request. Without this, every subsequent KV/DB/realtime/media op runs as anonymous, even though you have a validAuthUserin hand.- (optional)
can(action, resource, node?)— ask the policy engine, ahead of time, whether the bound caller is allowed to do something. The same evaluator gates direct ops, socan()is authoritative.
Skipping step 2 is the single most common Maravilla bug. Owner-scoped policies like auth.user_id == node.owner will see auth.user_id == "" and silently filter everything out. The UI shows an empty list. There is no error.
Canonical SvelteKit hooks.server.ts
This is the verbatim pattern from the demo app — every SvelteKit Maravilla project should have something equivalent:
import type { Handle } from '@sveltejs/kit';
import { getPlatform } from '@maravilla-labs/platform';
/**
* Resolve the current user once per request, *before* any load runs.
*
* SvelteKit runs `load` functions in parallel by default. Putting the
* session lookup in `+layout.server.ts` works for the layout's own data,
* but child loads that read `event.locals.user` may fire before the
* layout has written to it — leading to spurious redirects on pages
* like `/invites` even when the topbar clearly shows a signed-in user.
*
* Hooks run once per request, serially, before any load. Whatever we set
* on `event.locals` here is visible to every load in the tree.
*/
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null;
const token = event.cookies.get('__session');
if (token) {
try {
const platform = getPlatform();
const user = await platform.auth.validate(token);
// Bind identity for Layer-2 policies on any KV/DB/realtime/media
// op that runs later in this request.
await platform.auth.setCurrentUser(token);
event.locals.user = user;
} catch {
// Stale / revoked / malformed token — treat as anonymous.
// The browser still carries the cookie; the topbar will offer
// Login/Register. We deliberately don't delete the cookie here:
// clock skew or transient validation errors shouldn't log users
// out. The user can log out explicitly via /logout.
}
}
return resolve(event);
};
Two non-obvious choices to copy:
- Run in
hooks.server.ts, not+layout.server.ts. Layouts run in parallel with child loads — child loads can readevent.locals.userbefore the layout writes to it. Hooks run serially before any load. - Don't clear the cookie on validation failure. Clock skew and transient errors shouldn't log a user out. Let the user log out explicitly.
The React Router 7 equivalent is in maravilla-frameworks-react-router.
Public APIs
Register
const user = await platform.auth.register({
email: 'user@example.com',
password: 'securePassword123', // min 8 chars, plus your password_policy
profile: { display_name: 'Alex' }, // custom fields configured in maravilla.config.ts
});
// user.email_verified === false until verifyEmail()
profile carries whatever custom fields you declared under auth.registration.fields in your config. The fields surface as event.data.profile in any onAuth({ op: 'registered' }) handler — that's the canonical place to mint your app-side users doc.
Login
const session = await platform.auth.login({
email: 'user@example.com',
password: 'securePassword123',
});
// session.access_token — short-lived JWT (default 15 min)
// session.refresh_token — single-use opaque token (default 30 days)
// session.expires_in — seconds
// session.user — AuthUser
login() implicitly binds the caller for the remainder of the request. You don't need to call setCurrentUser after a successful login.
OAuth
// 1. Start the flow
const { auth_url, state } = await platform.auth.getOAuthUrl('google', {
redirectUri: 'https://myapp.com/auth/callback',
});
// Persist `state` (cookie or KV) for CSRF verification, then redirect.
// 2. Handle the callback
const result = await platform.auth.handleOAuthCallback('google', {
code: url.searchParams.get('code')!,
state: url.searchParams.get('state')!,
});
if ('access_token' in result) {
// AuthSession — user is authenticated
return setSessionCookieAndRedirect(result);
} else {
// { type: 'LinkRequired', email, provider, provider_id, existing_user_id }
// The OAuth identity belongs to a different existing account; ask the user
// to log into that account and link the provider explicitly.
}
Supported providers: google, github, okta, custom_oidc. Configure them in maravilla.config.ts under auth.oauth — see maravilla-config.
Refresh
const newSession = await platform.auth.refresh(refresh_token);
// Old refresh_token is now invalid (single-use)
Logout / password / email
await platform.auth.logout(sessionId);
await platform.auth.sendPasswordReset(email); // returns { token } — caller delivers
await platform.auth.resetPassword(token, newPassword);
await platform.auth.changePassword(userId, oldPassword, newPassword);
await platform.auth.sendVerification(userId); // returns { token }
await platform.auth.verifyEmail(token);
Request-scoped identity
These methods are only available inside the runtime (during a Deno isolate request). They throw on remote clients (e.g. when running CLI scripts):
setCurrentUser(token) — explicit bind
await platform.auth.setCurrentUser(token); // bind from a JWT
await platform.auth.setCurrentUser(null); // clear → anonymous
Use after extracting a token from an inbound Authorization header or session cookie.
getCurrentUser() — snapshot
const caller = platform.auth.getCurrentUser();
// {
// user_id: string, // "" if anonymous
// email: string,
// is_admin: boolean,
// roles: string[], // project-scoped role names
// is_anonymous: boolean,
// }
This is exactly what Layer-2 policies see as auth.*.
can(action, resource, node?) — pre-check
const ok = await platform.auth.can('delete', 'documents', {
owner: doc.owner,
status: doc.status,
});
if (!ok) return new Response('Forbidden', { status: 403 });
Runs the exact same evaluator as the direct op gate, so can() is authoritative. Returns a boolean; never throws on denial.
withAuth(handler) — convenience middleware
export default {
fetch: platform.auth.withAuth(async (request) => {
// request.user is guaranteed to be set; otherwise 401 JSON returned automatically
const data = await platform.env.DB.find('items', { owner: request.user.id });
return Response.json(data);
}),
};
Extracts the token from Authorization: Bearer <token> or __session cookie, validates it, binds the caller, and injects request.user.
Admin operations
const user = await platform.auth.getUser(userId);
const page = await platform.auth.listUsers({
limit: 50, offset: 0,
status: 'active',
email_contains: 'gmail.com',
group_id: 'g_123',
});
await platform.auth.updateUser(userId, {
email: 'new@example.com',
status: 'suspended',
profile: { tier: 'pro' },
});
await platform.auth.deleteUser(userId);
These bypass the normal user-facing endpoint and require the caller to be admin (or for Layer-2 to be off via platform.policy.setEnabled(false)).
AuthUser shape
AuthUser is exported from @maravilla-labs/platform — import type { AuthUser } from '@maravilla-labs/platform'. Notes worth knowing without opening the file:
idis"usr_..."(nanoid-prefixed).statusis'active' | 'suspended' | 'deactivated'. Suspended users still authenticate but most policies should reject them — check explicitly.provideris the auth provider key ("email","google", etc.) — match strings, not booleans.groupscarries group IDs, not names — useplatform.policypredicates rather than string-matching.created_at/updated_at/last_login_atare unix seconds (not ms).
Common pitfalls
- The "empty list" bug. You called
validate(), setevent.locals.user, but skippedsetCurrentUser(). Owner-scoped reads return zero rows because the policy engine sees an anonymous caller. Fix: always pairvalidatewithsetCurrentUserin your hook. setCurrentUserthrown error on a remote client. That method only works inside the runtime. CLI / Node scripts that hold a token can call public APIs but cannot bind a caller.- Mixing
withAuthwith manual binding.withAuthalready runs the contract; don't callsetCurrentUseragain inside the handler. - Caching
getCurrentUser()across requests. Don't. The caller is request-scoped — store theevent.locals.userfrom your hook instead.
Related skills
- maravilla-config — declare resources, password policy, OAuth, registration fields
- maravilla-policies — the
auth.* / node.*expression language - maravilla-events —
onAuth({ op: 'registered' })for app-side user provisioning - Framework patterns: sveltekit, react-router, nuxt
Full reference: https://www.maravilla.cloud/llms-full.txt.
More from maravilla-labs/maravilla-cli
maravilla-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).
11maravilla-push
Maravilla Cloud Web Push — server `platform.push.send/schedule/cancelScheduled/listScheduled` with idempotent keys and recurring `everySeconds`, browser `registerPush({ topics, userId, swPath })` from `@maravilla-labs/platform/push`. Use for browser notifications, scheduled reminders, recurring digests, and per-user fan-out by topic.
11