The core of the EdgeKits SaaS Kit for Cloudflare Workers: multi-tenant data access, permissions and entitlements, billing events and provider ports, the admin contract, a typed blueprint and env, and the Hono and Astro middleware that wires them into a Worker. It is TypeScript, runs on workerd, stores its data in D1 through Drizzle, and has no Cloudflare runtime dependency beyond types.
The kernel is a library of contracts and guarantees. An app imports the pieces it needs and supplies the rest:
- It is the tenant boundary (
TenantContext, tenant tables, tenant-bound repositories), the policy engine, the plan catalog, the event vocabulary, the provider ports, the admin RPC contract and the request middleware. - It is not a framework or an app. It registers no routes, ships no UI, and implements no authentication. Sessions, membership lookup, the billing adapter, the mail sender and the
AdminRpcimplementation are the app's: the kernel defines their shape and checks what it receives. - It is not a hosted service. Nothing calls home, and the only network request the kernel makes is the Cloudflare Access JWKS fetch in
accessIdentity().
The EdgeKits SaaS Starter is the paid composition built on this kernel: three Workers (site, app, admin) with Better Auth, Paddle billing, Workflows, the admin, the deploy runbook and the module registry. See edgekits.dev.
npm install @edgekits/kernel drizzle-orm honodrizzle-orm(^0.45.4) andhono(^4.13.13) are peer dependencies, so the app and the kernel share one copy of each.eslint(^9or^10) is an optional peer, needed only for@edgekits/kernel/eslint.zodis the kernel's only dependency.- Node 22 or later runs the tooling. The runtime subpaths target Cloudflare Workers; use
@cloudflare/workers-typesfor the D1 types.
The package root exports nothing. Import from a subpath:
| Subpath | What it holds |
|---|---|
@edgekits/kernel/tenancy |
TenantContext, resolveTenant(), tenantTable(), tenantReference(), tenant-bound repositories, the dev-time query guard, org claims |
@edgekits/kernel/policy |
the policy engine (can(), entitled(), entitlementFlags()), the entitlement resolver, subscriptions, overrides, suspension, kernel trials, the plan catalog |
@edgekits/kernel/events |
the KernelEvent vocabulary, InboundEvent, the EventBus port, the subscription reducer, realtimeScopes() and composeEventBus() |
@edgekits/kernel/ports |
the BillingProvider, MailProvider and RealtimeNotifier ports, billing capability manifests, price checks |
@edgekits/kernel/admin |
the AdminRpc contract and accessIdentity(), the Cloudflare Access middleware |
@edgekits/kernel/config |
defineSaaS(), the typed blueprint, provider validation, the typed env |
@edgekits/kernel/hono |
tenant(), requirePlan(), errors(), noStore(), sameOrigin(), securityHeaders() |
@edgekits/kernel/astro |
KernelLocals and toLocals() for Astro locals |
@edgekits/kernel/eslint |
tenantTables(), the flat-config fragment with the tenant-table rules |
import { tenantTable } from '@edgekits/kernel/tenancy'
import { text } from 'drizzle-orm/sqlite-core'
// Adds org_id and its index; the primary key is (org_id, id).
export const projects = tenantTable('projects', {
id: text(),
name: text().notNull(),
})
// In a repository: every query is bound to ctx.orgId.
const rows = await ctx.db.repo(projects).findMany()import { createPolicyEngine, planSource } from '@edgekits/kernel/policy'
const policy = createPolicyEngine({
entitlements: blueprint.entitlements,
roles: blueprint.roles,
plans: planSource(db),
counters: { projects: (ctx) => countProjects(ctx) },
})
policy.requirePermission(ctx, 'project.write') // 403 through errors()
await policy.requireEntitlement(ctx, 'projects') // 402 at the limitimport {
applySubscriptionEvents,
composeEventBus,
type BillingEvent,
} from '@edgekits/kernel/events'
// Pure: the same events give the same state in any order, duplicates once.
const state = applySubscriptionEvents(events as BillingEvent[])
state.status // 'active' | 'trialing' | 'canceled' | ...
// The audit bus first, then the open pages: one notify() per event,
// carrying only { scopes, at } (scopes from realtimeScopes(), 'activity'
// first). An event that belongs to no org (the catalog, settings and user
// blocks among them) is announced to notify(systemOrgId, ...), which the
// notifier must deliver only to a channel operators alone can read.
// A notify() that fails, or has not settled after notifyTimeoutMs (default
// 2000), and a scope lookup that throws are logged, never thrown: the
// failure's reason is 'error' (with the error) or 'timeout'.
const bus = composeEventBus(auditBus, notifier, {
log: console.error,
notifyTimeoutMs: 2000,
})import {
assertPriceSupported,
paddleCapabilities,
} from '@edgekits/kernel/ports'
// Throws CapabilityError (400 through errors()) before any provider call.
assertPriceSupported('paddle', paddleCapabilities, {
amount: 1900,
currency: 'USD',
period: 'month',
model: 'flat',
})import { accessIdentity } from '@edgekits/kernel/admin'
admin.use(
accessIdentity({
teamDomain: env.ACCESS_TEAM_DOMAIN, // https://<team>.cloudflareaccess.com
aud: env.ACCESS_AUD, // required outside mode 'dev'
mode: 'production',
build: import.meta.env.DEV ? 'dev' : 'production',
}),
)
// c.var.admin is { admin: { id, email }, request }import { auth, billing, defineSaaS, tenancy } from '@edgekits/kernel/config'
export default defineSaaS({
name: 'acme',
locales: ['en'],
tenancy: tenancy.orgs({
personalWorkspace: true,
isolation: 'shared-d1',
residency: 'none',
}),
auth: auth.betterAuth({ methods: ['magic-link'], invitations: true }),
entitlements: { projects: { kind: 'limit' }, ai: { kind: 'switch' } },
billing: billing.paddle({ model: 'flat', currency: 'USD' }),
})import {
composeEnv,
defineEnv,
parseEnv,
string,
url,
} from '@edgekits/kernel/config'
const schema = composeEnv([
defineEnv('auth', {
AUTH_SECRET: string({ min: 32 }),
APP_URL: url({ https: true }),
}),
])
const env = parseEnv(schema, workerEnv) // typed and frozen; errors name keys, never valuesimport {
errors,
noStore,
requirePlan,
sameOrigin,
securityHeaders,
tenant,
} from '@edgekits/kernel/hono'
app.use(securityHeaders(), sameOrigin({ allowed: ['https://app.example.com'] }))
app.use(
'/app/*',
tenant({ session, orgFromRequest: () => null, deps }),
noStore(),
)
app.use(
'/app/*',
requirePlan({ policy, allow: ['/app/billing'], billingPath: '/app/billing' }),
)
app.onError(errors())import { toLocals, type KernelLocals } from '@edgekits/kernel/astro'
declare global {
namespace App {
interface Locals extends KernelLocals {}
}
}
// In the Worker, before the Astro handler: Object.assign(locals, toLocals(c))// eslint.config.js
import tseslint from 'typescript-eslint'
import { tenantTables } from '@edgekits/kernel/eslint'
export default tseslint.config(
...tseslint.configs.recommended,
...tenantTables({ repositories: ['apps/*/src/modules/*/repository.ts'] }),
)Tenant data is reached only through a TenantContext, and the guarantee holds in three layers:
- Tenant-bound repositories.
tenantTable()adds theorg_idcolumn and its index, keys a table with anidby(org_id, id), and registers it as a tenant table.ctx.db.repo(table)accepts only registered tables. It bindsorg_id = ctx.orgIdto every read, update and delete, groups the caller's own predicate inside that binding, and setsorg_idon every insert. No method takes an org id, and anorgIdin the values is dropped.resolveTenant()builds the context only after it has checked that the org exists and the user is a member, on every request. The org id from a session or an API key is never trusted on its own.tenantReference()makes references between tenant tables composite foreign keys that carryorg_id.
- The lint rule.
@edgekits/kernel/eslintreports any import of a tenant table outside the repositories and the allowed files, including the kernel's own tenant tables by name. - The dev-time query guard.
tenantGuardLogger({ mode: 'strict' }), set on the Drizzle client in development and tests, throws on a select, update or delete of a tenant table without anorg_idpredicate. It catches raw SQL the lint cannot see.
BillingProvider is the contract a billing adapter implements:
- Webhooks:
verifyover the raw body and secret,parse,normalizeintoBillingEvents, andfetchSubscriptionfor the re-fetch before entitlements are written. - Checkout, portal and plan changes: checkout and the portal URL,
changePlan()with everyProrationMode(immediate,none,period-end),cancel(),keep()and refunds. - The catalog: products and prices.
A provider's capabilities are data, a BillingCapabilities manifest such as paddleCapabilities: currencies, billing models, proration modes, trials and refunds. The manifest is used in two places:
validateAgainstProviders(blueprint, capabilities)in/configchecks the blueprint's billing choices against it.assertPriceSupported()checks each price before a provider call.
The kernel's guidance for webhooks:
- verify the raw body byte-exact;
- treat events as unordered and duplicated;
- re-fetch the subscription before writing entitlements (
applyProviderSubscription()refuses a stale or foreign-provider overwrite); - acknowledge fast and apply in a durable step.
AdminRpc is the interface an app's Worker entrypoint implements for an operator console. It covers:
- orgs, members, users and activity;
- entitlement overrides, suspension, trials and refunds;
- the plan catalog and settings;
- impersonation, org claims, invitations, user blocks and the billing contact.
Every method takes an AdminContext, the verified operator. accessIdentity() produces it from the Cloudflare Access JWT in Cf-Access-Jwt-Assertion:
- RS256 against the team JWKS;
iss,audandexpchecked;mode: 'dev'refused in a production build.
Every write emits its admin.* event (AdminEvent in /events).
KernelEvent is one discriminated union:
BillingEvent: subscriptions, invoices, refunds, disputes, credits, licenses and rejected notifications.OrgEvent: org creation, members and invitations, the billing contact.LifecycleEvent: trials.AdminEvent: every operator action.
Every event names its org, its time and a sourceId, the idempotency key of the fact itself. Events that belong to no org use systemOrgId. An EventBus publishes them, and noopEventBus is the default.
The package follows semantic versioning with the 0.x rule. Until 1.0:
- a minor release (0.2.0) may contain breaking changes, and each is listed in CHANGELOG.md with its migration;
- a patch release never breaks.
Pin a minor range (~0.1.0) or an exact version.
npm run ci runs the whole gate, in order:
npm ci- Prettier
- ESLint (with the subpath boundary rule)
tsc(including a check of the built declarations from a consumer's side, and a plain-Node import of every subpath)- the Vitest suites (
workerdwith a local D1, and Node)
Releases are published by .github/workflows/publish.yml through npm trusted publishing (OIDC): the workflow holds no npm token, and every release carries an npm provenance statement that links the tarball to its commit and workflow run.
- Bump
versioninpackage.json(and the lockfile's root entry) and add the release toCHANGELOG.md; commit. - Tag the commit
v<version>(for examplev0.1.1) and push the tag. - The workflow checks that the tag names
package.json's version, runsnpm ci && npm run ci, and publishes.
0.1.0 was published by hand and carries no provenance; provenance starts with 0.1.1.
MIT