Convex-Backend

Owlat nutzt Convex als serverloses Backend und erhält damit Echtzeit-Subscriptions, ACID-Transaktionen und eine TypeScript-first-Entwicklung.

Owlat nutzt Convex als serverloses Backend und erhält damit Echtzeit-Subscriptions, ACID-Transaktionen und eine TypeScript-first-Entwicklung.

Verzeichnisstruktur

Das Backend ist in Domänenordner gegliedert (contacts/, campaigns/, topics/, mail/, automations/, inbox/, domains/, delivery/, webhooks/, …). Die Pfade der Convex-Functions spiegeln das Ordnerlayout wider: Eine Query in contacts/contacts.ts wird über api.contacts.contacts.<funcName> erreicht. Die Konventionen für das Aufteilen von Dateien, die Benennung und den Ablageort für neuen Code stehen in apps/api/convex/CONVENTIONS.md.

apps/api/convex/
_generated/Auto-generated (never edit)
api.d.tsAPI type definitions
dataModel.d.tsSchema types
lib/Shared utilities
authedFunctions.tsSecure-by-default builders
sessionOrganization.tsSession, roles & permissions
sendProviders/Provider-agnostic send pipeline
ses/SES sending
resend/Resend provider
mta/Self-hosted MTA
routing.tsProvider routing & dispatch
emailProviders/Domain identity & verification
sesIdentity.tsSES domain identity
mtaIdentity.tsMTA domain identity
domainVerification.tsSPF/DKIM/DMARC checks
schema/Per-domain table modules
schema.tsMerges domain tables
auth/BetterAuth config, API keys & onboarding
http.tsHTTP route handlers
crons.tsScheduled jobs
<domain>/API functions by domain

Secure-by-default-Builder

Convex veröffentlicht jede nicht als internal markierte query / mutation / action auf der öffentlichen Client-API des Deployments — jeder anonyme Aufrufer, der die Deployment-URL kennt, kann sie aufrufen. Zu den nackten Buildern aus _generated/server zu greifen bedeutet daher „ich habe persönlich verifiziert, dass es sicher ist, dies unauthentifiziert offenzulegen“, was praktisch nie zutrifft.

