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.
import { createSaasKernel } from '@kynetra/fx-saas'import { inMemoryStore, randomId, systemClock } from '@kynetra/fx-ports' // Development / tests: in-memory defaultsexport const kernel = createSaasKernel() // Production override: swap the store// export const kernel = createSaasKernel({ store: d1Store(env.DB) })createSaasKernel options
| Name | Type | Description |
|---|---|---|
| store | StorePort | Document store backend. Defaults to inMemoryStore(). Swap to d1Store(db) or any StorePort adapter. |
| id | IdPort | ID generator. Defaults to randomId() which produces URL-safe random strings. |
| clock | ClockPort | Clock 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.
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
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)
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 testsconst user = await kernel.users.create({ id: 'usr-1', email: 'test@example.com', name: 'Test', createdAt: new Date().toISOString(),})Tip
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.
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 + tenantapp.use(requireAuth([jwtAuth({ secret: env.JWT_SECRET })]))app.use(tenant({ required: true })) // Routesapp.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)