Kynetra FX

Documentation

SaaS Kernel

@kynetra/fx-saas provides a createSaasKernel factory that wires together a complete set of repositories for common SaaS entities. The kernel runs against any StorePort backend — start with in-memory, swap to D1 or Postgres in production.

Creating the kernel#

Call createSaasKernel(options?) once and share the instance across your app. It accepts three optional dependencies from @kynetra/fx-ports.

src/kernel.ts
import { createSaasKernel } from '@kynetra/fx-saas'
import { inMemoryStore, randomId, systemClock } from '@kynetra/fx-ports'
 
// Development / tests: in-memory defaults
export const kernel = createSaasKernel()
 
// Production override: swap the store
// export const kernel = createSaasKernel({ store: d1Store(env.DB) })

createSaasKernel options

NameTypeDescription
storeStorePortDocument store backend. Defaults to inMemoryStore(). Swap to d1Store(db) or any StorePort adapter.
idIdPortID generator. Defaults to randomId() which produces URL-safe random strings.
clockClockPortClock source for createdAt timestamps. Defaults to systemClock().

Repositories#

The kernel exposes 12 repositories. Every repository implements Repository<T> — a uniform CRUD interface described below. Some have additional domain-specific helpers.

  • kernel.users — user accounts.
  • kernel.organizations — tenant organisations (the top-level tenant entity).
  • kernel.workspaces — sub-spaces within an organisation.
  • kernel.memberships — links users to organisations with a role.
  • kernel.roles — named role definitions scoped to an organisation.
  • kernel.permissions — individual permission grants.
  • kernel.sessions — server-tracked sessions (for revocation).
  • kernel.apiKeys — API key records (store the hash, not the plaintext).
  • kernel.auditLogs — immutable event log; see Audit Logs.
  • kernel.featureFlags — per-tenant feature flag state; see Feature Flags.
  • kernel.notifications — notification records for users.
  • kernel.files — file metadata records (not file content).

The Repository<T> interface#

All repositories share the same five-method CRUD surface. Entities always extend { id: string, createdAt: string }; tenant-scoped entities also carry tenantId: string.

types
interface Repository<T> {
create(input: Omit<T, never>): Promise<T>
get(id: string): Promise<T | null>
list(filter?: Partial<T>): Promise<T[]>
update(id: string, patch: Partial<T>): Promise<T>
delete(id: string): Promise<void>
}

create

Inserts a new record. You supply the full input object (including id).

const user = await kernel.users.create({
id: crypto.randomUUID(),
email: 'alice@example.com',
name: 'Alice',
createdAt: new Date().toISOString(),
})

get

Fetches a single record by ID. Returns null if not found.

const user = await kernel.users.get('usr_01HTXYZ')
if (!user) return ctx.json({ error: 'not found' }, { status: 404 })

list

Returns all records matching the optional filter. Filter fields are AND-ed. Pass tenantId to scope to a tenant.

const members = await kernel.memberships.list({ organizationId: 'org_01HTXYZ' })
const adminMembers = await kernel.memberships.list({ organizationId: 'org_01HTXYZ', role: 'admin' })

update

Merges the patch into the record and returns the updated entity.

const updated = await kernel.users.update('usr_01HTXYZ', { name: 'Alice Smith' })

delete

Removes the record. Resolves when done; does not throw if the record was not found.

await kernel.users.delete('usr_01HTXYZ')

Domain helpers#

Beyond the base CRUD interface, some repositories expose higher-level operations that enforce consistency across multiple entities.

memberships.addMember

Creates a membership linking a user to an organisation with a role. This is the canonical way to add users to a tenant.

await kernel.memberships.addMember(
'org_01HTXYZ', // organizationId
'usr_01HTABC', // userId
'member' // role string
)

featureFlags.isEnabled

Checks whether a named flag is enabled. Optionally scoped to a tenant — if a tenant-specific flag exists it takes precedence over the global default.

