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 definitionsdataModel.d.tsSchema typeslib/Shared utilitiesauthedFunctions.tsSecure-by-default builderssessionOrganization.tsSession, roles & permissionssendProviders/Provider-agnostic send pipelineses/SES sendingresend/Resend providermta/Self-hosted MTArouting.tsProvider routing & dispatchemailProviders/Domain identity & verificationsesIdentity.tsSES domain identitymtaIdentity.tsMTA domain identitydomainVerification.tsSPF/DKIM/DMARC checksschema/Per-domain table modulesschema.tsMerges domain tablesauth/BetterAuth config, API keys & onboardinghttp.tsHTTP route handlerscrons.tsScheduled jobs<domain>/API functions by domainSecure-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.
| Builder | Auth-Mindestniveau | Verwenden für |
|---|---|---|
authedQuery | Authentifiziertes Organisationsmitglied (aktive Organisation + Rolle) | Reaktive Lesezugriffe für angemeldete Benutzer |
authedMutation | Authentifiziertes Organisationsmitglied (aktive Organisation + Rolle) | Der Standard für Schreibzugriffe; für privilegierte zusätzlich ein requirePermission(...) einziehen |
authedAction | Authentifiziertes Organisationsmitglied (durchgesetzt über die interne Query auth.membership.assertOrgMember) | Actions, die externe Dienste aufrufen |
adminMutation | Owner-/Admin-Mitglied (organization:manage) | Schreibzugriffe nur für Admins — die Rollenprüfung ist eingebaut |
ownerMutation | Owner-Rolle (organization:delete) | Destruktive Schreibzugriffe auf Organisationsebene, die selbst Admins nicht dürfen |
adminQuery | Owner-/Admin-Mitglied (organization:manage) | Sensible Lesezugriffe (API-Keys, Webhook-Secrets, geteiltes Postfach) |
authedIdentityMutation | Nur authentifizierte Identität (keine Organisationsmitgliedschaft) | Ausschließlich der schmale Registrierungspfad vor Bestehen einer Organisation |
publicQuery / publicMutation / publicAction | Keines — explizites Opt-out | Token-geschützte Links, signaturgeprüfte Webhooks, Tracking-Pixel, die Setup-Seite vor der Authentifizierung |
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 & identitiestopics/Topics & DOI flowscampaigns/Campaigns & schedulingemailTemplates/Template CRUDemailBlocks/Saved blockssegments.tsSegment filteringautomations/Trigger-based workflowstransactional/Transactional send APIdomains/Domains, DNS & warmingdelivery/Send pipeline & lifecyclewebhooks/Outbound webhooks & logsinbox/Shared inbox & threadingmail/SMTP/IMAP mailboxes & draftsemails.tsRendering & sendingFrontend-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' });