Damit der sichere Weg der Standard ist, läuft jede öffentliche Function über einen der Wrapper in apps/api/convex/lib/authedFunctions.ts statt über die rohen Builder. Der Lint apps/api/scripts/check-public-functions.sh (in apps/api an bun run lint angebunden) verbietet die nackten Builder query( / mutation( / action( überall außer in authedFunctions.ts, sodass ein vergessenes Gate die CI zum Scheitern bringt, statt stillschweigend einen offenen Endpunkt auszuliefern.

BuilderAuth-MindestniveauVerwenden für
authedQueryAuthentifiziertes Organisationsmitglied (aktive Organisation + Rolle)Reaktive Lesezugriffe für angemeldete Benutzer
authedMutationAuthentifiziertes Organisationsmitglied (aktive Organisation + Rolle)Der Standard für Schreibzugriffe; für privilegierte zusätzlich ein requirePermission(...) einziehen
authedActionAuthentifiziertes Organisationsmitglied (durchgesetzt über die interne Query auth.membership.assertOrgMember)Actions, die externe Dienste aufrufen
adminMutationOwner-/Admin-Mitglied (organization:manage)Schreibzugriffe nur für Admins — die Rollenprüfung ist eingebaut
ownerMutationOwner-Rolle (organization:delete)Destruktive Schreibzugriffe auf Organisationsebene, die selbst Admins nicht dürfen
adminQueryOwner-/Admin-Mitglied (organization:manage)Sensible Lesezugriffe (API-Keys, Webhook-Secrets, geteiltes Postfach)
authedIdentityMutationNur authentifizierte Identität (keine Organisationsmitgliedschaft)Ausschließlich der schmale Registrierungspfad vor Bestehen einer Organisation
publicQuery / publicMutation / publicActionKeines — explizites Opt-outToken-geschützte Links, signaturgeprüfte Webhooks, Tracking-Pixel, die Setup-Seite vor der Authentifizierung
Öffentliche Functions brauchen eine Begründung

Jede Verwendung von publicQuery / publicMutation / publicAction MUSS an der Aufrufstelle einen Kommentar // public: <reason> tragen — das macht „dies ist absichtlich öffentlich“ zu einer expliziten, greppbaren, per Lint-Allowlist erfassten Entscheidung statt zum Standard.

Ein separater Lint, apps/api/scripts/check-permissions.sh, verlangt zusätzlich, dass jede zustandsverändernde authedMutation / authedAction eine explizite Autorisierungsentscheidung trifft (ein rollenführender Wrapper, ein requirePermission(...) im Handler oder ein Opt-out-Kommentar // authz: <reason> bzw. // all-members: <reason>). Siehe apps/api/convex/CONVENTIONS.md § Permissions.

Function-Typen

Queries (nur lesend, in Echtzeit)

Queries sind reaktiv — Clients erhalten automatisch Aktualisierungen, wenn sich Daten ändern.

import { authedQuery } from './lib/authedFunctions';
import { getUserIdFromSession } from './lib/sessionOrganization';
import { v } from 'convex/values';

export const list = authedQuery({
    args: { organizationId: v.string() },
    handler: async (ctx, args) => {
        await getUserIdFromSession(ctx); // the wrapper already rejects anonymous callers
        return ctx.db
            .query('contacts')
            .withIndex('by_org', (q) => q.eq('organizationId', args.organizationId))
            .collect();
    },
});

Mutations (lesend und schreibend, transaktional)

Mutations sind ACID — alle Änderungen gelingen oder scheitern gemeinsam. authedMutation erzwingt ein authentifiziertes Organisationsmitglied, bevor der Handler läuft.

import { authedMutation } from './lib/authedFunctions';
import { v } from 'convex/values';

export const create = authedMutation({
    args: {
        organizationId: v.string(),
        email: v.string(),
        firstName: v.optional(v.string()),
    },
    handler: async (ctx, args) => {
        const contactId = await ctx.db.insert('contacts', {
            ...args,
            source: 'api',
            createdAt: Date.now(),
            updatedAt: Date.now(),
        });
        return contactId;
    },
});

Actions (externe APIs, Seiteneffekte)

Actions können externe Dienste aufrufen, haben aber keine Transaktionen. authedAction verlangt ein authentifiziertes Organisationsmitglied (vorab durchgesetzt über die interne Query auth.membership.assertOrgMember); Prüfungen einzelner Berechtigungen bzw. Rollen finden weiterhin in den Mutations/Queries statt, die die Action über ctx.runQuery / ctx.runMutation aufruft.

import { authedAction } from './lib/authedFunctions';
import { v } from 'convex/values';

export const sendEmail = authedAction({
    args: {
        to: v.string(),
        subject: v.string(),
        html: v.string(),
    },
    handler: async (ctx, args) => {
        const provider = getEmailProvider();
        return provider.sendEmail(args);
    },
});

HTTP-Actions (REST-Endpunkte)

HTTP-Actions bedienen externe API-Anfragen. Sie laufen in einer separaten, separat authentifizierten Runtime und verwenden deshalb direkt den rohen Builder httpAction (die Secure-by-default-Wrapper greifen hier nicht). Eine HTTP-Route, die sessiongeschützte Daten anfassen muss, ruft ein internal*-Geschwister auf.

import { httpAction } from './_generated/server';

export const handleWebhook = httpAction(async (ctx, request) => {
    const body = await request.json();
    // Process webhook
    return new Response('OK', { status: 200 });
});

// In http.ts
http.route({
    path: '/api/v1/webhook',
    method: 'POST',
    handler: handleWebhook,
});

Sessionbasierte Functions

Owlat ist eine Organisation pro Deployment: Jedes Deployment beherbergt genau eine BetterAuth-Organisation (beim Setup angelegt, und auth/organization/create ist deaktiviert). Da es keine Organisation gibt, die pro Anfrage übergeben werden müsste, lesen die Session-Helfer Identität und Rolle des Benutzers aus der aktiven Session statt aus einem Request-Argument. assertSingletonOrgInvariant in lib/sessionOrganization.ts ist die Laufzeitabsicherung — es bestätigt, dass genau eine Organisation existiert und dass die aktive Organisation der Session dieser entspricht (nach dem ersten Treffer prozessweit gecacht).

import { authedQuery, authedMutation } from './lib/authedFunctions';
import { getUserIdFromSession, getMutationContext } from './lib/sessionOrganization';
import { v } from 'convex/values';

// Query using session context — `authedQuery` rejects anonymous callers
export const listFromSession = authedQuery({
    args: {},
    handler: async (ctx) => {
        await getUserIdFromSession(ctx);
        // Single org per deployment: no organizationId argument to thread through.
        return ctx.db.query('contacts').collect();
    },
});

// Mutation using session context — `authedMutation` enforces an authenticated org member
export const createFromSession = authedMutation({
    args: { email: v.string() },
    handler: async (ctx, args) => {
        const { userId, role } = await getMutationContext(ctx);
        // `userId` / `role` available for auditing and permission checks.

        return ctx.db.insert('contacts', {
            email: args.email,
            source: 'api',
            createdAt: Date.now(),
            updatedAt: Date.now(),
        });
    },
});

getMutationContext(ctx) liefert { userId, role } (Typ MutationSessionContext) — im Rückgabewert gibt es keine organizationId.

Datenbank-Muster

Immer Indizes verwenden

// Good - uses index
const contacts = await ctx.db
    .query('contacts')
    .withIndex('by_org', (q) => q.eq('organizationId', organizationId))
    .collect();

// Bad - full table scan
const contacts = await ctx.db
    .query('contacts')
    .filter((q) => q.eq(q.field('organizationId'), organizationId))
    .collect();

Schemadefinition

import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
    contacts: defineTable({
        organizationId: v.string(),
        email: v.string(),
        firstName: v.optional(v.string()),
        lastName: v.optional(v.string()),
        createdAt: v.number(),
        updatedAt: v.number(),
    })
        .index('by_org', ['organizationId'])
        .index('by_org_and_email', ['organizationId', 'email']),
});

Häufige Feldmuster

// ID references
organizationId: v.string();
contactId: v.id('contacts');

// Timestamps (always milliseconds)
createdAt: v.number();
updatedAt: v.number();

// Status enums
status: v.union(v.literal('draft'), v.literal('published'), v.literal('archived'));

// Optional with default
description: v.optional(v.string());

// JSON stored as string
filters: v.string(); // Parse with JSON.parse()

Berechtigungssystem

Rollen

owner
  • Full access
  • Delete team
  • Transfer ownership
admin
  • Full access
  • Manage members
  • Cannot manage owners
editor
  • Create & edit content
  • No team management

Berechtigungen sind eine typisierte Permission-Union (campaigns:send, campaigns:manage, contacts:manage, topics:manage, segments:manage, templates:manage, automations:manage, organization:manage, settings:manage, organization:delete, chat:participate, …), die in lib/sessionOrganization.ts auf Rollen abgebildet wird. Prüfen Sie eine Fähigkeit mit hasPermission(role, '<scope>:<verb>'), statt Rollen direkt zu testen.

Berechtigungsprüfungen

Nehmen Sie für einen privilegierten Schreibzugriff den Mutation-Kontext und stellen Sie die konkrete Fähigkeit mit requirePermission(hasPermission(role, '<scope>:<verb>'), message) sicher:

import { authedMutation } from './lib/authedFunctions';
import { getMutationContext, requirePermission, hasPermission } from './lib/sessionOrganization';

export const deleteContact = authedMutation({
    args: { contactId: v.id('contacts') },
    handler: async (ctx, args) => {
        const { role } = await getMutationContext(ctx);

        requirePermission(
            hasPermission(role, 'contacts:manage'),
            'Only owners and admins can delete contacts',
        );

        await ctx.db.delete(args.contactId);
    },
});

Für Schreibzugriffe, die immer nur Admins erlaubt sind, ist adminMutation vorzuziehen — es backt die organization:manage-Prüfung in den Wrapper ein, sodass kein requirePermission im Handler nötig ist:

import { adminMutation } from './lib/authedFunctions';

export const purgeContacts = adminMutation({
    args: {},
    handler: async (ctx) => {
        // Floor is "owner/admin"; reaching the body means the check passed.
    },
});

requirePermission(hasPermission, message) nimmt einen Boolean entgegen (das Ergebnis von hasPermission(...)) sowie eine optionale Nachricht — nicht eine Rolle und einen Berechtigungsstring. Die alten Helfer isAdminRole / isOwnerRole wurden entfernt; verwenden Sie hasPermission(role, '<scope>:<verb>').

Fehlerbehandlung

// Throw descriptive errors
if (!contact) {
    throw new Error('Contact not found');
}

if (!domain.verified) {
    throw new Error(`Cannot send email: domain ${domain.name} is not verified`);
}

// Validation errors
if (!args.email.includes('@')) {
    throw new Error('Invalid email address');
}

Scheduler (Hintergrundjobs)

Interne Ziele werden über das generierte internal-Objekt adressiert, das das Layout der Domänenordner widerspiegelt (internal.<domain>.<file>.<func>).

import { internal } from './_generated/api';

// Schedule the next automation step (e.g. after a delay step).
// Real target: apps/api/convex/automations/stepWalker.ts → executeStep
await ctx.scheduler.runAfter(delayMs, internal.automations.stepWalker.executeStep, {
    automationRunId,
    stepRunId,
});

// Kick off background work immediately (fire-and-forget).
// Real target: apps/api/convex/segments.ts → refreshSingleSegmentCount
await ctx.scheduler.runAfter(0, internal.segments.refreshSingleSegmentCount, {
    segmentId,
});

Registrieren Sie wiederkehrende Jobs in crons.ts, statt sie sich selbst neu planen zu lassen.

Dateispeicher

import { authedQuery, authedMutation } from './lib/authedFunctions';
import { v } from 'convex/values';

// Generate upload URL (for client upload)
export const generateUploadUrl = authedMutation({
    args: {},
    handler: async (ctx) => {
        return ctx.storage.generateUploadUrl();
    },
});

// Get file URL
export const getUrl = authedQuery({
    args: { storageId: v.id('_storage') },
    handler: async (ctx, args) => {
        return ctx.storage.getUrl(args.storageId);
    },
});

// Delete file
export const deleteFile = authedMutation({
    args: { storageId: v.id('_storage') },
    handler: async (ctx, args) => {
        await ctx.storage.delete(args.storageId);
    },
});

Wichtige API-Dateien

contacts/CRM contacts & identities
topics/Topics & DOI flows
campaigns/Campaigns & scheduling
emailTemplates/Template CRUD
emailBlocks/Saved blocks
segments.tsSegment filtering
automations/Trigger-based workflows
transactional/Transactional send API
domains/Domains, DNS & warming
delivery/Send pipeline & lifecycle
webhooks/Outbound webhooks & logs
inbox/Shared inbox & threading
mail/SMTP/IMAP mailboxes & drafts
emails.tsRendering & sending

Frontend-Integration

In apps/web laufen reaktive Lesezugriffe über das Composable useConvexQuery und Schreibzugriffe über useBackendOperation (beide werden automatisch importiert). Die Function-Referenzen stammen aus dem generierten api-Objekt und folgen dem Pfad der Domänenordner.

// In a Vue component or composable
import { api } from '@owlat/api';

// Query (reactive) — returns { data, error, isLoading } refs
const { data: contacts } = useConvexQuery(api.contacts.contacts.list, {});

// Mutation — useBackendOperation normalizes errors and returns a `run()`
const { run: createContact } = useBackendOperation(api.contacts.contacts.create, {
    label: 'Create contact',
});
await createContact({ email: 'user@example.com' });