Documentation
Audit Logs & Feature Flags
The SaaS kernel ships two operational repositories out of the box: auditLogs for an immutable record of who did what, and featureFlags for per-tenant feature gating. Both are thin wrappers over the standard Repository<T> interface with domain-specific helpers on top.
Audit logs#
Audit logs answer the question "who did what, when, on which resource?" They are append-only by convention: you create records, never update or delete them.
auditLogs.record
The record helper provides a typed shorthand over kernel.auditLogs.create. It stamps the event with the current time from the kernel clock and generates an ID.
import { getPrincipal } from '@kynetra/fx-auth'import { getTenantId } from '@kynetra/fx-saas'import { kernel } from './kernel' app.delete('/issues/:id', async (ctx) => { const principal = getPrincipal(ctx) const tenantId = getTenantId(ctx) await kernel.issues.delete(ctx.params.id) await kernel.auditLogs.record({ action: 'issue.deleted', actorId: principal.id, target: { type: 'issue', id: ctx.params.id }, tenantId, }) return ctx.json({ deleted: true })})auditLogs.record options
| Name | Type | Description |
|---|---|---|
| action | string | A dot-namespaced event name, e.g. "issue.created", "member.invited", "billing.plan_changed". |
| actorId | string | The principal ID of the entity performing the action. Use principal.id. |
| target | { type: string; id: string } | Optional. The resource being acted upon. |
| tenantId | string | Optional. Associates the event with a tenant for scoped queries. |
The audit() shorthand
audit(kernel, ctx, { action, target? }) from @kynetra/fx-saas is a convenience wrapper that reads getPrincipal and getTenantId from the context automatically — no need to pass them separately.
import { audit } from '@kynetra/fx-saas'import { kernel } from './kernel' app.post('/members/invite', async (ctx) => { const body = await ctx.jsonBody<{ email: string; role: string }>() // ... invite logic ... // ctx already holds principal + tenantId from middleware await audit(kernel, ctx, { action: 'member.invited', target: { type: 'invitation', id: invite.id }, }) return ctx.json({ invited: true })})Querying audit logs
Use the standard list(filter?) method to query events. All standard filter fields apply — scope to a tenant, actor, or target type.
app.get('/audit', async (ctx) => { const tenantId = getTenantId(ctx) // All events for this tenant, newest first const events = await kernel.auditLogs.list({ tenantId }) // Filter by actor const myEvents = await kernel.auditLogs.list({ tenantId, actorId: principal.id, }) return ctx.json({ events })})Note
StorePort adapter.Feature flags#
Feature flags let you control which tenants (or all tenants globally) can access a feature, without deploying new code. The featureFlags repository stores flag records; isEnabled resolves the effective value.
featureFlags.isEnabled
isEnabled(key, tenantId?) checks whether a named flag is enabled. Resolution order:
- If a tenant-specific record exists for
(key, tenantId), that value wins. - Otherwise, fall back to the global flag record (no
tenantId). - If no record exists, returns
false.
import { getTenantId } from '@kynetra/fx-saas'import { kernel } from './kernel' app.get('/dashboard', async (ctx) => { const tenantId = getTenantId(ctx) const useV2 = await kernel.featureFlags.isEnabled('dashboard-v2', tenantId) return ctx.json({ component: useV2 ? 'DashboardV2' : 'DashboardV1', })})Managing flag records
Flags are plain repository records. Create them with kernel.featureFlags.create — either globally (no tenantId) or per-tenant.
// Enable globallyawait kernel.featureFlags.create({ id: crypto.randomUUID(), key: 'dashboard-v2', enabled: true, createdAt: new Date().toISOString(),}) // Enable for one tenant onlyawait kernel.featureFlags.create({ id: crypto.randomUUID(), key: 'dashboard-v2', enabled: true, tenantId: 'org_01HTXYZ', createdAt: new Date().toISOString(),}) // Disable for a specific tenant (overrides global)await kernel.featureFlags.create({ id: crypto.randomUUID(), key: 'dashboard-v2', enabled: false, tenantId: 'org_01HTABC', createdAt: new Date().toISOString(),})Admin route for toggling flags
Expose a protected admin route to toggle flags at runtime — no redeployments required.
import { requireAuth, jwtAuth } from '@kynetra/fx-auth'import { defineRoles, createRbac } from '@kynetra/fx-rbac'import { kernel } from '../../kernel' const roles = defineRoles({ admin: ['*'] })const rbac = createRbac(roles) app.group('/admin/flags', (g) => { g.use(requireAuth([jwtAuth({ secret: env.JWT_SECRET })])) g.use(rbac.requireRole('admin')) g.post('/', async (ctx) => { const { key, enabled, tenantId } = await ctx.jsonBody<{ key: string enabled: boolean tenantId?: string }>() const flag = await kernel.featureFlags.create({ id: crypto.randomUUID(), key, enabled, tenantId, createdAt: new Date().toISOString(), }) return ctx.json({ flag }, { status: 201 }) }) g.get('/', async (ctx) => { const flags = await kernel.featureFlags.list() return ctx.json({ flags }) })})Combining audit + feature flags in a handler#
A realistic handler that checks a flag before executing a feature, then records an audit event on success:
import { audit } from '@kynetra/fx-saas'import { getTenantId } from '@kynetra/fx-saas'import { kernel } from './kernel'import { FXError } from '@kynetra/fx' app.post('/export', async (ctx) => { const tenantId = getTenantId(ctx) const flagEnabled = await kernel.featureFlags.isEnabled('csv-export', tenantId) if (!flagEnabled) { throw new FXError(403, 'FX_FORBIDDEN', 'CSV export is not enabled for your plan') } const data = await generateExport(tenantId) await audit(kernel, ctx, { action: 'export.generated', target: { type: 'export', id: data.id }, }) return ctx.text(data.csv, { status: 200, headers: { 'content-type': 'text/csv' } })})Tip
StorePort backend as the rest of the kernel. Switching from in-memory to D1 via createSaasKernel({ store: d1Store(env.DB) }) makes both persistent automatically.