const enabled = await kernel.featureFlags.isEnabled('new-dashboard', tenantId)
 
if (enabled) {
return ctx.json({ dashboard: 'v2' })
}

auditLogs.record

Appends an immutable audit event. See Audit Logs for the full API and patterns.

await kernel.auditLogs.record({
action: 'issue.deleted',
actorId: principal.id,
target: { type: 'issue', id: issueId },
tenantId,
})

Swapping the store backend#

The kernel is backed by a StorePort. In development and tests, the default in-memory store is zero-setup. In production, pass a real adapter.

Cloudflare D1

src/worker.ts
import { cloudflare } from '@kynetra/fx-cloudflare'
import { d1Store, D1_STORE_MIGRATION } from '@kynetra/fx-cloudflare'
import { createSaasKernel } from '@kynetra/fx-saas'
import { app } from './app'
 
// Run D1_STORE_MIGRATION once to create the documents table
// wrangler d1 execute DB --command="<paste D1_STORE_MIGRATION here>"
 
return cloudflare(app, {
async fetch(request, env, ctx) {
const store = d1Store(env.DB)
const kernel = createSaasKernel({ store })
app.decorate('kernel', kernel)
return app.fetch(request)
}
})

In-memory (tests)

src/kernel.test.ts
import { createSaasKernel } from '@kynetra/fx-saas'
import { inMemoryStore, sequentialId, fixedClock } from '@kynetra/fx-ports'
 
const kernel = createSaasKernel({
store: inMemoryStore(),
id: sequentialId('usr'),
clock: fixedClock(new Date('2025-01-01')),
})
 
// Deterministic IDs and timestamps in tests
const user = await kernel.users.create({
id: 'usr-1',
email: 'test@example.com',
name: 'Test',
createdAt: new Date().toISOString(),
})

Tip

The StorePort interface is documented in Ports. Implement it to connect any database — Postgres, SQLite, Turso, or your own adapter.

Full SaaS bootstrap example#

The following shows a complete worker entry point combining authentication, tenancy, RBAC, and the kernel — a production-ready starting point for a multi-tenant SaaS API.

src/worker.ts
import { createFX } from '@kynetra/fx'
import { cloudflare, d1Store } from '@kynetra/fx-cloudflare'
import { requireAuth, jwtAuth, getPrincipal } from '@kynetra/fx-auth'
import { tenant, getTenantId, createSaasKernel, audit } from '@kynetra/fx-saas'
import { defineRoles, createRbac } from '@kynetra/fx-rbac'
 
const roles = defineRoles({
admin: ['*'],
member: ['issues:read', 'issues:create'],
viewer: ['issues:read'],
})
 
const rbac = createRbac(roles)
const app = createFX({ runtime: 'cloudflare' })
 
// Auth + tenant
app.use(requireAuth([jwtAuth({ secret: env.JWT_SECRET })]))
app.use(tenant({ required: true }))
 
// Routes
app.get('/issues', rbac.requirePermission('issues:read'), async (ctx) => {
const tenantId = getTenantId(ctx)
const kernel = ctx.get<ReturnType<typeof createSaasKernel>>('kernel')
const issues = await kernel.workspaces.list({ tenantId })
return ctx.json({ issues })
})
 
app.post('/issues', rbac.requirePermission('issues:create'), async (ctx) => {
const principal = getPrincipal(ctx)
const tenantId = getTenantId(ctx)
const kernel = ctx.get<ReturnType<typeof createSaasKernel>>('kernel')
const body = await ctx.jsonBody<{ title: string }>()
 
const issue = await kernel.workspaces.create({
id: crypto.randomUUID(),
tenantId,
name: body.title,
createdAt: new Date().toISOString(),
})
 
await audit(kernel, ctx, { action: 'issue.created', target: { type: 'issue', id: issue.id } })
return ctx.json({ issue }, { status: 201 })
})
 
return cloudflare(app)