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/seedAdmin.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 — the built-in MTA (default), Resend, or
SES:
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 EMAIL_PROVIDER=mta (the default), sendViaMta ultimately POSTs to
${MTA_API_URL}/send with a Bearer ${MTA_API_KEY} header — the same MTA that
delivers your campaigns. Resend and SES are also supported for
invitation/password-reset/email-change mail, selected by EMAIL_PROVIDER.
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/seedAdmin.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>
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.