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 teamId parameter 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

Frontend
useAuth()
sessionuserorgId
useOrganization()
membersinvitationsrole management
BetterAuthConvex
Userauto-created
Sessionauto-created
Organizationauto-created
Memberauto-created
Invitationauto-created

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… */ });
            },
        }),
    ],
});
Email is sent via the configured transport

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

HelperReturnsUse
getBetterAuthSession(ctx){ userId, activeOrganizationId } | nullSoft read; null for anonymous callers
getBetterAuthSessionWithRole(ctx)adds role: OrganizationRole | null; enforces the singleton-org invariantWhen 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 UserIdentityReject 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:

PermissionGranted to
campaigns:send, campaigns:manage, campaigns:scheduleowner, admin
templates:manage, automations:manageowner, admin
topics:manage, segments:manageowner, admin
media:manage, shareLinks:manage, imports:manageowner, admin
contacts:manageowner, admin
organization:manage, settings:manageowner, admin
chat:manageowner, admin
organization:deleteowner only
emails:test, knowledge:read, chat:participateany 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.