Kynetra FX

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.

src/handlers/issues.ts
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

NameTypeDescription
actionstringA dot-namespaced event name, e.g. "issue.created", "member.invited", "billing.plan_changed".
actorIdstringThe principal ID of the entity performing the action. Use principal.id.
target{ type: string; id: string }Optional. The resource being acted upon.
tenantIdstringOptional. 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.

src/handlers/members.ts
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.

src/routes/audit.ts
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

The in-memory store does not persist across Worker restarts. For production audit trails, back the kernel with D1 or another persistent 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.
src/handlers/dashboard.ts
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.

src/admin/flags.ts
// Enable globally
await kernel.featureFlags.create({
id: crypto.randomUUID(),
key: 'dashboard-v2',
enabled: true,
createdAt: new Date().toISOString(),
})
 
// Enable for one tenant only
await 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.

src/routes/admin/flags.ts
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:

src/handlers/export.ts
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

Feature flags and audit logs both use the same StorePort backend as the rest of the kernel. Switching from in-memory to D1 via createSaasKernel({ store: d1Store(env.DB) }) makes both persistent automatically.