Caravela treats authentication as a trait on a domain entity, not a
separate library. Declare the authenticatable block on your user
entity; the compiler extends the IR with the credential fields your
strategies need, and mix caravela.gen.auth emits the full auth stack:
a context module, a session schema, Plug pipelines, LiveView on_mount
hooks, an HTTP controller, and a session-tokens migration.
The generated code is standard Phoenix. You can edit it, eject it, or
let Caravela regenerate it — user-authored code below the
# --- CUSTOM --- marker is preserved on every rerun.
DSL
defmodule MyApp.Domains.Identity do
use Caravela.Domain, multi_tenant: true
version "v1"
entity :users do
field :email, :string, required: true, unique: true
field :name, :string, required: true
field :role, :string, default: "viewer"
authenticatable do
strategy :password
strategy :api_token,
scopes: [:read, :write, :admin],
ttl: {90, :days},
max_tokens: 5
session :token,
ttl: {30, :days},
remember_me: {365, :days},
max_sessions: 5
confirm :email, token_ttl: {24, :hours}
reset :password, token_ttl: {1, :hour}
on_register fn changeset, _context -> changeset end
on_login fn user, _context ->
if user.suspended, do: {:error, :suspended}, else: :ok
end
end
end
endStrategies
| Strategy | Effect |
|---|---|
:password | Email + Argon2-hashed password. Requires an :email field on the entity. Injects hashed_password. |
:api_token | Bearer tokens with scope + expiry. Injects api_tokens (map). |
Lifecycle options
session :token, ttl: {n, :days}, remember_me: {n, :days}, max_sessions: nconfirm :email, token_ttl: {n, :hours}— injectsconfirmed_at; unconfirmed users cannot log in.reset :password, token_ttl: {n, :hours}— enables reset flow.on_register fn changeset, ctx -> changeset endon_login fn user, ctx -> :ok | {:error, term} end
Compile-time validations
- Exactly one entity per domain may be
authenticatable. strategy :passwordrequires an:emailfield.- You may not manually declare
:hashed_password,:confirmed_at, or:api_tokens; they are auto-injected.
Generate
mix caravela.gen.auth MyApp.Domains.Identity
Emits (for a domain with version "v1"):
# Server (Phase 7)
lib/my_app/identity/v1/auth.ex # context (register/login/logout/…)
lib/my_app/identity/v1/user_session.ex # session schema
lib/my_app_web/plugs/auth.ex # Plug pipeline
lib/my_app_web/live/auth_hooks.ex # LiveView on_mount hooks
lib/my_app_web/controllers/v1/auth_controller.ex
priv/repo/migrations/<ts>_create_identity_user_sessions.exs
# UI (Phase 8)
assets/svelte/v1/auth/LoginForm.svelte
assets/svelte/v1/auth/RegisterForm.svelte # fields synthesised from the entity
assets/svelte/v1/auth/ResetPasswordForm.svelte # two-phase: request + confirm
assets/svelte/v1/auth/ConfirmEmail.svelte
assets/svelte/v1/auth/TokenManager.svelte # only with strategy :api_token
assets/svelte/v1/auth/SessionList.svelte
lib/my_app_web/live/v1/auth_live/*.ex # matching LiveView pagesA router snippet (public auth routes, authenticated live_session,
authenticated API) is printed to stdout — paste it into your router.
Flags: --dry-run, --output DIR, --force, --skip-ui (server only),
--skip-router (suppress the snippet).
Wire it up
Add to your router:
import MyAppWeb.Plugs.Auth
pipeline :api do
plug :accepts, ["json"]
plug :fetch_current_user
end
pipeline :require_auth do
plug :require_auth
end
scope "/api/v1", MyAppWeb.V1 do
pipe_through :api
post "/auth/register", AuthController, :register
post "/auth/login", AuthController, :login
delete "/auth/logout", AuthController, :logout
pipe_through :require_auth
# protected routes
endLiveView hook:
live_session :protected, on_mount: [{MyAppWeb.Live.AuthHooks, :require_auth}] do
live "/dashboard", DashboardLive
endDependencies
Password hashing uses :argon2_elixir. Add it to your deps — the
Caravela package does not pull it in:
{:argon2_elixir, "~> 4.0"}Svelte components
Every generated component is Svelte 5 ($props, $state, $derived)
and receives live: LiveHandle — the same prop every Caravela-generated
component gets — so events flow through live.pushEvent(...).
RegisterForm.svelte
Form fields are synthesised from the authenticatable entity: every
public, non-auth, non-role, non-tenant_id field gets an input.
Add field :company, :string, required: true to the entity and
regenerate — the registration form grows a required company input on
the next run. Fields with a default: (like role) are omitted so the
server picks the value.
TokenManager.svelte
Rendered only when strategy :api_token is declared. The scope <select>
is typed from the configured scopes (e.g. 'read' | 'write' | 'admin')
and a MAX_TOKENS constant reflects max_tokens:. Create/revoke
dispatch create_token / revoke_token events.
SessionList.svelte
Lists the active session rows returned by Auth.list_sessions/2. The
"current" session (the one backing the visit) is flagged and cannot be
self-revoked; a "sign out other sessions" button batches the rest.
CurrentUser as a typed Svelte prop
When the domain is authenticated, the TypeScript interfaces file adds:
export type CurrentUser = User | null;
export interface ApiToken { id: string; scope: 'read' | 'write' | 'admin'; … }
export interface Session { id: string; is_current: boolean; … }User is the entity interface with credential fields stripped:
hashed_password and api_tokens never cross the wire. confirmed_at
is kept so the UI can gate features on email confirmation.
Router snippet
Every mix caravela.gen.auth run prints a ready-to-paste snippet
containing:
- Public auth routes:
/auth/login,/auth/register,/auth/reset-password,/auth/reset-password/:token,/auth/confirm/:token - A matching
/api/auth/{register,login,logout}scope for non-browser clients - A
live_session :authenticatedwithon_mount: [{…AuthHooks, :require_auth}]— every LiveView mounted inside it receivescurrent_useras a typed Svelte prop - An authenticated API scope accepting either session cookie or
Authorization: Bearer <token>
Multi-tenancy
With multi_tenant: true, the context scopes email lookups by
tenant_id (from context.tenant_id) and stamps every registration
with the caller's tenant. fetch_current_user does not set
tenant_id itself — your app sets it earlier in the pipeline (e.g.
from a subdomain plug or X-Tenant-Id header).