Authentication
Owlat uses BetterAuth with the Convex adapter for authentication and organization (team) management.
Owlat uses BetterAuth with the Convex adapter for authentication and organization (team) management.
Overview
- BetterAuth handles user registration, login, and sessions
- Organization Plugin manages team membership within the single organization
- Session-based context resolves the user, their role, and the active org from the request — no
teamIdparameter is passed around
Single organization per instance. Each Owlat install hosts exactly one organization. The org is bootstrapped at install time by owlat quickstart (see Setup CLI & Installer), which mints the first admin through the backend POST /seed/admin action (apps/api/convex/seedAdminHttp.ts). BetterAuth's auth/organization/create endpoint is disabled at the plugin level (allowUserToCreateOrganization: false), the auth client does not re-export organization.create/organization.delete, and a runtime invariant in getBetterAuthSessionWithRole refuses any request whose session points at a non-singleton org. Multi-tenant SaaS hosting is handled by a separate control plane (a private codebase), which provisions a dedicated Owlat instance per tenant.
Architecture
useAuth()useOrganization()Configuration
Server (auth.ts)
apps/api/convex/auth/auth.ts builds the BetterAuth instance from a Convex
ActionCtx. The real config wires four plugins, custom roles, and delivers all
auth email through the configured system-email transport
(internal.systemMail.sendSystemEmail), which routes to whatever delivery
provider is set via EMAIL_PROVIDER (or a per-org provider route) — the
built-in MTA, Amazon SES, Resend, a generic SMTP relay, or Mailchimp
Transactional. There is no implicit default: with none of them configured, auth
mail is refused rather than sent through the MTA.
import { betterAuth } from 'better-auth';
import { organization, oneTimeToken } from 'better-auth/plugins';
import { createAccessControl } from 'better-auth/plugins/access';
import { convex, crossDomain } from '@convex-dev/better-auth/plugins';
import { getOptional } from '../lib/env';
// Custom access control: rename BetterAuth's default 'member' role to 'editor'.
const ac = createAccessControl({ ...defaultStatements });
const owner = ac.newRole({ ...ownerAc.statements });
const admin = ac.newRole({ ...adminAc.statements });
const editor = ac.newRole({ ...memberAc.statements });
export const createAuthOptions = (ctx: ActionCtx) => ({
database: authComponent.adapter(ctx),
secret: getOptional('BETTER_AUTH_SECRET'),
baseURL: getOptional('SITE_URL'),
emailAndPassword: {
enabled: true,
minPasswordLength: 10,
maxPasswordLength: 128,
sendResetPassword: async ({ user, token }) => {
await sendViaMta({ to: user.email, /* …reset email… */ });
},
},
session: {
expiresIn: 60 * 60 * 24 * 3, // 3-day sessions
updateAge: 60 * 60 * 12,
cookieCache: { enabled: true, maxAge: 5 * 60 },
},
plugins: [
// Cookieless cross-origin auth for the Tauri desktop app.
crossDomain({ siteUrl: getOptional('SITE_URL') }),
// Short-lived token redeemed by the desktop deep-link handshake.
oneTimeToken(),
// Provides /convex/token + /convex/jwks; mints the JWT Convex verifies.
// The JWT payload carries `activeOrganizationId` so the backend can
// resolve the active org from the claim without a session-table read.
convex({ authConfig, jwt: { definePayload: ({ user, session }) => ({
...user,
activeOrganizationId: session.activeOrganizationId ?? null,
}) } }),
organization({
ac,
roles: { owner, admin, editor },
// Single-org-per-instance: the one org is bootstrapped by /seed/admin;
// users cannot create more.
allowUserToCreateOrganization: false,
creatorRole: 'owner',
membershipLimit: 50,
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days
// Invitation emails are sent through the instance MTA.
sendInvitationEmail: async ({ email, organization: org, inviter, invitation }) => {
const acceptUrl = `${getOptional('SITE_URL')}/invite/accept?id=${invitation.id}`;
await sendViaMta({ to: email, /* …invitation email… */ });
},
}),
],
});
When the resolved provider is the built-in MTA (EMAIL_PROVIDER=mta),
sendViaMta ultimately POSTs to ${MTA_API_URL}/send with a
Bearer ${MTA_API_KEY} header — the same MTA that delivers your campaigns.
Every other kind the catalog declares carries invitation/password-reset/
email-change mail just as well, selected by EMAIL_PROVIDER or a provider
route.
Client (auth-client.ts)
apps/web/app/lib/auth-client.ts builds the BetterAuth Vue client. There is a
web/desktop split: the web build proxies auth requests same-origin (Nitro)
using window.location.origin (falling back to NUXT_PUBLIC_SITE_URL during
SSR), while the Tauri desktop build talks directly to the active workspace's
Convex site URL and carries the session in a header via the crossDomainClient
(persisted in the OS keychain).
import { createAuthClient } from 'better-auth/vue';
import { convexClient, crossDomainClient } from '@convex-dev/better-auth/client/plugins';
import { organizationClient } from 'better-auth/client/plugins';
function createWebAuthClient() {
const siteUrl =
typeof window !== 'undefined'
? window.location.origin
: (globalThis.process?.env?.NUXT_PUBLIC_SITE_URL ?? 'http://localhost:3000');
return createAuthClient({
baseURL: siteUrl,
plugins: [convexClient(), organizationClient()],
});
}
// Desktop (Tauri) adds the cross-domain plugin + keychain-backed storage.
export const authClient = isDesktopRuntime()
? createAuthClient({
baseURL: getActiveWorkspace()?.convexSiteUrl ?? 'http://localhost:3211',
plugins: [convexClient(), organizationClient(), crossDomainClient({ storage: keychainStorage })],
})
: createWebAuthClient();
// Auth methods.
export const { signIn, signUp, signOut, useSession, getSession } = authClient;
// Organization methods. `organization.create`/`organization.delete` are
// intentionally NOT re-exported — Owlat is single-org-per-instance.
export const {
organization: {
update: updateOrganization,
getFullOrganization,
list: listOrganizations,
setActive: setActiveOrganization,
checkSlug: checkOrgSlug,
inviteMember,
acceptInvitation,
rejectInvitation,
cancelInvitation,
removeMember,
updateMemberRole,
getActiveMember,
listMembers,
listInvitations,
leave: leaveOrganization,
},
useListOrganizations,
useActiveOrganization,
} = authClient;
Frontend Composables
useAuth()
Main authentication composable (apps/web/app/composables/useAuth.ts). It wraps
authClient.useSession() and exposes a reactive session plus the auth actions.
const {
sessionData, // Full BetterAuth session payload | null
user, // Current user | null
currentSession, // session row (carries activeOrganizationId)
status, // 'pending' | 'authenticated' | 'unauthenticated' | 'error'
isAuthenticated, // Boolean (status === 'authenticated')
isPending, // Boolean (status === 'pending')
error, // Error | null
activeOrganizationId, // Active org ID from the session
hasActiveOrganization, // Boolean
signInWithEmail, // (email, password) => Promise
signUpWithEmail, // (email, password, name) => Promise
signOut, // () => Promise
forgotPassword, // (email) => Promise
resetPassword, // (newPassword, token) => Promise
refetch, // force-refresh the session
waitUntilReady, // await until status leaves 'pending'
} = useAuth();
The caller's role does not come from useAuth() — it lives on
useOrganization().currentMemberRole.
useOrganization()
Organization membership management
(apps/web/app/composables/useOrganization.ts). Backed by shared useState, so
all callers share one set of HTTP requests per org switch.
const {
// State
organization, // active org | null
organizationId, // active org ID | null
organizations, // list of orgs the user belongs to
members, // Ref<OrganizationMember[]>
invitations, // Ref<OrganizationInvitation[]> (pending only)
currentMemberRole, // 'owner' | 'admin' | 'editor' | null
isLoading,
isLoadingMembers,
// Permission checks
canManageMembers, // owner or admin
isOwner,
// Actions
fetchMembers, // ({ force }?) => Promise
invite, // (email, role, mailbox?) => Promise
remove, // (memberIdOrEmail) => Promise
updateRole, // (memberId, role) => Promise
cancelInvite, // (invitationId) => Promise
setActive, // (orgId) => Promise
update, // ({ name?, slug?, logo? }) => Promise
getFullOrganization, // () => Promise
} = useOrganization();
The app role vocabulary is owner | admin | editor. BetterAuth stores editor
as its built-in member role internally — useOrganization maps between the two
at the boundary, so you always see editor in app code.
Authentication Flow
Registration
Public sign-up is invite-only. The register page
(apps/web/app/pages/auth/register.vue) shows a "Registration is disabled"
notice unless it was reached via an /invite/accept?id=… redirect. There is no
path that creates a fresh organization at sign-up time. The first organization is
created once, during install, by POST /seed/admin (see
apps/api/convex/seedAdminHttp.ts); every subsequent user joins that singleton org
as an editor/admin/owner via the invite flow.
<script setup>
import { api } from '@owlat/api';
const { signUpWithEmail } = useAuth();
const { run: createUserProfile } = useBackendOperation(api.auth.userProfiles.create);
const route = useRoute();
async function register() {
// 1. Create the BetterAuth user (also refreshes the session).
const result = await signUpWithEmail(email.value, password.value, name.value);
// 2. Create the Convex-side user profile (non-blocking; failures are toasted).
if (result?.user?.id) {
await createUserProfile({
authUserId: result.user.id,
email: email.value,
name: name.value,
});
}
// 3. Continue to the invite-accept page (or dashboard); BetterAuth attaches
// the new user to the singleton org with the invited role.
await navigateTo((route.query.redirect as string) || '/dashboard');
}
</script>
Email verification
REQUIRE_EMAIL_VERIFICATION gates whether a signup or invitation acceptance must
confirm a verification link before it can sign in (BetterAuth
requireEmailVerification + sendOnSignUp + the org plugin's
requireEmailVerificationOnInvitation). New deployments default it on — the
self-host env templates and the setup CLI write it for a fresh install. The web
setup wizard does not emit the flag itself, but when it is set the wizard refuses
to finish with verification on but no delivery provider, since the verification
mail routes through the system transport (which fails closed when no provider is
configured). The seeded owner is created already-verified, so
first-run admin is never locked out.
Existing installs are deliberately left unset. Enabling it on an install whose
users predate verification would lock out everyone who has no emailVerified
flag, so an upgrade never sets it — turn it on only after backfilling
emailVerified for legacy users.
If a user is ever stranded (mail outage, an account that predates verification),
an owner/admin recovers them without a database edit via
auth/emailVerificationAdmin.ts:
markMemberEmailVerified— mark the member's email verified out-of-band (idempotent, org-scoped, audited).resendMemberVerificationEmail— re-send the verification link through the same route a signup uses.
Login
<script setup>
const { signInWithEmail } = useAuth();
async function login() {
await signInWithEmail(email.value, password.value);
await navigateTo('/dashboard');
}
</script>
Protected Routes
<!-- In page component -->
<script setup>
definePageMeta({
middleware: 'auth',
});
</script>
Invite Acceptance
<!-- pages/invite/accept.vue -->
<script setup>
const { isAuthenticated } = useAuth();
const invitationId = useRoute().query.id;
async function acceptInvitation() {
await authClient.organization.acceptInvitation({
invitationId,
});
await navigateTo('/dashboard');
}
</script>
<template>
<div v-if="!isAuthenticated">
Please <NuxtLink to="/auth/login">log in</NuxtLink> to accept this invitation.
</div>
<div v-else>
<UiButton @click="acceptInvitation"> Accept Invitation </UiButton>
</div>
</template>
Backend Session Context
All backend session helpers live in
apps/api/convex/lib/sessionOrganization.ts. There is no teamId anywhere
in the backend session context — the single-org refactor removed the team-ID
concept. Data is scoped by the active org, which the helpers read from the
Convex JWT activeOrganizationId claim (falling back to the BetterAuth session
table while older tokens rotate out). A runtime invariant
(assertSingletonOrgInvariant) confirms exactly one org exists and that the
session's active org matches it.
Most public functions never call these helpers by hand. They use the
authedQuery / authedMutation / authedAction / adminMutation wrappers from
apps/api/convex/lib/authedFunctions.ts (see Convex Backend),
which run the session/role resolution and permission gate for you. Reach for the
helpers below when you need finer-grained control inside a wrapped handler.
Session helpers
| Helper | Returns | Use |
|---|---|---|
getBetterAuthSession(ctx) | { userId, activeOrganizationId } | null | Soft read; null for anonymous callers |
getBetterAuthSessionWithRole(ctx) | adds role: OrganizationRole | null; enforces the singleton-org invariant | When you need the caller's role |
getUserIdFromSession(ctx) | string (userId) | Read-only queries that need only the user ID; throws if unauthenticated |
requireAuthenticatedIdentity(ctx) | Convex UserIdentity | Reject anonymous callers in mutations/actions |
getMutationContext(ctx) | { userId, role } (MutationSessionContext) | Primary helper for session-based mutations; throws if unauthenticated, no active org, or not a member |
import {
getUserIdFromSession,
getMutationContext,
} from './lib/sessionOrganization';
// In a query: just need the user.
export const list = authedQuery({
handler: async (ctx) => {
const userId = await getUserIdFromSession(ctx);
// scope reads to the active org / userId
},
});
// In a mutation: need the role too. Note there is no teamId.
export const create = authedMutation({
args: { name: v.string() },
handler: async (ctx, args) => {
const { userId, role } = await getMutationContext(ctx);
requirePermission(hasPermission(role, 'contacts:manage'), 'Only owners/admins can create contacts');
},
});
Role-Based Access
Roles
owner- Full access
- Delete team
- Transfer ownership
admin- Full access
- Manage members
- Cannot manage owners
editor- Create & edit content
- No team management
Permission Checks
Permissions are a typed scope:verb system, also defined in
lib/sessionOrganization.ts (there is no ./lib/permissions module, and the old
isAdminRole/isOwnerRole helpers were removed). hasPermission(role, permission)
maps a role to a boolean; requirePermission(boolean, message?) is the assertion
gate.
The Permission union:
| Permission | Granted to |
|---|---|
campaigns:send, campaigns:manage, campaigns:schedule | owner, admin |
templates:manage, automations:manage | owner, admin |
topics:manage, segments:manage | owner, admin |
media:manage, shareLinks:manage, imports:manage | owner, admin |
contacts:manage | owner, admin |
organization:manage, settings:manage | owner, admin |
chat:manage | owner, admin |
organization:delete | owner only |
emails:test, knowledge:read, chat:participate | any member |
import {
hasPermission,
requirePermission,
requireAdminContext,
requireOwnerContext,
requireOrgPermission,
getMutationContext,
} from './lib/sessionOrganization';
// Boolean check.
if (hasPermission(role, 'settings:manage')) {
// ...
}
// Assertion: requirePermission takes a boolean + message (NOT a role).
const { role } = await getMutationContext(ctx);
requirePermission(hasPermission(role, 'settings:manage'), 'Only owners/admins can change settings');
For common cases, prefer the higher-level gates that fetch the context and assert in one call:
// Admin (owner/admin) — wraps getMutationContext, mutation-only.
const session = await requireAdminContext(ctx);
// Owner only.
const session = await requireOwnerContext(ctx);
// Any typed permission, works in queries OR mutations (e.g. admin-gated reads).
const session = await requireOrgPermission(ctx, 'settings:manage');
API Key Authentication
Public REST endpoints authenticate with an API key instead of a session. The
logic lives in apps/api/convex/auth/apiAuth.ts. Keys are lm_live_-prefixed,
stored only as a SHA-256 hash, and rate-limited to 10 requests per second per
key.
authenticateApiRequest(ctx, request) returns a discriminated union — the
discriminant is success, and on success you get a keyId, the key's scopes,
and rateLimit (there is no teamId; keys are key-scoped):
type ApiAuthResponse =
| { success: true; keyId: Id<'apiKeys'>; scopes: string[]; rateLimit: RateLimitHeaders }
| { success: false; error: string; status: number; retryAfter?: number };
The idiomatic way to build an endpoint is createAuthenticatedHandler, which
authenticates the request, handles CORS preflight, injects { keyId, scopes, rateLimit },
and attaches rate-limit headers to the response. Keys carry scopes enforced via
requireScope(auth, scope):
import { createAuthenticatedHandler, requireScope } from './auth/apiAuth';
export const handleRequest = createAuthenticatedHandler(async (ctx, request, auth) => {
// auth.keyId — the validated API key ID
// auth.scopes — the scopes granted to this key
// auth.rateLimit — { limit, remaining, reset }
const denied = requireScope(auth, 'contacts:read', request.headers.get('Origin'));
if (denied) return denied; // 403 when the key lacks the scope
return jsonResponse({ ok: true });
});
If you call authenticateApiRequest directly, branch on success:
const auth = await authenticateApiRequest(ctx, request);
if (!auth.success) {
return errorResponse(auth.status === 429 ? 'rate_limited' : 'unauthenticated', auth.error);
}
// auth.keyId, auth.rateLimit are now available
API keys are created and managed in the dashboard — see API Keys & Webhooks and the Authentication API reference.