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/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

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

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

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.