All notable changes to Keel are documented here. The format follows Keep a Changelog, and the project aims to adhere to Semantic Versioning.
[0.86.0] — 2026-07-21
Full-text search — the largest remaining gap against Laravel, and the last core capability with no seam at all.
Added
Search, over a pluggable driver. Same shape as the cache, queue, and storage layers: the core imports no engine, so it runs unchanged on Node and the edge.
class Post extends Model { static table = "posts"; static searchable = ["title", "body"]; } registerSearchable(Post); // in a service provider const posts = await search(Post, "edge runtime").get();registerSearchablewires the model'ssavedanddeletedevents to the index, so ordinary writes stay in sync without anyone remembering to reindex.Results are ordinary models.
get()resolves the driver's ids back through the model's own query builder, so casts, global scopes, relations, and soft deletes all still apply — then re-sorts the rows into relevance order, becauseWHERE id IN (…)has no obligation to preserve it. A hit whose row has since been deleted is skipped rather than returned as a hole, so an index that has drifted degrades quietly instead of handing youundefined.Two drivers.
MemorySearchDriveris the default — no database, no migration, and scoring simple enough to assert ordering against, which is what you want in tests.DatabaseSearchDriverstores documents in onesearch_indextable (searchMigration()) that serves every model, and searches it with whatever full-text machinery the dialect actually has: SQLite FTS5, a generated Postgrestsvectorwith a GIN index, MySQLFULLTEXT, and aLIKEfallback so a dialect with none of that still works.All drivers agree on the semantics, so swapping one doesn't change results: terms are AND-ed, terms match on prefix, and an empty query matches nothing rather than everything.
User input is never query syntax. A search box feeding a query language is the shape of an injection bug, so the SQLite driver quotes each term for FTS5 (
OR,NEAR,*, a stray"are words to search for, not operators) and the Postgres driver builds itstsqueryfrom parsed terms rather than handing the raw string toto_tsquery, which would raise on malformed input. You can pass a raw search box straight through.search:index <Model>(rebuild from the table,--chunkto size the batches) andsearch:flush <Model>.reindex()is the same thing in code and returns the document count. Both flush first, so they are a rebuild rather than a top-up and rows deleted behind the index's back don't linger.
[0.85.1] — 2026-07-21
Security release. Upgrade if you use sessionMiddleware().
Fixed
The session cookie was forgeable, which made it an authentication bypass. The whole session was serialized into the cookie as base64 JSON with no signature, and
auth().login(id)stores the logged-in user's id there. Base64 is an encoding, not a secret — so any visitor could decode their cookie, changeauth_idto any user, re-encode it, and be that user. No key, no brute force, nothing to break:cookie decodes to: {"auth_id":"7"} attacker sends : {"auth_id":"1"} -> server: auth id = 1Cookies are now
payload.signature, the signature being HMAC-SHA256 of the payload underconfig('app.key'), compared in constant time. A cookie that does not verify is discarded whole and the request starts with an empty session.Apps authenticating only with bearer tokens (
bearerAuth()) were not affected — those never consult the session.Two upgrade notes.
APP_KEYis now required for sessions: with no key there is nothing to sign with, so the middleware raises rather than silently writing an unsigned cookie. Every starter kit already ships one in.env.example. And because existing cookies are unsigned, everyone is logged out once on upgrade. Honouring old cookies would have meant honouring forgeable ones.The session cookie is now
Secureover HTTPS. It never set the flag, so it crossed the network in the clear wherever the connection was plain HTTP. It is set when the request arrived over HTTPS — directly, or viaX-Forwarded-Protofrom a proxy terminating TLS — and left off otherwise, sohttp://localhostdevelopment keeps working. An explicitcookie: { secure }still wins.hasValidSignature()compared signatures with===, which returns at the first differing byte. That timing difference is enough to reconstruct a signature a byte at a time. Now constant-time, like the storage URL signer already was.SchemaBuilder.raw()did not rewrite?placeholders to$n, so a migration with bindings worked on SQLite and failed only on Postgres. It now converts them like everything else in Keel.
Changed
- The three separate HMAC-SHA256 implementations (sessions would have been a
fourth) are now one exported
hmacHex, alongsidetimingSafeEqual, incrypto.ts. Signed URLs and signed cookies share the primitive rather than each carrying a copy.
[0.85.0] — 2026-07-21
Last release the drivers were queue, cache, and rate limiting. This one finishes
the set: storage gets shipped disks, and the migration commands stop having a
hole in the middle where db:seed should be.
Added
Three shipped storage disks.
storage()has always been a pluggable seam with exactly one implementation behind it —MemoryDisk— and a docs page full of recipes to copy. Those recipes are now adapters:localDisk({ root })from@shaferllc/keel/storage/local, for Node. Storesvisibilityin the file mode (0644 / 0600) and reads it back, walks directories forlist(), and refuses any path that resolves outside its root — a hostile upload filename can't walk up into the rest of the machine.s3Disk({ bucket, ... })from@shaferllc/keel/storage/s3, for S3, R2, MinIO, Spaces, and B2. It signs its own SigV4 requests overfetchand Web Crypto, so it imports no SDK and runs unchanged on Node and the edge — and, because it signs, it's the disk that makessignedUrl()andsignedUploadUrl()real presigned URLs rather than the app-key fallback. A browserPUTs straight to the bucket; the bytes never transit your app. Its signatures are verified against AWS's published SigV4 test vector.r2Disk(env.BUCKET)from@shaferllc/keel/storage/r2, for a Cloudflare R2 binding — duck-typed, so no Cloudflare types are imported. A binding can't presign, sosignedUrl()falls back to app-key signing andsignedUploadUrl()says so plainly instead of handing back a URL that wouldn't work.
Both bucket disks follow their pagination cursor, so
list()doesn't quietly truncate at 1000 objects.db:seed.make:seederhas generated seeders since v0.36, and nothing in the console could run one —seed()was reachable only from your own code. Now:keel db:seed, orkeel db:seed -c Userto runUserSeeder, resolved by class name from whichever module indatabase/seeders/exports it.migrate:reset,migrate:refresh,migrate:fresh, plus--seedonmigrate,migrate:refresh, andmigrate:fresh.refreshunwinds through yourdown()methods and migrates up again;freshignores them entirely and drops every table first, which is the way back to empty when adown()is wrong, missing, or names a table a half-applied migration never created. All three refuse to run underNODE_ENV=productionwithout--force.Migrator.reset(migrations)(roll back every batch) andMigrator.dropAllTables()(drop every table in the schema, bookkeeping included — oneDROP TABLE … CASCADEon Postgres, foreign keys suspended on SQLite), which the two commands are built on.
Fixed
make:*exited 0 when it refused to overwrite a file. The generators setprocess.exitCode = 1on a conflict, but the console kernel sets the exit code from whatever a command'srun()returns — so the failure was overwritten with 0 on the way out, and a scaffolding step in CI reported success while having written nothing.generate()now reports through its return value.
0.84.0 — 2026-07-15
The theme of this release: the pluggable seams grew shipped, durable drivers, with a database-backed variant as the portable default — a queue, a cache, and a rate limit you can restart.
Added
A database queue driver. Jobs are rows now, if you want them to be:
setQueue(new DatabaseDriver())+queueMigration()and a dispatched job survives a deploy, a crash, and a Worker eviction. Workers claim jobs with an atomic conditional update (several can share the table), a claim held paststaleAfteris released, and exhausted jobs land in a failed-jobs table instead of vanishing with the process. Because jobs cross a process boundary as data, closures are refused with a pointed error and job classes are rebuilt viaregisterJobs()— in the worker too, which is never the process you thought it was.A redis queue driver. The same durability contract as the database driver, in Redis: pending jobs in a sorted set scored by when they're due (delays and backoffs are just future scores), claims via atomic
ZREM(one winner per member, no Lua — HTTP adapters like Upstash stay in play), a reserved set with deadlines for crash recovery, failures in a hash. The sorted-set/hash commands it needs are optional additions toRedisConnection— a minimal adapter keeps working everywhere else, and the queue names exactly which commands are missing.MemoryRedisimplements them all, so tests exercise the real driver.Queue console commands.
queue:work(poll;--onceto drain and exit, the right shape for a cron trigger),queue:failed,queue:retry <id|all>, andqueue:flush. They drive any driver implementingFailedJobStore.A Failed jobs panel in Watch — the dashboard's first tab that acts: retry and delete per row, retry-all, flush-all, against the same bookkeeping as the console commands.
Shared cache stores.
DatabaseStore(+cacheMigration()) persists entries as rows anywhere aConnectionruns;kvStore(env.CACHE)makes a Cloudflare KV namespace the shared cache for Workers (KV's 60-second TTL floor is absorbed by the envelope's own expiry — never a stale read, only later garbage collection). Tags, namespaces, grace, and stampede protection work unchanged on both, as they always did onredisStore().Rate limiting that counts somewhere shared.
rateLimiter()takes astore:redisRateLimitStore()counts with INCR (atomic — a burst across nodes can't slip past),cacheRateLimitStore()counts through anyCache(best-effort). The default stays in-memory, which is exactly as strong as a single-node deploy and no stronger — the docs now say so.Feature flags.
features().define("new-billing", (user) => …), asked anywhere withfeature(name, scope)— per user, per team, or globally. The first resolution per scope is persisted, so a rollout doesn't flap experiences;activate/deactivate/forgetare the explicit levers. Values are JSON (a flag can carry a variant), an undefined flag is off rather than an error, andDatabaseFlagStore(+flagsMigration()) shares decisions across processes. New guide:docs/flags.md.make:modelandmake:migration. The two generators everyone reached for first.make:model Post -m -f -cscaffolds the model plus its create-table migration, factory, and resource controller;make:migrationnumbers itself into the existing sequence and shapes the stub from the name (create_postscreates,add_slug_to_postsalters). Both are in the MCPkeel_scaffoldtool too.
0.83.17 — 2026-07-13
Added
@shaferllc/keel/ui— first-party design kit for Hono JSX views: CSS tokens,.keel-*component styles, and components (Button,Field,Panel,Shell,Hero, …). Import stylesheet via@shaferllc/keel/ui/css. Starters (minimal/app/saas) consume it instead of a localui.ts.https://keeljs.com/install.sh— curl the MCP installer from the docs domain (site serves the baked script;/install-mcp.shaliases it).keel-mcpnpm package — thin published bin sonpx -y keel-mcpresolves (forwards to@shaferllc/keel).scripts/install.sh— alias ofinstall-mcp.shfor the short curl path.The
saasstarter kit grew three capabilities. It stopped at teams and billing; these are the three things a real SaaS reaches for next.Social login (GitHub / Google). Off unless credentials are set — no button, and the route 403s, the same bargain billing makes with Stripe. Accounts match on the provider's id first, email second, and email links an existing account only when the provider says the address is verified. The reverse order is an account takeover: anyone can put your address on their GitHub profile. GitHub doesn't report verification in the profile payload, so the kit asks
/user/emailsand fails closed.A queue and a scheduler. Registration used to
awaitthe verification email inline, putting a mail provider on the critical path of every signup — a failing one turned a successful registration into a 500. It's a job now. Node drains aMemoryDriveron an interval; the edge stays sync (a Worker may not hold a timer between requests) and a cron trigger drives the scheduler via thescheduledhandler.A team-scoped REST API at
/api/projects, documented at/docs. There is noscope:and nowhere("team_id", …):Projectis aTenantModel, so the generated queries are already scoped and another team's project is a 404, not a leak.
Fixed
MCP config that Cursor actually runs — written
.mcp.jsonusesnpx -y --package=@shaferllc/keel keel-mcpso the server starts even if the thinkeel-mcppackage is missing from the registry.Inviting anyone to a team 500'd in the
saaskit.invite()sends the invitation email itself and needsteams.mail.from; the kit shipped noconfig/teams.ts, so the message had no from address andsend()threw. No test ever POSTed to/teams/invite, which is why it went unnoticed. There is one now.The
appkit's two-factor test scraped the TOTP secret with a regex anchored onclass="block break-alland silently stopped matching when anmt-2was added in front of it. It matches the element, not an exact class prefix.The
saas"personal team" test asserted/Solo's team/against HTML readingSolo's team— JSX escapes the apostrophe, so it could never match.
0.83.16 — 2026-07-13
Added
scripts/install-mcp.sh— curlable MCP installer:curl -fsSL https://keeljs.com/install.sh | bash(docs lead with this; same asnpx -y keel-mcp@latest init).
0.83.15 — 2026-07-13
Added
npx -y keel-mcp@latest init— one command writes (merge-safe).mcp.jsonin the current project. Flags:--cursor,--claude,--all,--token.
0.83.14 — 2026-07-13
Changed
- create-keeljs@0.1.3 —
npm create keeljs@latest .works in the current directory; confirms before writing into a non-empty folder (--force/--yesto skip).
0.83.13 — 2026-07-13
Fixed
- 2FA setup no longer goes blank after a bad code —
pendingTwoFactorSetup()rebuilds the local QR from the stored secret; starters keep the scanner on screen. QR data URLs use base64 for broader<img>compatibility.
0.83.12 — 2026-07-13
Changed
- create-keeljs@0.1.2 — depends on
@shaferllc/keel@^0.83.11so new apps get the Tailwind starter UI and local 2FA QR codes.
0.83.11 — 2026-07-13
Added
- Local 2FA QR codes —
enableTwoFactor()returnsqr(SVGdata:URL viauqr). Starters render an<img>so the otpauth secret never hits a CDN. Also exported:otpauthQrSvg/otpauthQrDataUrl.
0.83.10 — 2026-07-13
Changed
- Starter kit UI — quiet maritime theme (Syne + IBM Plex Sans) built as
Tailwind utilities +
@themetokens; sharedresources/views/ui.tsclass strings across minimal / app / saas.
Fixed
- SaaS
/teamswithout a personal team — bootstraps a team and entersrunForTeamsoProject.all()no longer 500s for accounts created before register minted a workspace.
0.83.9 — 2026-07-13
Added
- Starter kits deepened —
apiusesapiResource+ OpenAPI + Watch;app/saasfinish password-reset form, email verification, and 2FA confirm UI;saasadds role-gated invites, invitation revoke, and Stripe-ready team billing (pricing / checkout / portal, FakeGateway without keys). Billing config gainsbillableTableso migrations targetteamswhen the team is the customer. keel kit:sync— refresh untouched starter-kit files from the package templates.create-keeljswrites.keel/kit.jsonhashes so customized files are skipped unless--force.
0.83.8 — 2026-07-13
Fixed
keel servefinds a free port — when the configured port (default 3000) is already in use, walk up and bind the next free one instead of crashing withEADDRINUSE. Prints a warning when it falls back.
0.83.7 — 2026-07-13
Documentation
- Keel Cloud (deploy from MCP) — advertise and walk
through creating / previewing / publishing sites on
*.keeljs.cloudfromkeel-mcp. Linked from the install guide, AI guide, README, and MCP overview.
0.83.6 — 2026-07-13
Documentation
- From install to deploy — end-to-end
guide:
create-keeljs→ local dev → Cloudflare self-deploy → optional Keel Cloud + MCP. Getting Started and the README now lead with the generator.
0.83.5 — 2026-07-13
Added
- Full Keel Cloud MCP surface — billing status/checkout/portal, soft-delete
and restore sites, list/delete secrets, set/clear custom domains. Documented in
Building with AI. Pairs with expanded
/api/v1routes on Keel Cloud.
0.83.4 — 2026-07-13
Added
- MCP Cloud tools (
keel_cloud_*) — whenKEEL_CLOUD_TOKENis set,keel-mcpregisters create/list/preview/publish/secrets/export tools against a Keel Cloud control plane. Documented in Building with AI. tokensMigration()— personal access tokens schema helper for apps that need the table viakeel migrate.- Billing customer portal —
user.billingPortal(returnUrl)(Stripe + Fake gateway); opens the hosted portal to manage card / cancel. - Hosting guide —
docs/hosting.md+ example harness for@shaferllc/keel/hosting. - Tests for gates, hosting, billing portal, and
tokensMigration.
0.83.3 — 2026-07-12
Added
@shaferllc/keel/gates— signup gating for private alpha / waitlist: email allowlist + invite codes (canRegister/redeemInvite), shipped as a package with a migration. Used by Keel Cloud; not the same as team invitations or authorization gates.@shaferllc/keel/hosting— Cloudflare client, hostname helpers, SQL dump, and secrets encryption helpers for hosted Workers/D1 apps.
0.83.2 — 2026-07-12
Added
- More runnable doc examples. Billing, ORM, query builder, CORS, security,
social auth, OpenAPI, Watch, packages, Hono, and starter kits now have
typechecked harnesses under
docs/examples/— the same onesnpm run typecheck:docscompiles against the published surface. - Worked examples in the guides — a complete billing subscribe flow (with
FakeGateway), an ORM CRUD+eager-load walkthrough, a production CORS recipe, and a clearer starter-kit picker.
Changed
- Watch no longer describes itself by another product's name.
[0.83.0] — 2026-07-12
Added
Starter kits, and a generator that can't fall behind. Four curated applications —
minimal,api,app(views, sessions, register/login, password reset, two-factor), andsaas(teams, roles, invitations, billing, multi-tenancy):npm create keeljs@latest my-app -- --preset saasThe templates ship inside this package, so the version a kit is generated from is, by construction, the version it was written for. The old standalone starter drifted to five releases behind and nothing noticed; CI now generates all four kits on every push and typechecks, migrates, boots, serves a request, bundles the Worker, and runs their tests.
Teams (
@shaferllc/keel/teams) — multi-tenancy, membership, roles, and invitations.TenantModelmakes isolation the default rather than a habit: reads are constrained by an inherited global scope (so evenfind(id)returnsnullfor another team's row — not a filter you can forget), and writes are stamped with the current team, so a row can't be born ownerless.Outside a team context a tenant query throws. A job, console command, or webhook has no request and therefore no team; returning everything is how one customer's data reaches another, and
team_id = NULLmeans jobs quietly do nothing. So work says which team it is for —runForTeam(team, fn)— or says out loud that it spans all of them —withoutTenant(fn). Both are greppable at audit time.Accounts (
@shaferllc/keel/accounts) — password reset, email verification, and two-factor, mounted with one provider. A correct password on a 2FA account yields a short-lived, single-purpose challenge, not a session, so there is no half-authenticated state for a route to forget to check. TOTP is RFC 6238, verified against the RFC's published vectors, WebCrypto-only, and therefore edge-safe. No tokens table: reset links carry their own purpose and expiry and are bound to a fingerprint of the current password hash, so spending one kills it.D1 over HTTP (
@shaferllc/keel/db/d1-http). The D1 binding only exists inside a Worker, sokeel migratehad nowhere to point and you could not create your tables. The sameConnectionover D1's HTTP API closes that: migrations run from a laptop and from CI. It treats an error in the response body as an error even when the status is 200 — which is how Cloudflare often reports them, and trusting the status would let a failed migration look like it succeeded.Model.withoutGlobalScope(...)/withoutGlobalScopes()— escaping a scope should be typed out and greppable, never something you arrive at by forgetting awhere.
Fixed
Global scopes and model hooks now inherit. Both were keyed by the exact class, so a scope or a
creatinghook declared on a base class was silently ignored by every subclass. The models guide advertises global scopes as "the base for multi-tenancy", and that was precisely the case that didn't work: the scope did nothing, the query returned every tenant's rows, and nothing complained. It failed open. Both now walk the prototype chain, and a scope is passed the model it is scoping so a base class can read each subclass's own configuration.The official
@libsql/clientno longer needs a cast. UnderstrictFunctionTypesthe narrower parameter types made the realClientunassignable toLibSqlLike, so wiring libSQL the obvious way requiredclient as unknown as LibSqlLike— a cast even Keel's own tests carried.createTeam()could not give two people with the same name a team. It slugged the name into aUNIQUEcolumn, so the second "Ada" to sign up got a 500. Looking for a free slug first is check-then-act and loses the race anyway; the unique index is the only real arbiter, so it now arbitrates and the insert retries.
[0.82.0] — 2026-07-12
Added
- Query builder: the methods people actually reach for.
join/leftJoin,groupBy/having/distinct,whereColumn/whereRaw/orderByRaw,when,increment/decrement,upsert/insertOrIgnore, andchunk, with docs.
[0.81.2] — 2026-07-12
Documentation
- The changelog is published to the docs site (
docs/changelog.md), so releases are readable at keeljs.com rather than only in the repository.
[0.81.1] — 2026-07-12
Documentation
- Full API-reference entries for the new ORM surface.
0.81.0shipped the guides; this fills in the per-method reference the docs maintain for everything else — the query-builder additions (join/leftJoin,groupBy/having/distinct,whereColumn/whereRaw/orderByRaw,when,increment/decrement,upsert/insertOrIgnore,chunk), the migration builders (index/foreign/alterTable,AlterTableBuilder,ForeignKeyBuilder), and the model additions (with/withCount/whereHas/has/doesntHave,ModelQuery, lifecycle events +observe, global scopes, soft deletes,hidden/visible/appends,morphMany/morphOne/morphTo/registerMorphType). Corrects two now-stale notes ("no soft-delete built in", "nested eager loading isn't here yet").llms.txt/llms-full.txt/ai-manifest.jsonregenerated to match.
[0.81.0] — 2026-07-12
Added
ORM: the features people reach for. The active-record
Modelgrows the surface a real ORM needs, all backward-compatible and still on the driver-agnostic query builder (no JOINs, edge-safe):Lifecycle events & observers —
creating/created,updating/updated,saving/saved,deleting/deleted,restoring/restored,retrieved. The*ingevents are cancelable (a hook returningfalsevetoes the write);Model.observe({...})attaches an observer object.User.creating((u) => { u.uuid = crypto.randomUUID(); }); User.deleting((u) => (u.isRoot ? false : undefined)); // vetoGlobal scopes (
addGlobalScope) applied to every query the model builds and inherited by subclasses — the base for tenancy and published-only reads.withoutGlobalScope(...)opts out, explicitly and greppably. Local scopes are just static methods returningquery().Soft deletes —
static softDeletes = true+deleted_at;delete()sets the timestamp, a scope hides trashed rows, andwithTrashed/onlyTrashed/restore/forceDelete/trashedround it out.with/withCount/whereHas/has/doesntHave— a model-awareModelQuerywith nested eager loading ("posts.comments") and relationship-existence filters, via a two-query strategy (no JOIN).await User.query().with("posts.comments").withCount("posts") .whereHas("posts", (q) => q.where("published", true)).get();Serialization control —
static hidden/visible/appendsontoJSON()(appends resolve getters or zero-arg methods).Polymorphic relations —
morphOne/morphMany/morphTowith a morph-type registry (registerMorphType), eager loading across mixed types, andwhereHas/withCountsupport.
Query builder grows
join/leftJoin,groupBy/having,distinct,whereColumn,whereRaw,orderByRaw,when(),increment/decrement, dialect-awareupsert/insertOrIgnore, andchunk()for paged iteration over large tables.Migrations grow
index()/uniqueIndex()andforeign().references().on()increateTable, plusSchemaBuilder.alterTable(add/drop/rename column, add/drop index) — so altering a table no longer needs hand-writtenraw()SQL.Accounts — a new
@shaferllc/keel/accountspackage: password reset, email verification, and two-factor auth (TOTP + single-use recovery codes), driven byattempt()and anAccountsServiceProvider, with its own migration and publishable config.Teams — a new
@shaferllc/keel/teamspackage: multi-tenancy with aTenantModel(aTENANT_SCOPEglobal scope), request-scoped team context, and invitations.
Changed
Model.create()now routes throughsave(), so mass-assignment, timestamps, and thesaving/creatingevents all apply in one place.
[0.80.0] — 2026-07-12
Added
Billing — a new
@shaferllc/keel/billingpackage: a subscription layer with one gateway-neutral API over Stripe and Paddle (switching gateways is a config change).class User extends Billable(Model)gives a gateway customer, subscriptions (create/swap/quantity/trials/cancel/ resume + status checks), single charges + refunds, invoices, hosted checkout, and verified per-gateway webhooks that sync local state and emit typed events.class User extends Billable(Model) { static table = "users"; } await user.newSubscription("default", "price_pro").trialDays(14).create(pmId); if (await user.subscribed()) { /* … */ }Reaches the active gateway from model methods through a module-level singleton (
setBilling/billing), matching Keel'ssetConnection/setLoggerpattern. ShipsSubscription/SubscriptionItemmodels, a gateway-neutral migration, a publishable config stub, and an in-memoryFakeGatewayso billing flows test without a network. Webhook signatures are verified with a vendored hex HMAC-SHA256 (edge-safe Web Crypto). Paddle's merchant-of-record differences (checkout-created subscriptions, no raw card handling) surface as clearBillingErrors rather than silent gaps.
[0.79.0] — 2026-07-12
Added
Route model binding. A
:postin the path arrives as aPost, not a string.bindModel("post", Post); router.get("/posts/:post", (c) => { const post = boundModel(Post); // already fetched. Not a string, not null. return c.json(post); });The row is looked up before the handler runs, and a miss is a 404 there and then — so the handler never sees a
nulland never has to remember to check for one. "Forgot the 404" stops being a bug you can write.keybinds by another column (/posts/hello-world→{ key: "slug" }).scopeis security, not a filter. A row outside it is a 404 — not a 403 (which would confirm it exists), and not merely absent from a list — so it can't be reached by guessing its id. The scope gets the request, so it can depend on who's asking. Tested against a real database, asserting the handler never runs for an out-of-scope row.- Binding runs before route middleware, so a policy can read the model instead of re-fetching it.
bindRoute(param, fn)resolves anything that isn't a model — a tenant, a feature flag; returningundefinedis a 404.- An unbound param is untouched (still a string), and only routes with parameters pay for any of this. Two params bound to the same model must be disambiguated — guessing would be worse than asking.
[0.78.2] — 2026-07-11
Added
Releases publish themselves. Pushing a
v*tag now builds, tests, and publishes to npm from CI, via trusted publishing (OIDC) — no token to leak or rotate, and npm attaches a provenance attestation: proof the tarball was built from that commit by that workflow, rather than uploaded from a laptop.Publishing by hand is why npm drifted so far from git — git reached v0.77.0 while npm's
lateststill said 0.74.0, and the published versions were sporadic (0.12, 0.35, 0.58, 0.66, 0.68, 0.74). The tag is the release now.It refuses to publish if the tag disagrees with
package.json, or if the typecheck, tests, or build fail — a tag can point at any commit, including one CI never saw.(v0.78.1 was tagged before this workflow existed, so it never reached npm; its
./package.jsonexport fix ships here.)
[0.78.1] — 2026-07-11
Fixed
./package.jsonis exported. With anexportsmap, Node blocksrequire("@shaferllc/keel/package.json")unless it's listed — and plenty of tooling reads it (bundlers, version checks, framework plugins). Caught by installing the published package and poking at it, which is the only way to see this class of problem.
[0.78.0] — 2026-07-11
Removed
The demo app is gone from the framework repo.
app/,bootstrap/,config/,routes/,resources/,database/,bin/keel.tsandvite.config.tswere an example application living inside the library. None of it shipped (thefilesfield isdist,docs, and the MCP bin), and after 0.76.0 inverted the CLI's dependency, nothing in the framework referenced it any more.This repo is now the framework, and only the framework. Application code — controllers, providers, routes, config — belongs in your repo; the starter app has the layout.
The
keel/serve/dev/dev:client/build:clientnpm scripts went with it: they existed to run the demo.npm test,npm run typecheck,npm run buildandnpm run verify:releaseare what you run here.
[0.77.0] — 2026-07-11
Changed
The console runs on Keel's own console. All 20 built-in commands (
serve,routes,repl,mcp, everymake:*,migrate:*,vendor:publish) are nowdefineCommand()s on theConsoleKernel.commanderis gone — not moved to a runtime dependency, removed.This was forced by shipping the console in 0.76.0:
@shaferllc/keel/cliimportedcommander, which was a devDependency, so a consumer installing the package gotERR_MODULE_NOT_FOUNDon import. Promoting commander to a runtime dep would have fixed the symptom while shipping a second command system alongside the one we'd just built. So the built-ins moved instead.What you get for it: generated help (
keel help make:controllerprints usage, args, and options), commands grouped by namespace,routesandmigrate:statusas real tables, and typed flags everywhere —keel routes --nopeis now an error rather than a shrug.PackageCommandis nowdefineCommand()'s shape — it was typed against a commanderCommand. A package's command gets typed args and flags, generated help, the terminal UI, and the prompt-trapping test story for free.Breaking for a package contributing commands: replace
configure/actionwithflags/args/run. Both in-tree packages (openapi:export,watch:prune) are ported.That port fixed a live bug:
watch:prune --hours lotsdidNumber("lots")→NaN, so the retention cutoff becameNaNand the prune silently misbehaved. A typedflag.number()rejects it as a usage error.@hono/node-serveris an optional peer dependency, dynamically imported byserve. It's only needed to serve on Node — a Workers app has no reason to install it — and a missing one now says so instead of failing at import.
[0.76.0] — 2026-07-11
Added
- The console ships in the package.
@shaferllc/keel/cli— so an app getsserve,routes,repl,migrate:*, and everymake:*generator from the dependency, rather than having to vendor them.
Changed
run(argv, { createApplication })— the console is handed an application factory instead of importing one.src/core/cli/index.tsimportedbootstrap/app.ts: the framework depended on an application, which is the dependency pointing the wrong way. It also had consequences —- the file reached outside
rootDir: src, sotsconfig.build.jsonhad to exclude it from the build; - which meant the console was not in the published package at all (only the
keel-mcpbin was); - and it's why
runCommand()in the testing toolkit takes a callback rather than an argv array — importing the CLI fromtesting.tsbroke the build.
Your
bin/keel.tsnow passes its own factory. The CLI compiles, ships, and is importable.Breaking for anyone calling
run()directly: it takes a second argument. A one-line change inbin/keel.ts.- the file reached outside
[Unreleased]
Added
- CI. Every push and pull request now runs the checks that were, until now, only
ever run by hand in the right order by someone who remembered to:
typecheck— src and tests. The suite went unchecked for a long time, so a test asserting a type at compile time was asserting nothing.test.build— the check that matters most. A git install runs the build throughprepare, so a tree that doesn't build cannot be installed at all — and neither the tests nor the typecheck can see it, because they run against the working directory. CI's checkout is the committed tree, which is exactly what shipped three uninstallable tags (v0.74.0–v0.74.2).typecheck:docs— the docs examples compile againstdist/, i.e. the real published surface. They have caught bugs the tests could not: inference that worked in-repo but broke for a consumer.- The generated AI surface is in sync — regenerating
llms.txt,llms-full.txtanddocs/ai-manifest.jsonmust be a no-op, or someone changed a doc or an export and shipped a stale surface. - No control characters in source — we have shipped both a NUL byte (a cache key) and raw ANSI escapes (the console colors), each of which turns a text file "binary" to grep, diff, and code review.
[0.75.1] — 2026-07-11
Changed
- Removed comparisons to other frameworks from the API-resources guide and source comments. Keel isn't that, and the docs shouldn't read as a comparison to another framework. The surrounding sentences are rewritten so they still say what the feature does rather than leaving a hole. No behavior change.
[0.75.0] — 2026-07-11
Added
API resources — a full CRUD REST API from a model.
@shaferllc/keel/api.apiResource(router, Post, { filter: ["status", "authorId"], sort: ["createdAt", "title"], body: PostSchema, access: { read: true, write: (c) => isEditor(c) }, scope: (q, c) => q.where("authorId", currentUserId(c)), });It registers real routes on the router — so
url()finds them,keel routeslists them, and@shaferllc/keel/openapidocuments them for free — rather than hiding a generic handler behind a wildcard.- Access is deny-by-default. An action with no rule returns 403. For a generated API that's the only safe default: you opt routes open, never shut, so forgetting a rule fails closed rather than publishing your table.
- Filtering and sorting are allow-listed. A column not on the list is silently
ignored, never passed to SQL — which is what stops
?sort=passwordor?secret_column=xfrom doing anything.perPageis clamped to a ceiling, so there's no "give me everything". scopeis row-level security, not decoration. A row outside the scope 404s for read, update and delete — not merely absent from the list — so it can't be fetched, changed, or removed by guessing its id. There are tests for each of those three, asserting against the database that the row really wasn't touched.- Writes run through the model's mass-assignment guard and your Zod schema
(
body, orcreateBody/updateBody), withbeforeWriteto set fields the client never sends (an owner id, timestamps). transformshapes the output (a function or a KeelTransformer);only/excepttrim the action set;ApiServiceProviderpublishes aconfig/api.tsfor the pagination defaults.
The api package does not import openapi — it writes its operation docs under a known route-config key, so the two install independently. A test asserts that key still matches the one openapi reads, because a silent drift there would stop documenting every generated route and no comment would catch it.
[0.74.4] — 2026-07-11
Fixed
flag.string({ parse })and friends now infer their parameter. Theconstgeneric on the option builders swallowed the contextual type in the emitted declarations, so a consumer writingparse: (raw) => raw.toUpperCase()would have had to annotaterawby hand even though it compiled fine inside this repo. Same bug, same fix, asenvVar'svalidate.
Changed
The test suite is type-checked.
tests/was not in any tsconfig'sinclude, and tests run throughtsx, which strips types without checking them — so the suite had 44 type errors nobody could see, and a test asserting a type at compile time was asserting nothing at all. All 44 are fixed, andnpm run typechecknow coverssrcandtests.npm run verify:releasebuilds from what is committed, not from the working tree.npm testandnpm run typecheckboth run against your working directory, so neither can see a file you forgot to commit or committed half-written — while a git install runs the build throughprepare, so a broken tree there means the package cannot be installed at all. That is exactly how v0.74.0–v0.74.2 shipped unusable. This exportsHEADand does the install a consumer would.
[0.74.3] — 2026-07-11
Fixed
v0.74.0–v0.74.2 did not build from a clean checkout, which made them unusable as a dependency (a git install runs
npm run buildthroughprepare). An in-progresssrc/apifeature was committed by accident, half-written, and it didn't compile; a leftovercp src/api/…in the build script then failed once the feature was untracked.It is now untracked again — the package exports and the build config no longer include it — so the released tree is back to what it was in 0.73.0 plus the environment validation that 0.74.0 was actually about. A clean-clone build is verified before tagging now, rather than after.
[0.74.0] — 2026-07-11
Added
Environment validation — fail at boot, not at 3am.
env("DATABASE_URL")hands back whatever is (or isn't) inprocess.env, so a missing variable boots a perfectly healthy-looking app that dies on the first request that needs it, in production, at night.defineEnv()checks the whole environment up front and refuses to start otherwise.export const env = defineEnv({ APP_KEY: envVar.string({ required: true, description: "32+ random characters" }), PORT: envVar.number({ default: 3000 }), NODE_ENV: envVar.enum(["development", "test", "production"], { default: "development" }), DATABASE_URL: envVar.url({ required: true }), SENTRY_DSN: envVar.string(), }); env.PORT; // number — not "3000" env.NODE_ENV; // "development" | "test" | "production" — not string env.SENTRY_DSN; // string | undefined- The value types are inferred from the rules: a
numberrule gives anumber, anenumgives the literal union rather thanstring, and anything optional without a default is| undefined— so you can't forget to handle it. - Every problem is reported at once, not the first one. Fixing a deploy one missing variable per restart is its own small hell.
- Rules:
envVar.string/number/boolean/enum/url, each withrequired,default,description(shown in the failure, so they know what to set), andvalidate.urlcatches a truncated connection string;booleanaccepts the spellings people actually use (1,yes,on). - An empty string counts as absent —
PORT=in a.envis a typo, not a deliberate empty port. - The returned object is frozen, so nothing reassigns your config at runtime.
- The value types are inferred from the rules: a
[0.73.0] — 2026-07-11
Added
- Database transactions.
transaction(fn)commits whenfnreturns and rolls back if it throws — so two related writes either both land or neither does, and a failure between them can't leave the card charged and the order missing. The error is rethrown after the rollback; nothing is swallowed.- Queries inside are ambient.
db(), models, and relations all pick up the open transaction without being handed it, because it lives inAsyncLocalStoragerather than a module global — so two requests running transactions at once can't steal each other's connection.transaction()also passes an explicit handle (tx.table(),tx.write(),tx.rollback()) for when you'd rather be obvious, andinTransaction()reports whether one is open. - Nesting uses savepoints. A
transaction()inside another doesn't open a second transaction — databases don't have those — it takes a savepoint, so an inner failure rolls back only the inner work and the outer transaction carries on. Without that, a nested helper's failure would silently abandon its caller's writes too. - The pooling trap, closed. A transaction needs every statement on one
connection, but a pool hands each statement to whichever is free — so
BEGINissued through a pool wraps nothing, theCOMMITcommits nothing, and a failure half-writes. It looks like it works.Connectiontherefore gains an optionalbegin(): the Postgres adapter now checks a connection out of thePool(detected viaconnect()), runs the whole transaction on it, and releases it afterwards even if theCOMMITthrows. Single-connection drivers (a barepg.Client, SQLite, libSQL) need nothing and fall back toBEGIN/COMMIT/ROLLBACK. - D1 refuses honestly. Cloudflare D1 can't hold a transaction open across
awaits, so
transaction()on it throws a clear error pointing atdatabase.batch([...]), rather than letting aBEGINfail cryptically inside the driver. A transaction that quietly isn't one is far worse than one that refuses to start.
- Queries inside are ambient.
[0.72.0] — 2026-07-11
Added
A real console. Commands with typed arguments and flags, prompts, a terminal UI, and a REPL.
keel make:command greetscaffolds one; anything inapp/Commandsis discovered automatically. See the console guide.- The types are inferred from the spec, not cast.
arg.string()gives you astring;arg.string({ required: false })gives youstring | undefined; add a default and it's astringagain. The parsing is generated from the same declaration, so the two can't drift apart. arg.string/number/spreadandflag.boolean/string/number/array, each withdescription,default,required,parse, and (for flags) a single-letteralias. The parser handles--flag value,--flag=value,--no-flag,-f value, bundled shorthands (-lt 5), and--passthrough.- An unknown flag is an error, not a shrug — a typo'd
--forseshould tell you rather than silently doing nothing.allowUnknownFlagsopts out. - Usage errors print what's wrong and the command's help; a thrown error exits 1 with its message, not a stack trace. A console is a bad place to show someone a stack because they mistyped a flag.
- Terminal UI:
info/success/warning/error,action()for aligned CREATE/SKIP lines, tables, stickers, numbered instructions, colors, and a task runner that stops at the first failure (the tasks after it almost certainly depended on it, and a cascade of red tells you nothing new). No dependency — ANSI codes are a dozen escape sequences, not a package. - Prompts:
ask,secure,confirm,toggle,choice,multiple,autocomplete, withdefault/hint/validate/result. A failedvalidatere-asks rather than dying. - Prompts are testable, which is the whole point:
createPrompt({ trap: true })lets a test script the answers, andcreateUi({ raw: true })buffers the output colorlessly so you can assert on exactly what a command said. An untrapped prompt throws instead of hanging — otherwise a test would block forever on stdin it will never receive, and the suite would just stop, with no failure to read.assertAllTrapsUsed()catches a question you scripted but never asked. keel repl— an interactive shell with the application booted: the container is up, the providers have run, anddb,make,cache,routerand friends are in scope..lslists them. Poking at a model in a REPL is the fastest debugging loop there is, and it shouldn't cost you a throwaway script.
The built-in commands (
serve,routes,make:*,migrate:*) and package-contributed commands still run through the original console wrapper; your commands run on the new system and take precedence over a built-in of the same name. Migrating the built-ins across is mechanical and changes none of the API above.- The types are inferred from the spec, not cast.
[0.71.0] — 2026-07-11
Added
Pages — page-based routing, where a file is a route.
resources/pages/users/[id].tsxserves/users/:id; no route file to keep in sync, no controller, no wiring. New pages guide, andkeel make:page users/[id].- Conventions:
index.tsxnames its directory,[id]is a parameter,[...slug]is a catch-all, and a leading_keeps a file private — so a layout or a partial can live beside your pages without becoming a URL. loaderruns before the page renders and its return value arrives asdata;middlewareguards a page (and runs before the loader, so a refused page never loads its data);nameandpathoverride the derived route name and URL.- Specificity is decided for you. This is the part file-based routing
usually gets wrong: register
/users/:idbefore/users/newand the literal page is unreachable forever, because:idmatches"new". Pages are sorted before they're registered — literals beat parameters, parameters beat catch-alls — so the file layout stops being a trap. - It drives the router rather than replacing it. Every page becomes an
ordinary named route, so
url()finds it, route middleware applies, andkeel routeslists it. Mix pages and hand-written routes freely, and reach for a controller the moment a page outgrows a file. - Edge-safe:
pages()scans the filesystem on Node, whiledefinePages()takes a build-time manifest —import.meta.glob("./pages/**/*.tsx", { eager: true })— so the same pages run on Workers.
- Conventions:
Packages — a redistributable slice of an app. Routes, a UI, config, migrations, and console commands that install with a single
app.register(...).ServiceProviderwas already the unit of composition;PackageProvideradds the conventions a shippable package needs, so it can carry its own schema and assets instead of asking the host app to wire them by hand.MigrationRegistry,CommandRegistry,PublishRegistry. New packages guide.Watch — a debug dashboard. Records the requests, queries, exceptions, logs, jobs, mail, notifications, cache lookups, events, and scheduled tasks flowing through the app, and shows them at
/watch, with each request linked to the queries and logs it produced. Built on a new instrumentation seam (instrument(),runRequest(),currentRequestId()) and ontapLogs(), which observes every log record without changing where logs normally go. Ships as a Keel package, and is that system's reference implementation. New watch guide.
[0.70.0] — 2026-07-11
Added
Telemetry — distributed tracing with no SDK. Spans, W3C trace context, and an OTLP exporter, in a module you can read. The OpenTelemetry Node SDK is a large tree of packages that assumes a Node process; what a trace is, though, is small — an id, a parent, a start and an end, some attributes, and a documented JSON shape to POST them in. This speaks OTLP/HTTP over
fetch, so it runs on Workers as happily as on Node, and any collector takes it (Jaeger, Tempo, Honeycomb, Grafana, Datadog). New telemetry guide.trace(name, fn)opens a span, ends it whenfnsettles, and records a throw before rethrowing. Spans nest automatically acrossawaitboundaries — and across concurrent traces, because the current span lives inAsyncLocalStorage, not a global, so two in-flight requests can't get tangled.tracing()middleware: a server span per request, joined to the caller's trace via theirtraceparent, with the trace id written back on the response so a user reporting a slow page can be looked up. A 5xx fails the span; a 404 doesn't — that's a valid answer, not a fault.injectTraceContext()for outgoing calls, plusparseTraceparent()/traceparent(). A malformed header starts a fresh trace rather than failing the request.traceIds()to hangtrace_id/span_idon a log line — the jump from a log to the trace it came from.sampleRatio, decided once at the root and inherited by every child, because half a trace is worse than none.otlpExporter(),consoleExporter(), andMemoryExporterfor tests. Spans batch;flushTelemetry()drains them before an isolate goes away.
A real testing toolkit. The test client injects requests without a server; this fills in everything around it. See the testing guide.
- Request building:
withToken(),withBasicAuth(),withHeader(s),withCookie(s),acceptJson()— each returning a copy, so a configured client can't leak into another test — plusform()andmultipart(). - Response assertions:
assertJsonContains()(a subset match — pins the fields a test is about, so adding an unrelated field doesn't break twenty tests),assertSee()/assertDontSee(),assertValidationErrors(...fields),assertCookie()/assertCookieMissing(),assertHeaderMissing(), status shorthands (assertCreated,assertNotFound,assertUnprocessable, …), anddump(). - Database assertions:
assertDatabaseHas()/assertDatabaseMissing()/assertDatabaseCount()/assertDatabaseEmpty(), andtruncate()— which deletes rows rather than rolling back a transaction, so it works on every driver rather than only the ones with savepoints. - Time control:
freezeTime()/timeTravel()/restoreTime(), so "expires in an hour" doesn't take an hour to test. MocksDateandDate.now()— not timers, and notnew Date("2020-01-01"); only "what time is it now". - Spies:
spy()andspyOn(), which call through by default — observing rather than stubbing — until you tell them otherwise.restoreSpies()undoes them. - State reset:
resetState()restores every fake, unfreezes the clock, drops event listeners, empties the cache, and hands back a fresh lock store. - Console tests:
runCommand(fn)captures stdout, stderr, and the exit code, withassertSucceeded()/assertFailed()/assertOutputContains()and friends. You pass the command in, because the console entry point belongs to your app, not the core. - Browser tests are documented rather than wrapped: Playwright already does this well, and a thinner API in front of it would only get in the way.
- Request building:
[0.69.1] — 2026-07-11
Fixed
serveStorage({ signed: true })now fails loudly on abasePathmismatch.signedUrl()signs the path the disk reports, so a disk handing out/storage/…while the middleware is mounted at/privatecould never produce a matching signature — and every request 403'd, which reads as "your link expired" and sends you hunting in the wrong place. It now throws, naming both paths and how to line them up.
[0.69.0] — 2026-07-11
Added
AI-native tooling — write Keel apps with an agent. A machine-readable surface generated from the same source as the human docs, so it never drifts:
- An MCP server (
keel mcp/ the shippedkeel-mcpbin) exposing Keel's docs, full public API (400+ exports), generators, and conventions to any Model Context Protocol client. Tools:keel_overview,keel_search_docs,keel_read_doc,keel_search_api,keel_list_generators,keel_scaffold. Resources:keel://overview,keel://llms-full, andkeel://docs/<slug>per guide. AGENTS.md+CLAUDE.md— an agent playbook (import rule, folder map, container/provider model, how-to-add-X table, guardrails), shipped in the package.llms.txt+llms-full.txt— a spec-compliant doc index and a one-file concatenation of every guide, shipped in the package.docs/ai.md— the "Building Keel apps with AI" guide.npm run build:airegeneratesllms.txt,llms-full.txt, anddocs/ai-manifest.json(the index the MCP server reads); wired intobuild.
- An MCP server (
Distributed locks (
lock(),MemoryLockStore,LockStore) — "only one of you may do this at a time" across processes and nodes, with a pluggable store seam (the core imports no driver). See the locks guide.Every acquisition mints an owner token, and release/extend only succeed for the owner. That isn't bookkeeping — without it, a lock whose TTL expires mid-work gets picked up by process B, and A's late
release()would delete B's lock and let a third process in.run()takes the lock, runs, and always gives it back;extend()throws rather than silently no-op'ing once the lock is lost.Internationalization — ICU message formatting plus the
Intlformatters that go with it, with no dependency: Node and Workers both ship full ICU, so plurals, currencies, dates, and relative times are the platform's job, and Keel only adds the message parser on top.t(key, data)/i18n(locale), with an ICU subset covering interpolation,plural(including exact=0branches and#),selectordinal,select,number(incl.::currency/USD),date, andtime— nested arbitrarily deep. Plural categories come from the locale, not from English.Intl-backedformatNumber/formatCurrency/formatDate/formatTime/formatRelativeTime/formatList/formatPlural/formatDisplayName— worth using even in a single-locale app.detectLocale()middleware (custom resolver → query → cookie →Accept-Language→ default; only supported locales are honored, so?lang=xxcan't push the app into a locale you have no translations for) andnegotiateLocale()on its own.- Nested or flat translation keys, and a fallback chain that walks
es-MX→ configured fallback →es→ default, so a regional locale can be a handful of overrides. - A missing key renders as the key itself (the page still works and the gap is
visible) and fires
i18n.missing.
Mail: queueing, attachments, class-based mails, and a fake.
sendLater()— put the message on the queue instead of holding the request open for an SMTP round trip. Validated at the call site, not on the worker, so a malformed message throws where the stack trace means something.- Attachments —
attach()(content type inferred from the extension) andembed()for inlinecid:images. BaseMail— a reusable, testable email class;send()/sendLater().- Named mailers —
setMailer(t, o, "marketing")/mail("marketing"). fakeMail()/restoreMail()withassertSent/assertNotSent/assertSentCount/assertQueued/assertNotQueued/assertQueuedCount/assertNothingSent. Sent and queued are tracked separately, and the fake still validates, so it can't paper over a message the real mailer would reject.mail.sending/mail.sent/mail.queuedevents, and a defaultreplyTo.
Queues: retries, backoff, priority, and a dead-letter list.
- Retries with backoff —
static maxRetriesandstatic backoffper job class (exponentialBackoff/linearBackoff/fixedBackoff/noBackoff), overridable per dispatch.maxRetriesdefaults to 0 — the safe default for work that isn't idempotent. failed(error)hook and a dead-letter list (driver.failed): an exhausted job is logged, handed to its hook, and kept, rather than vanishing.- Priority (lower runs first) and per-class
queue/prioritydefaults. JobContext(jobId,attempt,queue) readable fromhandle().fakeQueue()/restoreQueue()withassertPushed/assertNotPushed/assertPushedCount/assertNothingPushed/pushedJobs.
- Retries with backoff —
Logger:
traceandfatal, sinks, and better redaction.- Levels are now
trace<debug<info<warn<error<fatal, pluslog(level, …),isLevelEnabled()/ifLevelEnabled()(so an expensive context object isn't built for a line nobody will emit), andenabled: false. - Sinks — output goes through a
Sinkfunction receiving the structuredLogRecord, so logs can go to a file or an HTTP collector instead of the console.MemorySinkcollects them for tests. - Redaction gains
*wildcard path segments ("*.password"), a customcensor, andremoveto drop the key outright. It still never mutates the caller's object, and it runs before the sink, so a custom sink can never see the unredacted values. - Named loggers —
setLogger(logger, "audit")/namedLogger("audit").
- Levels are now
hasApplication()— whether an application has been bootstrapped. The queue and the mailer use it so they still work in a worker or a unit test that never created one.
Changed
A failed queue job no longer takes down the worker.
work()used to propagate the error and stop draining. It now retries the job, and once the retries are exhausted it logs the failure loudly, runsfailed(), records it in the driver's dead-letter list, and carries on with the rest of the queue — one bad job can't stop the others.SyncDriveris the exception: it ran the job inline, so the caller still gets the error thrown at them.dispatch()now materializes the queue defaults, so a queued job'soptionsalways carryqueueandpriority.
0.68.0 — 2026-07-11
Added
Storage: signed URLs, direct uploads, and metadata. Kept keel's "core imports no SDK" rule — the
Diskseam grew optional capabilities, so existing disks keep working untouched:- Content types.
put()now infers the MIME type from the path's extension (.png→image/png), so files stop landing in your bucket asapplication/octet-stream— the difference between a browser rendering a file and downloading it. NewWriteOptions(contentType,cacheControl,visibility, custommetadata) override it. signedUrl(path, { expiresIn })— a temporary URL for a private file. A disk with backend presigning (S3/R2/GCS) returns the bucket's own; any other disk gets one signed withconfig('app.key'). The signature covers the path and query but not the host, so a signed URL survives a CDN hostname.signedUploadUrl(path, { contentType })— the browserPUTs straight to the bucket, so a 50 MB upload never streams through a Worker. Requires a disk that can presign; there's no generic fallback, and calling it on one that can't throws rather than handing back a URL that won't work.serveStorage()— middleware that serves a disk's files over HTTP withETag/304 and storedCache-Control, and (insigned: truemode) rejects unsigned or expired requests with a 403. This is what makes app-signed URLs real for disks without backend presigning.metadata()/size()/copy()/move()— using the backend's server-side operation when the disk offers one, falling back to read-then-write otherwise.fakeDisk()/restoreDisk()withassertExists/assertMissing/assertContents/assertCount, so tests never touch a real bucket. Matches thehash.fake()precedent from 0.66.0.- Also
signStorageUrl/verifyStorageUrl/contentTypeForfor signing any URL yourself, and an S3/R2 presigning disk recipe in the storage guide.
- Content types.
Events: a typed registry, error isolation, and fakes.
- The
EventsListregistry. Declare an event's payload once via module augmentation and both sides are checked — the value youemitand the one your listener receives can no longer drift apart. Opt-in and incremental: an undeclared event behaves exactly as before. onError(handler)— route listener failures to one handler (with the event name and payload) instead of letting them rejectemit.onAny(listener)— observe every event, for logging and metrics.fake()/restore()returning anEventBufferwithassertEmitted(optionally payload-matching),assertNotEmitted,assertEmittedCount,assertNoneEmitted,all(), andpayloadsFor()— assert an event fired without triggering its side effects.clearAll()— drop listeners, any-listeners, and the error handler.
- The
Health checks. New health guide and
healthCheck()middleware serving the two endpoints an orchestrator actually asks about:/health/live(answers instantly, checks nothing — a liveness probe that touched the database would get a healthy app restarted during a database blip) and/health/ready(runs every registered check; 200 while healthy, 503 when one fails, which evicts the instance without killing it).health().register([...]),Result.ok/warning/failed(a warning is still healthy),withMeta(),cacheFor(seconds)so a frequent probe doesn't hammer what it's probing,check(name, fn)andBaseCheckfor your own, and built-inDatabaseCheck/RedisCheck/CacheCheck. A check that throws becomes a failed result rather than taking down the report.Deliberately absent: disk-space, heap, and RSS checks. They measure a Node process, and on Workers there isn't one.
Changed
- A throwing event listener no longer skips the listeners after it.
emit()now runs every listener and reports failures afterwards — rejecting with the error, or with anAggregateErrorif several failed, or handing them toonError()if one is registered. Previously the first failure aborted the loop, so an analytics listener blowing up could silently cancel the welcome email. Failures are still never swallowed.
0.67.0 — 2026-07-11
Added
Cache resilience & invalidation. Stayed inside keel's single-store, edge-native model:
- Stampede protection.
remember()/rememberForever()now collapse concurrent misses for the same key into a single factory run, sharing the result — a hot key expiring no longer dog-piles the upstream. Per-isolate (no cross-node lock), which is where the dog-pile actually melts a server. - Grace / stale-on-error.
remember(key, ttl, factory, { grace })retains an expired valuegraceseconds longer and serves it if the refreshing factory throws — a flaky upstream degrades to slightly-stale data instead of an error. A plainget()still reports the expired key as a miss, so stale values never leak through the read path. - Tags &
deleteByTag. Tag entries via a{ tags }option onput/add/remember/rememberForever, then invalidate a whole group withdeleteByTag(["posts"]). Uses version-stamp invalidation (a per-tag counter entries record anddeleteByTagbumps), so it's O(number of tags) with no key index and works on anyCacheStore. Tag-dropped entries are a hard miss (not grace-eligible). - Namespaces.
cache().namespace("users")scopes keys under ausers:prefix (so namespaces can reuse logical keys) and itsflush()clears only that namespace via the same version-stamp mechanism. Namespaces nest and carry the full API. add(key, value, ttl?)— write only if absent, returns whether it wrote.missing(key)— the inverse ofhas.forgetMany(keys)— delete several keys at once.- New
RememberOptionsandPutOptionstypes.
Values are now stored in an internal envelope (value + logical expiry + tag stamps) so the cache can distinguish fresh from grace-retained or tag-invalidated; this is transparent through the
CacheAPI and JSON-safe for the Redis store. Existingget/put/has/pull/rememberbehavior is unchanged, and the pluggableCacheStorecontract is untouched. Intentionally not matched from bentocache: multi-tier L1/L2 + bus (multi-node sync), soft/hard timeouts, and the DynamoDB/database/file drivers — larger features that cut against keel's single-store simplicity.- Stampede protection.
0.66.0 — 2026-07-11
Added
hash.fake()/hash.restore(). Swap real PBKDF2 for a trivial, insecure scheme in tests so a suite that creates many users doesn't pay the (deliberate) hashing cost —makereturnsfake$<password>andverifyjust compares. Never for use outside tests.
0.65.0 — 2026-07-11
Added
Security middleware suite. Hashing and encryption already existed; this adds the rest — all edge-native:
cors()— Cross-Origin Resource Sharing with automatic preflight handling.originas boolean /"*"/ allowlist / predicate, plusmethods,headers,exposeHeaders,credentials(auto-downgrades"*"to the concrete origin), andmaxAge. New CORS guide.securityHeaders()— the SSR "shield": Content-Security-Policy (string or a camelCase directives object), HSTS,X-Frame-Options,X-Content-Type-Options: nosniff, andReferrer-Policy, each individually toggleable.csrf()— session-backed CSRF protection; rejects unsafe requests without a valid token (419), withcsrfField()/csrfToken()helpers, anXSRF-TOKENcookie for SPAs, and route exemptions. New Securing SSR apps guide.
Container & provider lifecycle. Container services already existed as the global helpers in
helpers.ts; this fills in the rest:- Provider
ready()andshutdown()hooks. Providers grew two optional lifecycle methods beyondregister()/boot():ready()runs once the whole app is up (after every provider'sboot()and the app'sonReadyhooks), andshutdown()runs onapp.terminate()in reverse registration order (LIFO). Both are optional, so plain duck-typed providers keep working. Container.swap(token, factory)/restore(token?). Temporarily replace a binding with a fake for tests — the original binding and any resolved instance are remembered;restore()with no token undoes every swap. Also as theswap/restoreglobal helpers.Container.alias(alias, target). Point a token at another somake("router")resolves through tomake(Router), honoring the target's own sharing. Also as thealiasglobal helper.- Graceful shutdown is now wired.
keel servetraps SIGINT/SIGTERM, stops accepting connections, and runsapp.terminate()(and thus every provider'sshutdown()) before exiting — theonShutdown/terminate()machinery existed but nothing triggered it.
All additive and backward compatible.
@inject-style reflective constructor injection and contextual bindings were intentionally left out — Keel's DI is by convention (a provider/controller constructor receives the container), which keeps the core free ofreflect-metadataand edge-native.- Provider
Changed
encryption.encrypt(value, { expiresIn, purpose })— encrypted values can now self-expire and be bound to a purpose (e.g."password-reset"), verified ondecrypt(token, { purpose }); a wrong/absent purpose or an expired token returnsnull. Backward compatible — tokens made without options decrypt as before.rateLimiternow also emits theX-RateLimit-Resetheader.
0.64.0 — 2026-07-11
Added
OAuth 1.0a social sign-in. Social auth grew a second flow for the older, three-legged providers (Twitter/X, and any OAuth 1.0a API). Every request is HMAC-SHA1-signed with Web Crypto, so it's edge-native like the OAuth2 side:
social.twitter(config)preset, andsocial.driver1(spec, config)/oauth1Driverfor any OAuth 1.0a provider.OAuth1Driver—requestToken()→redirect()→accessToken()/user(), plus a signedget()for profile calls. Returns the same normalizedSocialUser(itstokenis anOAuth1Token).oauth1Signature()— the low-level RFC 5849 HMAC-SHA1 signer, exposed for signing arbitrary provider API requests (verified against the canonical Twitter test vector).- New types
OAuth1Config,OAuth1Token,OAuth1ProviderSpec;SocialUseris now generic over its token type. Documented in the Social authentication guide.
Additive and backward compatible — the OAuth2 presets are unchanged.
0.63.0 — 2026-07-11
Added
A full authentication system. Session and JWT already existed; this adds the rest, all edge-native (Web Crypto +
fetch, no native deps):- Opaque access tokens (
tokens.ts) — revocable, ability-scoped, DB-backed bearer tokens, the stateful counterpart tojwt.createToken(userId, { abilities, expiresIn, name })mints akeel_<selector>.<verifier>token (plaintext shown once);verifyToken,revokeToken,revokeTokens(log out everywhere),listTokens,tokenAllows/tokenDenies,setTokensTable. The split selector/verifier design stores only a SHA-256 hash and needs noRETURNING, so it's portable across every driver and a leaked DB can't mint tokens. Expired tokens self-prune on use. tokenAuth(options?)guard — verifies an opaqueBearertoken, sets the authenticated user, enforces requiredabilities, and exposes the token viatoken()/tokenCan().basicAuth(verify, options?)guard — HTTP Basic auth with aWWW-Authenticatechallenge; the verifier returns a user id,true, or a falsy value.- Social sign-in (
social.ts) — OAuth 2.0 "sign in with…",fetch-based with GitHub/Google/Discord presets andsocial.driver()for any other provider. Returns a normalizedSocialUser;redirect(),exchangeCode(),userFromToken(),user(),oauthState()for CSRF,OAuthError. Keel owns the OAuth dance only — you find-or-create your user and log them in. New Social authentication guide. - Timing-safe credentials —
hash.dummy, a valid hash that never matches, so verifying a missing user costs the same as a wrong password (closes the email-enumeration timing leak). gateAfter(callback)— the after-hook counterpart togateBefore, completing authorization parity (audit or veto a decision after it's made).
All additive and backward compatible. The
Authsession guard,jwt+bearerAuth, and gates/policies are unchanged.- Opaque access tokens (
[0.62.0] — 2026-07-11
Added
- Proxy-aware URL accessors on
request.request.protocol,request.secure,request.host,request.hostname,request.origin,request.fullUrl, andrequest.querystringintrospect the request URL and connection. They honorX-Forwarded-Proto/X-Forwarded-Hostover the raw URL, so an app behind a TLS-terminating proxy or load balancer sees the client's real scheme and host — useoriginto build absolute links andsecureto gate insecure requests. response.back(fallback?)andredirect("back"). Redirect to the request'sReferer, falling back tofallback(default"/") when it's absent — the "return where you came from" shortcut for post-form flows.response.attachment(filename?). Marks the response as a downloadable attachment viaContent-Disposition, emitting both a quoted ASCIIfilenameand an RFC 5987filename*so non-ASCII names survive. Chainable, so pair it withtype().- Encoding & charset negotiation.
request.encoding(encodings)/request.encodings()andrequest.charset(charsets)/request.charsets()complete the content-negotiation set alongside the existingacceptsandlanguagehelpers, using the same q-weight and*rules.
0.61.0 — 2026-07-11
Added
Batteries-included database adapters. Ready-made
Connectionimplementations for the common drivers, so you no longer hand-write theselect/writebridge. Each ships as an optional subpath import and takes your driver instance:@shaferllc/keel/db/d1—d1Connection(env.DB)for Cloudflare D1 (sqlite).@shaferllc/keel/db/pg—pgConnection(client)for any node-postgres-compatible client:pgon Node or@neondatabase/serverlesson the edge (postgres).@shaferllc/keel/db/libsql—libsqlConnection(client)for@libsql/client/ Turso, on Node and the edge (sqlite).
Each adapter duck-types its driver (a minimal structural interface) and imports no driver — so Keel's core stays dependency-free and nothing is bundled until you import an adapter, and you install only the driver you use (a peer, not a Keel dependency). D1 and libSQL return the last insert id natively; Postgres needs a
RETURNING idclause forinsertGetId().
0.60.0 — 2026-07-11
Added
Multiple database connections. The database layer grew from a single global connection to a named registry, so an app can talk to several databases at once — a Postgres primary and a SQLite/D1 cache, a separate reporting warehouse, a per-tenant shard — each with its own dialect. Inspired by the common API behind the Feathers database adapters (register many, route per resource), but kept in Keel's driver-agnostic, edge-safe
Connectionmodel (still no bundled driver):addConnection(name, conn, dialect?)registers a named connection alongside the default;setConnectionstill registers the default (unchanged).db(table, connectionName?)routes a single query to a named connection.connection(name?)returns aConnectionHandle—table()plus a raw, dialect-adjustedselect/writebridge.Model.connection(astatic) puts a whole model — reads, writes, and relations — on a chosen connection.setDefaultConnection(name)switches the default;connectionNames()lists the registered ones;clearConnections()resets (test helper).- New type
ConnectionHandle.
Fully backward compatible:
setConnection+db(table)behave exactly as before (the unnamed default lives under"default"), and connection resolution stays lazy — building a query never throws, only running one does.
0.59.0 — 2026-07-11
Added
Stateless token authentication (JWT + bearer guard). Took the token half of the Feathers authentication API (service, JWT, hook) — the piece Keel was missing next to its session/cookie
Auth— and built it edge-native:jwt— HS256 sign/verify on the Web Crypto API (nojsonwebtoken, no native bindings), signed withconfig('app.key').jwt.sign(payload, opts?)stampsiatand (withexpiresIn)exp, and supportssubject/issuer/audience/secret;jwt.verify()returns the payload ornullfor a malformed, tampered, expired, not-yet-valid, or wrong-issuer/audience token. Only HS256 is accepted —alg: noneand asymmetric algs are refused, closing the JWT algorithm-confusion hole. New typesJwtPayload,JwtSignOptions,JwtVerifyOptions.bearerAuth(options?)— a guard middleware that readsAuthorization: Bearer <token>, verifies it, and makes the token'ssubthe authenticated id, soauth().user()resolves through the registered provider exactly as with sessions. Needs no session store (ideal on Workers).{ optional: true }lets unauthenticated requests through.auth().id()now honors abearerAuth()token (it wins over the session) and reads the request context directly, so token-only APIs work withoutsessionMiddleware().
Username/password login is unchanged —
hash+auth().login()already cover the local flow. OAuth remains out of scope. All additive and backward compatible.
0.58.0 — 2026-07-11
Added
Errors: the full HTTP exception family. Rounded out the built-in exceptions against the Feathers errors API — the set now covers every common status, each with a fixed
statusand a stable machinecode:BadRequestException(400),PaymentRequiredException(402),MethodNotAllowedException(405),NotAcceptableException(406),RequestTimeoutException(408),ConflictException(409),LengthRequiredException(411),TooManyRequestsException(429),ServerErrorException(500),NotImplementedException(501),BadGatewayException(502), andServiceUnavailableException(503) — joining the existingNotFoundException,UnauthorizedException,ForbiddenException, andValidationException.STATUS_TEXTgained labels for the new statuses.Structured error data.
HttpExceptiongained an optionaldatabag (new ConflictException(message, data)) that surfaces in the JSON error body underdata, plus atoJSON()returning the exact rendered body shape ({ error, status, code?, data? }, anderrorsforValidationException) so an exception can be serialized outside the HTTP kernel.All additive and backward compatible.
0.57.0 — 2026-07-11
Added
Application object: Feathers-style ergonomics. Adopted the useful parts of the Feathers Application API onto
Application, all additive:app.configure(fn)— run a(app) => unknownconfigurator and chain. The one-shot inline alternative to aServiceProvider(no register/boot split).app.set(key, value)/app.get(key, fallback?)— app-wide settings store, backed byConfigsoapp.setandconfig().getshare one store.app.on/app.once/app.off/app.emit— app-level events delegating to theEventssingleton (same emitter as the globallisten()helper).- New exported type
Configurator.
All backward compatible.
app.listen/teardownmap to Keel's existing Hono adapter +boot()/terminate(); the registry (use/service) is Keel's container + service broker.
0.56.0 — 2026-07-11
Added
Service broker: params validation & result caching. Six more Moleculer pages checked (validating, caching, metrics, tracing, errors, runner):
- Validating — an action's
paramsschema is validated (and coerced) before the handler; a bad call rejects withValidationException. Bring your own Zod-style schema. - Caching — mark an action
cache: true | { ttl, keys }and give the broker acacher(any KeelCache— memory or Redis); results memoize by action + params (keyslimits the key). No cacher → no-op. - Metrics / tracing — the middleware
localActionseam is the hook; the trace context (requestID/parentID/level/caller) is already on every ctx. Errors — typed broker errors exist pluscreateError. Runner —createService()+broker.start()from boot. All documented rather than added.
All additive and backward compatible.
- Validating — an action's
0.55.0 — 2026-07-11
Added
Service broker: fault tolerance & registry introspection. Two more Moleculer pages checked (fault-tolerance, registry):
- Retry —
call(action, params, { retries: 3 })re-runs the whole call on failure (total attempts = retries + 1);BrokerOptions.retriessets a default. - Fallback —
{ fallback: value }or{ fallback: (err, ctx) => value }returns instead of throwing once every attempt (anderrorhooks) fails. Order: retry → error hooks → fallback → throw. (Timeout was already present.) - Registry introspection —
broker.hasAction(name),listActions(),listServices(),getService(name). - Networking / balancing — clustering is the
Transporterseam (NATS/Redis/ TCP); single-node has one endpoint per action, so cross-node balancing doesn't apply — event group balancing already works viaemit(…, { groups }). Documented rather than added.
All additive and backward compatible.
- Retry —
0.54.0 — 2026-07-11
Added
Service broker: Moleculer-parity events & context. A second parity pass over the broker, drawn from Moleculer's events and context pages:
broadcastLocal— broadcast to every listener on this node (mirrorsbroadcastuntil a real transporter would relay across nodes).- Event groups & patterns — the Events docs now spell out group-based
balancing (
emit(..., { groups })), and subscription keys gain the?single-char wildcard alongside*/**. - Internal events — the broker now emits
$broker.started,$broker.stopped, and$services.changed({ service }payload) that any service can subscribe to. - Event context — event handlers receive
ctx.eventName,ctx.eventType("emit"/"broadcast"), andctx.eventGroups. - Request-tree context — every context now carries
ctx.parentID,ctx.level(depth from 1),ctx.caller(invoking service), andctx.action;ctx.toJSON()returns a log-safe snapshot with no functions or live refs. - Broker middlewares (Moleculer's
middlewares) — pass
middlewares: [...]to wrap every action call and tap broker lifecycle. A middleware'slocalAction(next, action)wraps the handler (they compose, first = outermost);started(broker)/stopped(broker)run duringbroker.start()/stop(). (Service lifecycle hooks and per-servicethis.loggerwere already in place — the other two pages checked.)
All additive and backward compatible.
0.53.0 — 2026-07-11
Added
- Route config / metadata. Attach arbitrary data to a route or group with
.config({ … })and read it in the handler or route middleware viarequest.route.config— per-route flags like an auth scope, rate tier, or layout choice. Group config merges into every route, with a route's own keys winning. The matched-route context is now set before a route's middleware, so route/group middleware can branch onrequest.route. See docs/routing.md.
0.52.0 — 2026-07-11
Added
Service broker: Moleculer-parity actions & services. The broker grows the pieces a service-oriented app leans on, drawn from Moleculer's services and actions pages:
- Full action definitions — an action may now be
{ handler, visibility, timeout, hooks }instead of only a bare handler. - Action hooks —
before/after/errorat the service level (keyed by action name, with*,"a|b", and glob matching) or inline per action, run in Moleculer's order (before: wildcard → named → action; after/error reversed). - Visibility —
published/public/protected/private;privateactions are hidden fromcallbut reachable internally viathis.actions.x. mcall— batch calls as an array or keyed map, withsettledfor per-call{ status, value | reason }.- Mixins — reusable schemas merged by type (settings/metadata deep-merge,
actions/events/methods/hooks by key, lifecycle hooks chained), with a
merged()hook; the service's own schema wins on conflict. - Dependencies —
dependenciesgates a service'sstartedhook on other services being registered;broker.waitForServices()/this.waitForServices()wait explicitly. - Richer context —
ctx.locals(per-call scratch),ctx.headers(transient, not propagated), andctx.requestID(correlation id threaded through the request tree);metadataon the service instance; event listeners may declare agroupthatemit'sgroupsoption targets.
All additive and backward compatible — a bare-handler action, function-shorthand event, and hook-less service behave exactly as before.
- Full action definitions — an action may now be
0.51.0 — 2026-07-11
Added
- Response header helpers. The
responseaccessor gainsheaders({...})(set several at once),getHeader(name), andhasHeader(name)— so middleware can inspect and conditionally set what a handler already put on the response (e.g. a defaultcache-control). See docs/request-response.md. - Design principles documented in docs/architecture.md — edge-safe / driver-agnostic and explicit-over-implicit spelled out alongside the existing container-first and thin-over-clever tenets.
0.50.0 — 2026-07-11
Added
- Parameterized providers. Service providers — Keel's plugin system — now take
options at registration:
app.register(RateLimitProvider, { max: 100 }), typed viaServiceProvider<{ max: number }>and read asthis.options. The same provider class can register more than once with different options, so a provider is now genuinely reusable. Backward compatible — options default to{}. (Keel providers stay un-encapsulated by design; per-request scoping is middleware.) See docs/providers.md.
0.49.0 — 2026-07-11
Added
- Broadcasting. Push events to clients in real time over named channels on a
pluggable
Broadcaster— the core owns no socket, so point it at Pusher/Ably (fetch), a Cloudflare Durable Object, or the built-inMemoryBroadcaster(in-process fan-out, for tests).broadcast(channels, event, payload)publishes;channelAuth("orders.{id}", (user, params) => …)gates private and presence channels (returnfalse/true/member-data), resolved byauthorizeChannelat your socket endpoint — composing withauth()and authorization.MemoryBroadcaster.subscribe()fans out in-process (Durable Object / SSE). See docs/broadcasting.md.
0.48.0 — 2026-07-11
Added
- Task scheduling. Declare recurring work with a fluent cadence —
schedule(new PruneSessions()).daily(),schedule(() => sync()).everyFiveMinutes(),schedule(job).cron("0 9 * * 1")— then run the scheduler once a minute from a single trigger. Cadences:everyMinute…everyThirtyMinutes,hourly/hourlyAt,daily/dailyAt("13:30"),weekly/monthly, or any 5-fieldcron().scheduler().runDue(now)runs everything due (to the minute);due()lists without running. Built-in cron matcher (*, lists, ranges, steps, and standard dom/dow semantics) — wire it to Cloudflare Cron Triggers'scheduled()handler or a Node interval. A task is aJobor a function. See docs/scheduling.md.
0.47.0 — 2026-07-11
Added
- File storage. A driver-agnostic storage layer on a pluggable
Disk— the core imports no filesystem or SDK, so it runs on Node and the edge.setDisk(disk, name?)thenstorage(name?):put(string / bytes / ArrayBuffer) /get/getText/exists/delete/list(prefix?)/url.MemoryDiskis a full in-memory driver and the default, sostorage()works in tests with no setup; point disks at the local filesystem (Node), S3 (fetch), or a Cloudflare R2 binding (adapters in the docs). Register several disks by name and select withstorage("s3"). See docs/storage.md.
0.46.0 — 2026-07-11
Added
- Authorization — gates & policies. Where
auth()is who you are, this is what you're allowed to do.define(ability, (user, ...args) => …)registers a gate;policy(Model, PolicyClass)groups abilities as methods on a plain class, andcan("update", post)routes toPostPolicy.update(user, post)by the argument's class.can/cannotreturn booleans;authorizethrows a403;canFor/authorizeForcheck a specific user;gateBeforeshort-circuits every check (admin bypass). The current user resolves fromauth().user()by default (overridable withsetUserResolver); unknown abilities deny. See docs/authorization.md.
0.45.0 — 2026-07-11
Added
- Test client.
testClient(app)injects requests into your app — no server, no port — and returns aTestResponsewith verb helpers (get/post(JSON body) /put/patch/delete) and fluent, chainable assertions (assertStatus/assertOk/assertJson/assertText/assertHeader/assertRedirect). The response body is pre-buffered, so reads are synchronous and repeatable. Accepts anApplication, anHttpKernel(to register global middleware first), or anyrequest()-able. Edge-safe — the same fetch-style injection Keel's own suite uses, minus the boilerplate. See docs/testing.md.
0.44.0 — 2026-07-11
Added
- Declarative request validation.
validateRequest({ body, query, params })is middleware that validates the request before the handler runs — rejecting a bad request with a422ValidationException(errors from every part aggregated, keyedbody.field/query.field/params.field) so the handler only ever sees valid input.validated(part)returns the parsed, typed value. Built on the same schema-agnosticvalidate()engine (bring your own Zod-style schema). See docs/validation.md.
0.43.0 — 2026-07-10
Added
- Per-request logging.
requestLogger()middleware binds a child logger with a generatedreqIdto each request, so every log line within a request correlates. It logs request start/completion (method, path, status, ms) by default; options forgenReqId, reusing an incomingidHeader(distributed tracing), and disabling the auto lines.requestLog()reaches the current request's logger anywhere (falls back to the base logger outside a request). - Log redaction.
new Logger({ redact: ["password", "req.headers.authorization"] })replaces matched values (top-level keys or dot paths) with"[redacted]"without mutating the logged object; inherited by child loggers. See docs/logger.md.
0.42.0 — 2026-07-10
Added
- Application lifecycle hooks & graceful shutdown.
onReady(hook)runs after boot (or immediately if already booted);onShutdown(hook)registers cleanup andterminate()runs every shutdown hook newest-first (LIFO) — close DB/Redis connections, flush queues onSIGTERM.terminate()is idempotent and a throwing hook can't strand the rest (first error re-thrown after all run).Router.onRoute(hook)observes route registration (fired live and replayed for existing routes). Available asApplicationmethods and global helpers. Request-lifecycle hooks remain middleware. See docs/hooks.md.
0.41.1 — 2026-07-10
Added
- Coded errors —
createError. Mint a reusable, codedHttpExceptionsubclass in one line:createError("E_FUNDS", "Balance too low: need %s", 402).%splaceholders fill from the constructor arguments, the result renders through the default path (withcodein the JSON body) and passesinstanceof HttpException. The built-in exceptions now carry stable codes too (E_NOT_FOUND,E_UNAUTHORIZED,E_FORBIDDEN,E_VALIDATION), socodesurfaces without any work. Also documented: serving over HTTP/2 needs no framework code — it's a transport concern handled by the edge platform, a reverse proxy, or a@hono/node-servernode:http2option. See docs/errors.md and docs/hono.md.
0.41.0 — 2026-07-10
Added
- Service Broker. A Moleculer-style backbone for service-oriented code.
Register services (a name plus
actionsandevents) with aBroker, then reach them by string name:broker().call("users.get", { id })runs an action;broker().emit("user.created", user)fans an event out to every listener (balanced), orbroadcastto all. Actions receive aContextand call other actions viactx.call, threadingmeta(auth, trace ids) down through nested calls. Services supportversionprefixes (v2.users.*),settings, boundmethods, glob event subscriptions (user.*/user.**), lifecycle hooks (created/started/stopped), and per-calltimeout. Clustering lives behind a pluggableTransporterseam — the defaultLocalTransporteris a single-node no-op, so the core imports no network client and stays edge-safe.broker()/setBroker()manage the default instance, mirroringredis()/setRedis(). See docs/broker.md.
0.40.1 — 2026-07-10
Added
- Raw request-body accessors.
request.text(),request.arrayBuffer(), andrequest.blob()read the body for content typesjson()/all()don't handle — XML, CSV, protobuf, msgpack, or any custom format — which you then parse yourself. Keel keeps body parsing explicit (no content-type parser registry): you call the accessor you want. See docs/request-response.md.
0.40.0 — 2026-07-10
Added
- Request decorators. Attach named, computed values to the current request
—
request.user/tenant/locale— resolved lazily and memoized for the life of the request.decorateRequest(name, resolver)registers a resolver (sync or async),decorated(name)reads it (computed once, then cached),setRequestValue(name, value)sets it imperatively (e.g. from middleware), andhasRequestDecorator(name)checks. The per-request memo is keyed off the context via a WeakMap, so nothing leaks between requests. (Decorating the app is already the container's job.) See docs/decorators.md.
0.39.0 — 2026-07-10
Added
- Redis. A Redis integration on a pluggable
RedisConnectiondriver — the core imports no client, so it runs on Node and the edge.setRedis(driver)thenredis():get/set(with{ ex }/{ px }TTL) /del/exists/incr/decr/expire/ttl/keys/flushAll, plusgetJson/setJsonand arememberread-through cache.MemoryRedisis a full in-memory driver (TTL-aware) and the default, soredis()works in tests with no setup; point it at Upstash (fetch), ioredis, or node-redis in production.redisStore()adapts it into aCacheStoreso the cache can be Redis-backed. See docs/redis.md.
0.38.0 — 2026-07-10
Added
- ORM maturity — timestamps.
static timestamps = trueauto-managescreated_at/updated_at(both on insert, onlyupdated_aton update); column names are overridable viacreatedAtColumn/updatedAtColumn. - Pagination.
Model.paginate(page, perPage)anddb(table).paginate(...)return aPaginated<T>—{ data, total, perPage, currentPage, lastPage }. - Aggregates & single values. Query builder
sum/avg/min/max, plusvalue(column)(one column of the first row) andpluck(column)(a column across all rows). - More query clauses.
whereBetween,whereNotIn,whereLike, andlatest()/oldest()ordering by a timestamp column. - Find-or-create & convenience writes.
Model.firstOrCreate(match, values),Model.updateOrCreate(match, values), instanceupdate(attrs)(fill + save), andrefresh()(re-read the row). See docs/models.md. - Full Vite support. A first-class frontend build, the way modern full-stack
frameworks do it. A
keelVite()plugin (new@shaferllc/keel/viteentry) wiresvite.config.ts— manifest, output, entrypoints,base— and writes apublic/hotmarker while the dev server runs; optionalreloadglobs full-reload the browser on server-view changes. TheViteservice renders the<script>/<link>tags for your entrypoints and resolves asset URLs, flipping automatically between the dev server (with HMR) and the hashed, preloaded production manifest. HelpersviteTags/viteAsset/viteReactRefreshslot straight into a JSX<head>;scriptAttributes/styleAttributes, a CDNassetsUrl, React Fast Refresh, and an edge-safeuseManifestpath are all covered. Tag generation is pure and runs on the edge. See docs/vite.md.
0.37.1 — 2026-07-10
Fixed
make:*stubs import the resolvable specifier. Generated files now import from@shaferllc/keel/core(the published entry point) instead of the internal@keel/corealias, so scaffolded code compiles in a real project.Connection.selectis no longer generic — it returnsPromise<Row[]>, so a driver implementation no longer needs anas Connectioncast.db<T>()still types results (the builder casts internally).hash.verifynever throws. A malformed hash (right prefix but a non-numeric iteration count or invalid base64) now returnsfalseinstead of throwing.- Sessions handle non-Latin1 values. Cookie serialization is UTF-8-safe, so
storing emoji or non-Latin text no longer crashes the response (
btoathrow). router.url()fills repeated params. A:paramappearing more than once in a path is now fully substituted, and won't match inside a longer param name.
0.37.0 — 2026-07-10
Added
- Transformers. A presentation layer between your models and your JSON:
subclass
Transformer<T>, define onetransform(), and getitem/collection/document.when(condition, value)includes a field only when a condition holds — omitting the key entirely rather than leakingnull— with amergeWhencounterpart for groups of fields and thunks for deferred values.whenLoaded(model, name, transformer)embeds a relation only if it was eager-loaded, so a transformer never fires a surprise query.document()wraps the payload under a key (databy default) with top-levelmeta. Edge-safe; depends on nothing but the value you hand it. New generatorkeel make:transformer. See docs/transformers.md. - Templates. A string templating engine:
{{ }}/{{{ }}}interpolation,{{-- comments --}}, and@-tags —@if/@elseif/@else,@each(with$loop),@include/@includeIf,@set, layouts (@layout/@section/@yield), components with slots (@component/@slot), filters ({{ name | upper }}), globals, and@dump.templates().register(name, src)thenrender(name, state). Unlike engines that compile to a function, Keel interprets templates against a safe expression evaluator instead ofeval/new Function, so the same templates run on Node and on Workers. See docs/templates.md.
0.36.0 — 2026-07-10
Added
- Notifications. Send a message to one or many recipients over pluggable
channels:
notify(user, new InvoicePaid(4200)). ANotificationdeclaresvia()(channels) and per-channel content (toMail,toArray). Built-in channels:MailChannel(via the mailer, routed byemailorrouteNotificationFor),DatabaseChannel(insertstoArrayinto a table), andArrayChannel(for tests). SetshouldQueue = trueto deliver from a queued job. This is where the mail and queue layers compose — a custom channel is onesendmethod. New generatorkeel make:notification. See docs/notifications.md.
0.35.0 — 2026-07-10
Added
- Queues & jobs. Move slow work off the request path:
dispatch(new SendWelcome(id))places aJob(or a plain function) on a queue, and a pluggableQueueDriverdecides when it runs. Built-in drivers:SyncDriver(runs immediately — the default),MemoryDriver(defers;work()drains it FIFO, inspect.jobs).dispatchtakes{ delay, queue }options; a custompush-only driver is the seam for a real broker (e.g. Cloudflare Queues). New generatorkeel make:job. Core imports no broker, edge-safe. See docs/queues.md.
0.34.0 — 2026-07-10
Added
- Mail. A fluent, edge-safe mailer:
mail().to().subject().html().send(), with a pluggableTransport(like the databaseConnection). Register a default withsetMailer(transport, { from }). Built-in transports:ArrayTransport(collects to.sent, the default and ideal for tests),LogTransport(logs instead of delivering), andfetchTransport({ url, headers, body })for provider HTTP APIs (Resend/Postmark/Mailgun) overfetch— the core imports no SDK.send()validates recipient/subject/body/ from. See docs/mail.md.
0.33.0 — 2026-07-10
Added
- Model attribute casts.
static casts = { active: "boolean", meta: "json", joined_at: "date" }round-trips columns as real JS types — cast when read (from the database orfill) and back to storable primitives on write. This is what letsboolean/jsoncolumns bind cleanly on drivers that reject JS booleans and objects. Types:int,float,boolean,string,json/array,date. - Mass-assignment guarding.
static fillable(allowlist) orstatic guarded(denylist) filter the attributescreate()andfill()accept, so untrusted request data can't over-post protected columns.forceFill()bypasses it deliberately. With neither declared, behavior is unchanged (backward compatible). See docs/models.md.
0.32.0 — 2026-07-10
Added
- Factories & seeders.
factory(Model, (f, i) => ({ ... }))builds model attributes with a built-in, dependency-freeFaker(names, emails, words, numbers, uuids — seedable for deterministic runs). Call.make()(unsaved) or.create()(persisted),.count(n)for batches, and override attributes inline.Seederclasses have arun()and cancall([OtherSeeder])to compose;seed(DatabaseSeeder)runs one. New generatorskeel make:factoryandkeel make:seeder. Edge-safe (no external faker library). See docs/factories.md.
0.31.0 — 2026-07-10
Added
- Model relationships. Define relationships as methods on your model:
hasMany/hasOne/belongsTo/belongsToMany, with conventional foreign keys (user_id) you can override. Relations are awaitable (await user.posts()), expose.query()to drop to the builder, andModel.load(models, "posts", "roles")eager-loads with onewhereInper relation (fixes N+1).belongsToManyreads through a pivot table and offersattach/detach/sync. Loaded relations stay out ofsave()and serialize throughtoJSON(). Runs entirely on the query builder — no JOINs, edge-safe. See docs/models.md.
0.30.0 — 2026-07-10
Added
- Migrations. A fluent schema builder (
schema.createTable(name, t => { t.id(); t.string("email").unique(); t.timestamps(); })) and aMigratorthat runs{ name, up, down }migrations against your connection, tracking applied ones in amigrationstable (up/down/ran, batched). Dialect-aware SQL (sqlite/mysql/postgres). See docs/migrations.md.
0.29.0 — 2026-07-10
Added
- Active-record
Model. SubclassModel, set atable, and get staticfind/findOrFail/all/first/where/createplus instancesave(insert or update),delete,fill, andtoJSON. Built on the query builder, so it runs on any registered connection (edge-safe).Model.query()drops to the raw builder for richer queries. See docs/models.md.
0.28.0 — 2026-07-10
Added
- Database query builder. A driver-agnostic, parameterized query builder:
db(table).where().orderBy().limit().get()/first()/count()/exists(), pluswhereIn/whereNull/orWhere, andinsert/insertGetId/update/delete. Runs through a two-methodConnectionyou register withsetConnection(conn, dialect)— works with D1, Neon/Postgres, PlanetScale, Turso, better-sqlite3,pg. The core imports no driver (edge-safe). See docs/database.md.
0.27.0 — 2026-07-10
Added
- Authentication. Session-based auth:
auth().login(id)/logout()/check()/guest()/id()/user(), a pluggable user provider viasetUserProvider(), and anauthGuard({ redirectTo? })middleware (401 or redirect). Built on the session + hash primitives. See docs/authentication.md.
0.26.0 — 2026-07-10
Added
- Logger. A leveled logger (
logger().debug/info/warn/error) with structured JSON output (pretty in debug), a level threshold fromconfig('logger.level'), andlogger().child({ … })for bound fields. See docs/logger.md.
0.25.0 — 2026-07-10
Added
- Rate limiting.
rateLimiter({ max, window, key, message })— a fixed-window limiter middleware with per-key buckets (client IP by default), the standardX-RateLimit-*/Retry-Afterheaders, and429on exceed. In-memory store (pluggable for distributed limiting). See docs/rate-limiting.md.
0.24.0 — 2026-07-10
Added
- Password hashing.
hash.make(password)(PBKDF2-SHA256, self-describing),hash.verify(hashed, password)(timing-safe), andhash.needsRehash(). - Value encryption.
encryption.encrypt(value)/encryption.decrypt(token)(AES-GCM, keyed byconfig('app.key');decryptreturnsnullon tamper). - Both use the Web Crypto API — edge-safe, no native bindings. See docs/hashing.md.
0.23.0 — 2026-07-10
Added
- Debugging helpers.
dump(...values)prints to the console and returns its first argument (inline-friendly);dd(...values)dumps to the browser and halts the request via a self-rendering exception. Both edge-safe. See docs/debugging.md.
0.22.0 — 2026-07-10
Added
- Self-handling & reportable exceptions. An exception with a
handle(c)method renders itself; one with areport()method has it called (and awaited) before rendering — for logging/metrics, without masking the error. - Error codes.
HttpExceptionnow carries an optionalcode(e.g.E_UNAUTHORIZED), included in the JSON error body. See docs/errors.md.
0.21.0 — 2026-07-10
Added
- URL builder.
router.url(name, params, { qs })now takes a query string. - Signed URLs.
router.signedUrl(name, params, { qs, expiresIn })produces a tamper-proof link (HMAC-SHA256 via Web Crypto, keyed byconfig('app.key'));router.hasValidSignature()verifies the current request. Edge-safe. See docs/url-builder.md.
0.20.0 — 2026-07-10
Added
- Named middleware registry.
router.named({ auth, admin })registers middleware by name; reference it with.use("auth")/.middleware([...])on routes, groups, and resources. Names resolve when the app builds (unknown names throw). Raw functions still work everywhere. See docs/middleware.md.
0.19.0 — 2026-07-10
Added
- File uploads.
request.file(name),request.files(name), andrequest.allFiles()return web-standardFileobjects (edge-safe, no temp dir). The parsedFormDatais cached per request, so file access andrequest.all()coexist. - Content negotiation.
request.accepts([...]),request.types(),request.language([...]),request.languages(). - Request meta.
request.hasBody(),request.headers(),request.ips(). - Response helpers.
response.type(mime),response.append(name, value),response.removeHeader(name), and the guardsresponse.abortIf(cond, …)/response.abortUnless(cond, …).
0.18.0 — 2026-07-10
Added
- Static file server.
serveStatic(options)serves files from a directory (defaultpublic/) before your routes, withETag/Last-Modified/304handling,Cache-Control(maxAge/immutable), a dot-file policy (ignore/deny/allow), per-fileheaders(), and path-traversal protection.node:fsis imported dynamically so the core still loads on the edge. See docs/static-files.md.
0.17.0 — 2026-07-10
Added
- Cache. A memory-backed cache with TTLs and the
rememberpattern:cache().get/put/has/forget/pull/flush,cache().remember(key, ttl, fn), andrememberForever. Pluggable via theCacheStoreinterface (swap in Redis/KV). See docs/cache.md.
0.16.0 — 2026-07-10
Added
- Events. A tiny event emitter for decoupling —
emit(event, payload)andlisten(event, fn)global helpers, plusevents()foronce/off/listenerCount/clear. Listeners may be async and are awaited in order. See docs/events.md.
0.15.0 — 2026-07-10
Added
- Sessions. A cookie-backed session store (edge-safe, no external service):
session().get/put/has/forget/pull/increment/clear/all, plus flash messages (session().flash()/session().flashed()) that survive one request. Enable withsessionMiddleware()in your HTTP kernel. See docs/sessions.md.
0.14.0 — 2026-07-10
Added
- Request input API.
request.all(),request.input(key, fallback?),request.only([...]),request.except([...])(merge query + parsed body), plusrequest.ip(). - Cookies.
request.cookie(name?),response.cookie(name, value, options), andresponse.clearCookie(name). - Response helpers.
response.send(data)(objects → JSON, else text) andresponse.abort(message, status)(throws anHttpException). - See docs/request-response.md.
0.13.0 — 2026-07-10
Added
- Single-action controllers.
router.post("/publish", [PublishPost])calls the controller'shandlemethod. - Lazy-loaded controllers.
[() => import("../Controllers/X.js"), "index"]— the controller is imported only when its route is first hit. - Richer resources.
RouteResourcegained.as(name),.params({ … }), and.use(actions, mw);router.resource("posts.comments", C)nests resources (/posts/:post_id/comments/:id). make:controller --resourcegenerates a controller with all seven RESTful actions.- See docs/controllers.md.
0.12.0 — 2026-07-10
Added
- Inertia.js server adapter.
inertia("Page", props)androuter.on(path).renderInertia(...)— full HTML on first load, JSON page object on XHR navigations, asset-version 409s, and partial reloads. Configure anInertiainstance (root view + version) in a provider. See docs/inertia.md. - Domain / subdomain routing.
route.domain(pattern)andgroup(...).domain(":tenant.example.com"), dispatched byHost; subdomain params viarequest.subdomain(name). - Route matchers & global constraints.
router.matchers.number()/uuid()/slug()/alpha(), a globalrouter.where(param, matcher), group.where(), and the{ match }matcher form. - Brisk-route helpers.
on().renderInertia(),on().redirectToPath(), andon().redirectToRoute(name, params, { qs }). - Current route.
request.route({ name, pattern, methods }) andrequest.routeIs(name). .use()middleware alias on routes and groups.
Tests
- Suite grown to 45 tests; ~99% line coverage maintained.
0.11.0 — 2026-07-10
Added
- First-class routing. The router gained a fluent API:
- Named routes +
router.url(name, params)for URL generation. - Route groups —
router.group(cb).prefix().middleware().as(). - Resource routes —
router.resource(name, Controller)with.only()/.except()/.apiOnly(). - Per-route middleware —
route.middleware([...]). - Param constraints —
route.where("id", /\d+/). router.on(path).redirect(to)/.render(Component)convenience routes.router.any()androuter.route(methods, path, handler).keel routesnow lists verbs and route names.
- Named routes +
Changed
RouteDefinition.method→methods: Method[](routes can match multiple verbs); route defs also carryname,middleware, andwheres.
0.10.0 — 2026-07-10
Added
- Request validation.
validate(schema, data?)parses input (the JSON body by default) and returns typed data, or throws aValidationExceptionthat the kernel renders as a 422 with per-fielderrors. Schema-agnostic — works with any Zod-stylesafeParseschema, so the framework doesn't bundle a validation library. See docs/validation.md.
0.9.0 — 2026-07-10
Added
- Static response routes. Pass a ready-made response as a handler, no
closure:
router.get("/health", json({ status: "ok" })). The router clones the response per request. (Dynamic responses that read the request still use a closure, sinceparam()etc. run per request.) responseaccessor. Mirrorsrequest:response.json(),response.text(),response.html(),response.redirect(), plus chainableresponse.status(code)andresponse.header(name, value).
Changed
json(),text(),html(), andredirect()now work outside a request too (returning a plainResponse), which is what makes static-response routes possible. Inside a handler they still build on the context.
0.8.0 — 2026-07-10
Added
requestaccessor. A flat view of the current request/response —request.method,request.path,request.url,request.status, plusrequest.header(),request.param(),request.query(),request.json(), andrequest.raw. Write`${request.method} ${request.path} → ${request.status}`in a logger without touchingc.
Changed
requestis now this accessor object rather than a function returning the raw Request; userequest.rawfor the underlyingRequest.
0.7.0 — 2026-07-10
Added
- Global container helpers.
bind(),singleton(),instance(),make(), andbound()operate on the active application, so you can register and resolve services from anywhere withoutthis.app— e.g.bind("clock", () => new Date())andmake("clock"). Thethis.app.*methods still work.
With this, Keel's whole surface is reachable as flat, easy-to-remember helpers:
config · view · json/text/html/redirect · param/query/body ·
bind/singleton/instance/make · app.
0.6.0 — 2026-07-10
Added
- Request & response helpers.
json(),text(),html(),redirect(),param(),query(),header(),body(),request(), andctx()reach the current request without threading the context — writejson({ id: param("id") })instead ofc.json({ id: c.req.param("id") }). Backed by async-context storage the HTTP kernel enables per request. Takingcexplicitly still works.
0.5.0 — 2026-07-10
Added
- Error & exception handling. Throw
HttpException(orNotFoundException,UnauthorizedException,ForbiddenException,ValidationException) anywhere and the HTTP kernel renders the right response — JSON or HTML byAccept, a readable stack-trace error page whenapp.debugis on, and hidden internals for unexpected 500s in production. Unmatched routes become a tidy 404. Customize viakernel.onError(handler)or by overridingrenderException. See docs/errors.md.
0.4.0 — 2026-07-10
Added
- Global
view()helper. Render a view component in one call:view(WelcomePage, { appName })— props are type-checked against the component, and it returns a full HTML document.view(HomePage)works for components with no props. Sugar over theViewservice, matchingconfig().
0.3.0 — 2026-07-10
Added
- Global
config()andapp()helpers. Read configuration from anywhere withconfig("app.name")/config("app.port", 3000)— no need to resolve the container by hand.app()returns the active application. Both resolve against the application registered automatically on construction. - Published as
@shaferllc/keel. The framework is now a proper npm package with a real build (compiled JS +.d.tsindist/). Apps install it withnpm install @shaferllc/keeland import from@shaferllc/keel/core, so they receive core updates throughnpm update.
Changed
- Documentation and copy no longer describe Keel by comparison to other frameworks — it stands on its own.
0.2.0 — 2026-07-10
Views, and a core that runs on the edge.
Added
- View layer — a
Viewservice that renders Hono JSX components to HTML. Views live inresources/views/; layouts are just components. Platform-neutral, so the same views run on Node and Cloudflare Workers. See docs/views.md. keel/corepackage export — the framework core is now installable by other apps (import { Application } from "keel/core"). Onlysrc/coreships in the published package.
Changed
- Workers-safe core —
Applicationno longer statically imports Node built-ins or dotenv; they're loaded dynamically only when filesystem config discovery runs.boot(providers, { discoverConfig: false, config })lets you configure inline on runtimes without a filesystem (e.g. Cloudflare Workers).
0.1.0 — 2026-07-10
The first release: the MVP core. Enough of a framework to build and serve a real application.
Added
- Service container —
bind/singleton/instance/make, with string, symbol, and class tokens and auto-construction of unbound classes. - Application kernel — loads
.env, auto-loadsconfig/*.ts, and runs the service-providerregister()→boot()lifecycle. - Configuration — dot-notation
Configrepository plus a type-coercingenv()helper. - Service providers —
ServiceProviderbase class and abootstrapprovider list. - Routing — a
Routerfacade over Hono; handlers may be closures or[Controller, method]tuples resolved from the container. - HTTP kernel — global middleware stack that compiles routes onto Hono,
served by
@hono/node-server. Ships with a request-logging middleware. - Console (
keel) —serve,routes, andmake:controller,make:provider,make:middlewaregenerators with an overwrite guard. - Documentation — getting started, container, providers, configuration, routing, middleware, console, and architecture guides.