Changelog
All notable changes to this project are documented here. The format follows Keep a Changelog, and the project follows Semantic Versioning.
Unreleased
[0.11.0] — 2026-10-30
Added
-
Start a project from an example (#528,
@setu-ts/cli).setu new <name> --example <app>(alsonpm create @setu-ts <name> -- --example <app>) downloads an offered example from the CLI’s GitHub release tag and rewrites it into a standalone Node project. Dry-run fetches nothing; failed downloads write nothing. See Examples. -
node-redis as a supported Redis client (M117, #525, 0.11.0,
@setu-ts/common, the Redis-backed plugins,@setu-ts/telemetry-plugin,@setu-ts/cli).fromNodeRedis(client, options?)wraps a node-redis (redisv5/v6) client so every Redis-backed plugin’sclientoption takes it: cache, queue, Redis Streams messaging, the realtime backplane, the scheduler lock, idempotency andRedisRateLimitStore. The adapter (NodeRedisAdapter, typed overNodeRedisClientLike, optionsNodeRedisAdapterOptions) bounds each command with its owncommandTimeoutMs(default 15 s), because node-redis does not bound a written command; connects lazily; keeps an'error'listener attached; and turns RESP3 map replies back into RESP2 shapes. No package depends onredis; ioredis stays the default.isConnectionFaultrecognizes node-redis’s outage errors, so they answer503as ioredis outages do.instrumentations.redistraces node-redis through@opentelemetry/instrumentation-redis(^0.69.0, optional peer) on Node;fromNodeRedisalso takes aNodeRedisClientFactory, sorediscan load on the first connect, after the instrumentation is registered.setu new/setu addtake--redis-client ioredis|node-redis; a node-redis project wires each plugin through a generatedsrc/node-redis.ts, and a workspace recordsredisClient. See Using node-redis and upgrading. -
Workspace build, test and deploy (M122, #522, 0.11.0,
@setu-ts/cli,@setu-ts/common).setu workspace buildbuilds one image per member, tagged with the commit.setu workspace deploybuilds, loads into akind-*cluster or pushes to--registry, applies one Secret per member fromk8s/secrets/<member>/.env(names from.env.example; server-side, base64data:), appliesk8s/members.yamlwith the namespace and images filled in, and waits for every rollout; it refuses first, naming every missing variable, and--dry-runshows the plan. New roots getbuildanddeployscripts, andteston npm and Bun (Deno:deno task --recursive test, since a recursive root task would call itself). Generated Deployments read the Secret throughenvFrom, and Compose services read each member’s.env— a full-stack member’sSESSION_SECRETwas set by neither. The optionalenv_fileneeds Docker Compose 2.24.0 or later.parseEnvmoves from config-plugin into@setu-ts/common. See deployment and upgrading. -
Local diagnostics on Node (M126, #521,
@setu-ts/runtime). The explicitly activated diagnostics connector can bind on Node as well as Deno, using a private IPv4-loopbacknode:httpserver. Request-target spellings are preserved, duplicate singleton lines are refused, parser errors answer a fixed empty400, bind conflicts reject startup, and shutdown destroys open connections; the server evicts oldest-idle sockets at 32 and routes Expect through the ordinary request path. Bun and Workers continue to refuse this transport. -
Session and account lifecycle (M124, #520, 0.11.0,
@setu-ts/auth-plugin).signIn: { providers: [] }is accepted withoutmfaorpasskeys, so a password-only application getsrefreshPrincipal’s per-request re-check (V10-65).requireAuth({ signInPage })(RequireAuthOptions) answers an unauthenticated browser navigation with302to the sign-in page carryingreturnTo, and every other request with the existing401; both answers appendAccept,Sec-Fetch-ModeandX-Requested-WithtoVary(V10-66).signIn.sessionStamp(SessionStamp) reads a stamp from the application’s user record: its SHA-256 digest is recorded at sign-in and compared on every request in constant time, so changing the stamp ends every session of that user on both session strategies; passkey registration admits a session only through the same re-checks, so an ended session cannot enrol a credential and mint a new one (itsmayRegisterpolicy now receives that re-checked principal, carryingclaims.amr).IAuthSessionService.current()stays the un-re-checked snapshot — authorize fromctx.request.user.safeReturnTois exported for an application’s own login handler. See ending every session of a user and upgrading. -
Full-stack broker configuration (M121, #519, 0.11.0,
@setu-ts/cli). Full-stack scaffolds and workspace members render messaging and queue transport options with config-backed connection reads and the selected clients.setu add messaging/queuereads workspace transports and accepts standalone--broker/--queue, preserving existing values and printing exact manual guidance.setu add react-routerrefuses non-full-stack projects with the command to create one. See upgrading. -
Styled app shells and views (M123, #517, 0.11.0,
@setu-ts/cli). Full-stack scaffolds include Tailwind 4, neutral shadcn theme tokens, copied MIT Button/Card, a light/dark/system toggle and a fixed header/sidebar with scrolling content.components.jsonsupports adding more components; class names combine with shadcn’scnpackage, andapp/lib/utils.ts(classNames) is no longer generated.setu add viewaddshono, JSX options, Tailwind CLI build/watch scripts, stylesheet, layout,/uiexample and static serving, preserving and reporting existing choices. Workers receives the dependency/JSX/example pieces only. See upgrading and server-rendered view styling. -
npm configuration as a build secret (M120b, #516, 0.11.0,
@setu-ts/cli). Every generated install step (Node, Bun and Deno; workspace and standalone) mounts an optional BuildKit secretnpmrcat/root/.npmrc, and the generated Compose file passes it fromNPMRC, so a private registry token or npm settings reach the install without entering an image layer. Without the secret, builds are unchanged. Generated Dockerfiles now need BuildKit; the deprecated legacy builder refusesRUN --mount. A one-member workspace’s generateddocker/compose.yamlno longer renders an emptyenvironment:key, which Compose refused for the whole file. See deployment and upgrading. -
Production builds (M120, #515, 0.11.0,
@setu-ts/cli).setu buildchecks wiring and bundles one Node service, including workspace libraries and migration runners. Runtime npm packages remain external; runtime imports of devDependencies fail. Generated Node starts now require a build; use start:dev for source development and start:debug for debugging. Node and Bun full-stack starts stop rebuilding. Standalone and workspace images install production dependencies and build frontend artifacts in the image. Bun workspaces select the hoisted linker so members resolve shared libraries; library generation safely plans the setting for older workspaces. Deno images build a full-stack frontend on a glibc stage, and a Deno workspace with a full-stack member and a library no longer fails to start read-only (the image switches the root’snodeModulesDirto"manual"). Generated.dockerignorefiles exclude.npmrc. Every CLI command now refuses a manifest that is itself a symbolic link (naming the link) instead of reading through it. See upgrading. -
Custom logger transport (#512, 0.11.0,
@setu-ts/logger-plugin).LoggerPlugin({ transport: 'custom', logger })wraps an applicationILoggerwith level filtering, metadata redaction, request logging and trace correlation. Logger/transport mismatches throw a namedLoggerConfigurationErrorat construction; child adapters keep the controls. -
Scaffold logger choice (#512, 0.11.0,
@setu-ts/cli).setu new --logger console|pinoandsetu generate app --logger console|pinoconfigure templates with logging. The prompt, help and dry-run report the choice; Pino on Cloudflare Workers is refused by name. -
setu checkandsetu sync(M118, #510, 0.11.0,@setu-ts/cli).setu check [--dir] [--strict]is a read-only wiring gate: it exits1when a seam artifact is skipped, a barrel does not list an artifact in its directory or lists one that is gone,setu.config.tsdoes not reference a barrel on disk, an artifact is registered both by its barrel and by hand, a feature module is skipped or not activated, or the oldsrc/routes/layout is still in use. It never loads the application. Barrels are compared as sets of names, so a reformatted barrel passes. Every finding carries a stable id (setu.E001…setu.E013,setu.W001…setu.W004); warnings exit0unless--strict. Asetu.config.tsor seam artifact that exists but cannot be read is an error, andsyncleaves an artifact’s barrel unchanged rather than drop an import it cannot verify..setu-ts/check.jsonsilences an id everywhere or for one path, and an invalid file silences nothing (setu.E011). From a workspace root both commands run over every member, and refuse a member that is a symbolic link.checkexecutes no project code and parses in linear time, so it is safe on an untrusted pull request;syncandgenerateload the project’s Prettier configuration.setu sync [--dir] [--dry-run]rewrites every CLI-managed barrel from its directory with the renderersetu generateuses, writes nothing else, and then reports what it could not fix. -
Realtime subscriber reporting (#511, 0.11.0,
@setu-ts/common,@setu-ts/realtime-backplane-plugin,@setu-ts/cloudflare-plugin).dispatchRealtimeFrameandcreateRealtimeHandlerErrorReportershare subscriber isolation and per-handler warning, repeat and recovery diagnostics, withIRealtimeHandlerErrorReporterandRealtimeHandlerErrorReporterOptionsas their contracts.onHandlerError(error, frame)replaces default logging on built-in backplane plugin options andDurableObjectBackplaneOptions. -
Bounded LaunchDarkly snapshots (#511, 0.11.0,
@setu-ts/feature-flags-plugin).LaunchDarklyProviderOptions.maxCachedContextscaps the least recently used context snapshots; default 10,000, validated as a positive integer at provider construction. -
A compiled DI container with a request scope (M125, 0.11.0,
@setu-ts/common,@setu-ts/di-plugin,@setu-ts/kernel,@setu-ts/decorator-plugin,@setu-ts/testing). The container compiles its graph into direct factories and validates it once at boot.createToken<T>(name)(@setu-ts/common) returns anInjectionToken<T>, a frozen object matched by identity; with a class, it is accepted wherever aContainerToken<T>is, andresolveinfersTfrom either.InjectEntryis oneinjectelement, a token or{ token, optional: true }.IContainer.forRequest(ctx)returns the request’s scope, created on first call and disposed when the request finishes;IContainer.dispose()andProviderOptions.disposerelease what the container created, each asynchronous disposal bounded at 5000 ms (per disposal, not in total);ContainerConfig.timingsupplies the timers, andContainerConfig.reportDisposeError— set byDiPluginto a loggerwarn— reports a request scope’s disposal failures whenever they settle.IRequestContext.defer?(cleanup)is the kernel’s per-request cleanup primitive: cleanups run before a buffered response is returned, or when a streamed body ends, each asynchronous one bounded at 5000 ms, and a request that never calls it stays on the synchronous path.IRequestContext.slot?(key, create)is owner-keyed per-request storage under a key only its owner holds;forRequestkeeps the scope there.@Params(Service(token))(@setu-ts/decorator-plugin) resolves from the request scope on every call.DiContainer.close()ends registration;DiPlugincalls it after the last bootstrap hook. New errors in@setu-ts/di-plugin:ContainerValidationError(withfindings),ScopeRequiredError,ScopeDisposedError,ContainerClosedErrorandRequestScopeUnavailableError. -
Portable database errors (M119e, 0.11.0,
@setu-ts/common,@setu-ts/database-plugin).UnknownPayloadFieldErrormoves to common, preserving the database-plugin re-export’s identity;InvalidFieldValueErrorprovides the fixed 400 field-type refusal.MissingKeyColumnErrorandTenantColumnUnresolvedErrorname server configuration faults and their remedies. -
PublishTimeoutError(M119a, 0.11.0,@setu-ts/messaging-plugin). A new exported class for a publish that outlived its acknowledgement bound and may still be delivered, so a caller can distinguish it from a broker refusal without parsing the message:nameis'PublishTimeoutError', and the error carries the published topic and the configured bound as readonlytopic: stringandtimeoutMs: number. The Kafka, Pub/Sub, RabbitMQ, Redis Streams and NATS publish paths throw it whenpublishTimeoutMsexpires; its message names the broker, topic and bound and ends ”…; it may still be delivered”, with the Kafka, Pub/Sub and RabbitMQ texts unchanged from before, so a caller matching on the text keeps working. -
publishTimeoutMson the Redis Streams and NATS arms (M119a, 0.11.0,@setu-ts/messaging-plugin). The option the Kafka, Pub/Sub and RabbitMQ arms already accept is now accepted byRedisStreamsMessagingOptions(andRedisStreamsOptions) andNatsMessagingOptions(andNatsOptions): default 15000 ms,0unbounded, a value outside 0–2147483647 (includingNaN) refused withRangeErroratMessagingPlugin(...). On Redis Streams the bound covers the publish’sXADDonly — consumer reads are unaffected. On NATS it is passed to the client as its own publishtimeoutand held by the framework’s deadline as well. -
describeError(value)(M119g, #500, 0.11.0,@setu-ts/common). A new exported function that renders any thrown value as one bounded diagnostic line —Name: message (code=…, …) [inner; inner] <- cause— from the sameserializeErrorrecord the structured sink uses. A non-Errorvalue yieldsError: <string>, anAggregateErrorlists its members, whitespace and control characters collapse to one space, and the line caps at 8192 Unicode code points with… [truncated]. Pure and total. It is the one renderer behindcreateConnectionErrorReporter, the idempotency plugin’s claim/release/settle lines, and the messaging plugin’s broker error lines (moved from there unchanged). -
HealthIndicatorOptionsand per-indicator readiness membership (M119b, 0.11.0,@setu-ts/common). A new exported interface whose only member isreadiness?: boolean; absent means the indicator counts toward readiness, which is the previous behaviour. It is carried by an optional parameter on both registration surfaces —IHealthApi.register(name, indicator, options?)andIHealthService.registerIndicator(name, indicator, options?)— and by an optionalIHealthIndicator.readiness?member.checkReady()runs only the indicators that are notselfand that count toward readiness; an indicator outside readiness still runs incheck()(/health) and is absent from the/readyreport.checkLive()is unchanged. -
HealthPluginOptions.readiness(M119b, 0.11.0,@setu-ts/health-plugin).{ include?: readonly string[], exclude?: readonly string[] }, which overrides a contributor’s declared membership by exact indicator name:includeforces a named indicator into readiness,excludeforces it out.HealthPlugin(...)refuses a non-array, an empty or non-string name, the nameself, or a name in both lists withTypeError; the message names the option and never echoes a name. Names that match no registered indicator produce one count-only warning at startup. -
setu agents --check --strict(V10-24, M119h, #506, 0.11.0,@setu-ts/cli).--strictis accepted only with--check(alone: usage error, exit 2,--strict requires --check.). With it, each agent-card warning becomes<AGENTS.md path>: <warning>and the command exits 1, so a CI step run afterinstallfails when a package’sAGENT_CARD.mdwas not delivered. Plain--checkstill answers without an install. -
Generated Workers projects carry
testandtest:e2e(V10-23, M119h, #506, 0.11.0,@setu-ts/cli). Acloudflare-workersscaffold now gets both scripts, run throughtsx --testagainst the real Worker on workerd via wrangler’screateTestHarness, plustest/support/server.ts(which locateswrangler.tomlfrom its own path, never the working directory), a smoke test, an e2e test and fetch-based module tests.package.jsonrecordstsxand@types/node,tsconfig.jsonaddsnodetotypes, thedeno.jsontesttask that permitted an empty suite is dropped, and the generated README’s Testing section names both commands. The upgrade guide shows how to add them to a project scaffolded before this release. -
CliDependencies.logicalCwd(V10-25, M119h, #506, 0.11.0,@setu-ts/cli). One additive optional member on the exported type. The executable supplies the shell’sPWDonly when it is absolute and resolves to the physical working directory;setu agentsuses it solely to refuse a write through a symlinked workspace member, never to authorize one.
Changed
-
Agent brief development guidance (M119i, #528,
@setu-ts/cli). Briefs list and gloss development commands, distinguish Workers from socket full-stack development, and include messaging guidance for starter compositions. Workspace roots name the member development entry and production start. Existing projects re-runsetu agents;setu agents --checkreports their old briefs stale. -
IRedisLockClient.evalreturnsPromise<unknown>(M117, #525, 0.11.0,@setu-ts/scheduler-plugin). It declaredPromise<number | string | null>, which one adapter serving the queue’s and idempotency’s scripts could not honestly satisfy; the lock ignores the result. Any client that satisfied the old type still does. -
IRedisBackplaneClientgains an optionalconnect?()(M117, #525, 0.11.0,@setu-ts/realtime-backplane-plugin). When the backplane starts it connects an injected client whosestatusis'wait', so a lazily connecting client reports its real health from the start. -
Node devtool scaffolding (M126, #521,
@setu-ts/cli).--devtoolanddevtool enablenow support Node projects and workspace members. Development entries read credentials and exit through runtime services; older CLI-owned entries remain recognized and can be upgraded. Node usestsx main.dev.ts, keeps the diagnostics dependency development-only, and forwards a workspace session only to its selected member within the launcher process group. -
redactRecord()returns a snapshot (#513, 0.11.0,@setu-ts/common). Every plain object and array it walks is now copied, unclassified subtrees included, instead of being returned by identity, so a logger serializes exactly the values redaction checked (the same choice pino’s own redactor makes). Code that compared a result’s subtree with the original by===should compare by value. -
One request completion at info (#512, 0.11.0,
@setu-ts/logger-plugin). Request logging emitsrequest completedatinfowithmethod,path,status,durationand the child’srequestId.request receivedmoves todebug; setLoggerPlugin({ requestLogging: true, level: 'debug' })to restore the arrival/completion pair. When the exposed child level excludes debug, arrival metadata is not built. Slow warnings, error-status prediction and path exclusions are unchanged. -
Pino scaffold default (#512, 0.11.0,
@setu-ts/cli). REST, microservice, class-based and full-stack projects use Pino on Node, Deno and Bun, adding its10.xruntime dependency through the existing client dependency renderer. Workers defaults to console;--logger consolekeeps console output without installing Pino. -
Generated projects run
setu check(M118, #510, 0.11.0,@setu-ts/cli). Node, Bun and Workers scaffolds carry@setu-ts/cliat the framework range indevDependencies, and theircheckscript istsc --noEmit && setu check(npm run build && tsc --noEmit && setu checkfor a Workers project with a build). Deno scaffolds gain achecktask:deno check main.ts setu.config.ts && deno run -A npm:@setu-ts/cli@^<version> check; the devtool form addsmain.dev.ts, andsetu devtool enablereplaces the scaffold’s ownchecktask with it.checkandsyncare built-in verbs and now shadow a plugin command of the same name.setu generatereadssetu.config.tswith the scanner’s 1 MiB regular-file limit; its output is otherwise unchanged. See the upgrade guide for existing projects. -
Optional peers in a workspace (M119f, #505, 0.11.0,
@setu-ts/common,@setu-ts/cliand every optional-peer loader).OptionalDependencyUnavailableErroracceptsrequiredBy?: string; all 49 loaders name their npm package and direct installation to that package’s location within its declared version range, usually the workspace root.dependencyandcauseare unchanged;requiredByadds no instance field.setu addrecords its peers in both member and rootpackage.jsondependenciesfor listed Node/Bun Setu workspace members, preserving existing entries (a peer independencies,optionalDependenciesor a non-optionalpeerDependenciesentry counts; an optional peer is added todependenciesat its own spec; one only indevDependenciesis moved todependencies, pin unchanged, since--omit=devwould drop it), recording a peer the rootoverridespins (a string value, or an object’s"."entry) at that override’s spec since npm refuses any other, leaving$namereferences and conditional overrides (a version-selector key, or several rules for one package) to the user, noting (while still writing the root entry at the CLI’s range) a member that pins the peer at another spec, since npm may install a second copy for it, and naming both edited paths. Deno and standalone behavior is unchanged. See the upgrade note. -
Drizzle tenant columns resolve to properties (M119e, 0.11.0,
@setu-ts/database-plugin). The tenant bridge resolves and caches the configured column per entity for every read and write; an absent or ambiguous mapping fails before statement I/O. Payload stamps remain last and update strips the resolved property. -
Scoped cursor fingerprints (M119e, 0.11.0,
@setu-ts/common,@setu-ts/database-plugin,@setu-ts/cloudflare-plugin).sortFingerprintgains optional entity/key-column scope. Memory, Prisma, Drizzle, MongoDB, DynamoDB, Cosmos DB, Bigtable and D1 refuse legacy and cross-entity cursors; restart pagination once on upgrade. -
BREAKING — one place for routes: controllers (#526, 0.11.0,
@setu-ts/cli). Every file that registers routes is a controller,<name>.controller.ts: a class controller (@Controller) or a function controller (register<Pascal>Routes), told apart by its exports. The.routes.tssuffix is gone, andsetu g routewrites a function controller. A project keeps controllers and services in one place:src/controllers/andsrc/services/until the firstsetu g module, then onlysrc/modules/<name>/. New projects start flat (therestgreeting example is insrc/controllers/), and no longer scaffoldsrc/modules/index.ts. The firstg moduleremoves the flat barrels and rewritessetu.config.ts’s registration lines; with flat controllers or services present it refuses, and--adopt-controllersmoves them into the module, rewriting imports and keeping URLs, all or nothing; the scaffold’s untouched greeting example moves without the flag. Once a project has modules,controller,route,service,sseandsetu add viewrequire--module. Subfolders are scanned (up to 8 deep; linked directories are reported, never followed), andsetu syncrelocates moved files. A class-based module’sindex.tsis now CLI-managed and registers its function controllers, so a modular class-based config also callsregisterModules(app.router, app.services);g controllerandg serviceadd their class to a plain@Modulearray literal.setu add viewwritesviews.controller.tsinstead ofsrc/views/routes.tsand theregisterViewRoutescall.setu checkaddssetu.E014(controllers or services outside modules),setu.E015(an import in a CLI-written file that does not resolve) andsetu.E016(two controllers with one base name). There is no migration for existing projects. See the CLI guide. -
BREAKING — Zod 3 support removed (#496, 0.11.0,
@setu-ts/openapi-plugin,@setu-ts/validation-plugin,@setu-ts/decorator-plugin). The supported range is nowzod >=4.6.6 <5; validation still duck-typessafeParsebut is tested with Zod 4 only. OpenAPI schemas without the publictoJSONSchemamethod, including Zod 3 schemas, now degrade to{}with the existingnot a recognized zod schemaunrepresentable diagnostic. Upgrade Zod to at least 4.6.6 and migrate any Zod 3 API usage before regenerating OpenAPI documents and clients. -
BREAKING — DI lifetimes are enforced (M125, 0.11.0,
@setu-ts/di-plugin,@setu-ts/common,@setu-ts/decorator-plugin). Asingletonreaching ascopedprovider, directly or throughtransients, fails the boot withContainerValidationError, as do a missing dependency and a cycle — previously the singleton silently kept the first scope’s instance, and the others failed on the first resolve that reached them. Resolving ascopedtoken on the root container, or atransientthere that reaches one, or atransientwith adisposeoption, throwsScopeRequiredErrorinstead of quietly behaving as a singleton. Reach scoped services throughcontainer.forRequest(ctx)or@Params(Service(token)), orcreateScope()for a scope of your own. -
BREAKING — DI registration closes at boot (M125, 0.11.0,
@setu-ts/di-plugin).DiPlugincloses the container after the last bootstrap hook;registeron it afterwards throwsContainerClosedError. Register providers duringregister(),onInitoronBootstrap. A scope may register a token its parent chain does not have — it lives as long as the scope, asingletonlifetime there being stored asscoped— but no longer shadow one it has — register the replacement on the root, or useoverrideProviderin tests. -
BREAKING — factories receive their dependencies (M125, 0.11.0,
@setu-ts/common).FactoryProvider.useFactoryis(...deps: never[]) => T, called with the resolvedFactoryProvider.injectentries in order. A factory that closed over the container to resolve its own dependencies still works, but boot validation cannot see them; declareinjectinstead. -
BREAKING — route patterns outside the route grammar are refused (#518, 0.11.0,
@setu-ts/kernel,@setu-ts/graphql-plugin). Route patterns are static segments,:nameparameters, a final optional:name?,*segments and a finalprefix*. Patterns outside that grammar now throw at registration, naming the part that is not supported: a Hono regular-expression parameter (:name{…}); an empty segment (/api//:id); a*inside a segment that is not the final one (/v*/admin); a parameter name that is not a word (\w+, asLinearRouterread it::user-id) or is__proto__, which would hit the prototype setter ofctx.paramsrather than become a parameter; an optional parameter that is not last (/p/:id?/edit); and?outside a final optional parameter. None was documented, and each routed differently underTrieRouterthan underLinearRouter— a:name{…}constraint that can match/(/files/:path{.+}/download) also took time quadratic in the path length, about 300 ms for an 8 KB path. Use:nameor*and validate the value in the handler or with@setu-ts/validation-plugin.router.group()now joins its prefix and a path with exactly one/, as its documentation said, sogroup('/api/', r => r.get('/:id'))registers/api/:id;@setu-ts/graphql-pluginjoins apathending in/to its SSEstreamand WebSocketwspaths the same way. -
BREAKING —
IResponsegainssetHeaders(#518, 0.11.0,@setu-ts/common). A required member that sets several headers at once, asheader()per entry. An application class or a test fake typed asIResponsemust add it — a loop over its ownheader()is enough; 23 fakes in this repository’s own tests needed exactly that.createTestContextfrom@setu-ts/testingalready implements it. Passing the same frozen object on every request lets the kernel validate it once and copy it into each response in one step;HttpSecurityPlugin’s security headers now do. -
Request identity fields are inherited on framework-created requests (#518, 0.11.0,
@setu-ts/common,@setu-ts/kernel,@setu-ts/runtime,@setu-ts/testing). The one-write guard onuser,tenantandlocaleis now installed once on the request class’s prototype (installRequestIdentityGuard) for served,inject()andcreateTestContextrequests, instead of on every request object. On those requestsObject.keys(ctx.request)andJSON.stringify(ctx.request)no longer list the three fields; reading and writing them, the one-write rule and thereplace*helpers are unchanged. A request object an application builds itself keeps the per-instance guard and its enumerable fields. -
BREAKING —
IContainergainsforRequestanddispose(M125, 0.11.0,@setu-ts/common). Required members, so an application’s ownIContainerimplementation must add them;OverridingContainerin@setu-ts/testingforwards both.ProviderOptionsis now generic,ProviderOptions<T = unknown>. -
BREAKING —
ScopeManagerremoved (M125, 0.11.0,@setu-ts/di-plugin). The compiled graph replaced it, and nothing outside the package imported it. There is no replacement export. -
BREAKING — decorated controllers are DI singletons (M125, 0.11.0,
@setu-ts/decorator-plugin). Controllers were always built once, at registration; they are now registered assingletonwhateverDiPlugin’sdefaultScope. A controller constructor that reaches ascopedservice fails the boot; take the service as@Params(Service(token))on the handler instead. -
A queue-less NATS subscription is shared (V10-13, M119a, 0.11.0,
@setu-ts/messaging-plugin). Asubscribe()with noqueue—respond()with no queue included — no longer gives every replica every message on a per-process consumer. It now uses ONE durable JetStream consumer per topic, named<defaultQueue>.<topic>(default prefixmessaging-consumers) and shared by every replica: each message is delivered to one replica, and a restart re-attaches to the existing consumer and resumes from its acknowledgement floor. A group created for the first time starts at messages published after it, as a new Kafka group does. To keep every replica receiving every message, pass aqueuethat is stable and unique per replica (for example the pod name); a queue made from a random per-start value would leave a durable consumer behind on every start. An explicitqueueis unchanged. -
Redis Streams publishes gain a 15 s default bound (V10-21, M119a, 0.11.0,
@setu-ts/messaging-plugin). The arm joins the bounded ones: a publish now rejects atpublishTimeoutMs(default 15000 ms,0unbounded) withPublishTimeoutErrorinstead of holding indefinitely against a paused Redis. SetpublishTimeoutMs: 0to restore the previous unbounded publish wait; consumer reads are unaffected. -
The NATS publish bound moves from the client’s fixed 5 s to
publishTimeoutMs(M119a, 0.11.0,@setu-ts/messaging-plugin). Before this release an unresponsive NATS server rejected a publish after the client’s own 5 s, with a bareErrorthe caller could not test for. The bound is nowpublishTimeoutMs(default 15000 ms,0unbounded, refused out of range), passed to the client as its own publishtimeoutand held by the framework’s deadline as well, and an expired publish rejects withPublishTimeoutError. -
The
mailindicator is out of readiness by default (V10-49, M119b, 0.11.0,@setu-ts/mail-plugin).MailPluginnow registers its indicator withreadiness: false. Its status mapping is unchanged — an unreachable mail backend still readsdown, so/healthreports the outage (503) and alerts still fire — but/readyno longer counts it and stays 200.HealthPlugin({ readiness: { include: ['mail'] } })restores the previous behaviour. -
The gRPC health bridge reads readiness (M119b, 0.11.0,
@setu-ts/grpc-plugin).grpc.health.v1.Health/Checkfor the whole server now maps the readiness report (checkReady()) instead of the full health report, so it agrees with/ready; an indicator that is out of readiness (themailindicator, by default) no longer turns the serverNOT_SERVING. -
An unanswered queue reachability check is
upwithreachable: 'unknown'(M119b, 0.11.0,@setu-ts/queue-plugin). For EVERY queue adapter, a reachability check that does not answer within its 2 s bound now reportsupwithreachable: 'unknown'and no depth counts, instead ofdown; a check that answersfalsestill readsdownat once. -
The CSRF check accepts
Origin: nullwithSec-Fetch-Site: same-origin(V10-1, M119c, 0.11.0,@setu-ts/http-security-plugin). Under the plugin’s defaultReferrer-Policy: no-referrera browser sendsOrigin: nullon a same-origin HTML form POST, which was refused 403. The pair is now accepted (the Fetch Metadata rule Go and Hono use);nullwith any otherSec-Fetch-Sitevalue, or without it, is still refused.csrf: { acceptNullOriginSameOrigin: false }restores the earlier refusal. -
Generated Node test scripts force exit (V10-29, M119c, 0.11.0,
@setu-ts/cli).testandtest:e2epass--test-force-exit, so a test whose application failed to start fails promptly instead of hanging CI to its timeout. -
setu add cache|scheduler|realtime-backplanewrite the Redis arm in a Redis-transport workspace (M119c, 0.11.0,@setu-ts/cli). The registration reads the workspace’sREDIS_URLandioredisis installed; elsewhere the in-memory default is unchanged. -
setu agents --dry-runreports what the real run will do (V10-26, M119h, #506, 0.11.0,@setu-ts/cli). A brief whose spliced bytes equal the file isunchanged: the real run prints that and no longer rewrites the file, and the dry run printswould create/would update/unchangedthrough the same reporter as every other command. Both modes check every changed brief for writability before the first byte and refuse the whole run when one target is unwritable —<path>: not writable by you; nothing was written, orWOULD REFUSE: <path> is not writable by youin a dry run — exiting 1 with nothing written. A temp-file open refused withEACCES/EPERMnow reports<path>: permission denied creating a file in <directory>instead of naming the temp file, for every CLI write. -
setu generate … --modulenames a module that is not activated (V10-18, M119h, #506, 0.11.0,@setu-ts/cli). The scanner admits a module from its files, never from its activation, so a module whosesrc/modules/index.tsentry was removed accepted new artifacts and left them unreachable without a word. After the write (and after the plan in--dry-run), the command reads the activation barrel and, when the masked code does not name the module’s registrar orMODULESentry, prints the exact import and registration line to add. When the module is active butsetu.config.tsdoes not reference the barrel’s export, it prints the config lines instead. A comment-only mention does not count; the files are still written and the exit status is unchanged. -
A workspace ownership refusal names a remedy for its reason (V10-27, M119h, #506, 0.11.0,
@setu-ts/cli). A symlink says to replace it with the directory it points at or to runsetu agentsfrom its real path (named in parentheses when resolvable); a non-directory member path says to fix the workspace layout; only a directory owned by another user, or one whose ownership cannot be checked, still says to run in a workspace you own. From inside a symlinked member, the verified logical path is walked and refused in every mode rather than writing into the link target.
Security
-
Clear two High dependency advisories (#496). Remove Zod 3 from the workspace and require Zod
>=4.6.6 <5for GHSA-mrhx-c7xc-m3w5. Override@azure/msal-nodeto^7.1.0for GHSA-xcx6-jx8p-x3h5, because@azure/identitystill requests the vulnerable 6.x line. Applications using the Azure secrets provider should apply the same override until Azure ships an identity release accepting the fix; workspace overrides do not propagate to consumers. -
Redaction cache, single-read redaction and log-call containment (#513, 0.11.0,
@setu-ts/common,@setu-ts/logger-plugin,@setu-ts/metrics-plugin). From the security audit of #512. The redaction matcher’s path cache now skips paths longer than 256 characters, so field names taken from request bodies can no longer pin memory (600 requests with 900 KB names held ~1 GiB). Redaction now reads each property once and returns that snapshot: a getter returning a different object on a later read (during traversal, or when the logger serializes the result) could put a secret redaction never checked into the output. Log calls andchild()no longer throw on any transport: metadata that throws while being read is replaced by[unserializable metadata], such bindings are dropped, and a custom sink that throws loses that record instead of failing the request, and its first failure is reported once on stderr, by error class only.ConsoleLogger’s constructor is back to(runtime, options?), and metric label values are read once per observation, for both the series key and the stored labels.
Fixed
-
Validate forwarded client addresses (#529, 0.11.0,
@setu-ts/http-security-plugin). IP security validates every examinedX-Forwarded-Foror customipHeaderentry and strips numeric ports from IPv4 and bracketed IPv6 addresses. An invalid entry stops resolution with no client or forwarded origin, rather than publishing arbitrary text or falling back to the socket peer. Both header families share one address validator and canonical IPv6 form. -
Full-stack workspace libraries on Node and Bun stay external (#528,
@setu-ts/cli). The generatedvite.config.tsread a library’s name only fromdeno.jsonordeno.jsonc, so a Node or Bun library (named inpackage.json) was bundled as a second copy whose module state the server did not share. It now readspackage.jsontoo. In an existing project, add'package.json'to the file list in itsreadLibraryManifest. -
setu addon Deno writes a call the project’s owndeno fmtkeeps (M117, #525, 0.11.0,@setu-ts/cli). The provider callsetu addinserts intosetu.config.tswas written on one line however long its options were — a Redis connection read, a nestedoptions: { … }, a seam merged with stray spaces — so a Deno project faileddeno fmt --checkafter its firstsetu add messaging --broker redis,queue --queue redis, or Redis-backed add in a workspace member. The call is now laid out for the generated 100-column width, breaking only what does not fit. npm targets were already formatted by Prettier and are unchanged. -
Full-stack generator wiring and page collisions (#524, 0.11.0,
@setu-ts/cli). Route, controller and module generation refuses kernel routes that would shadow existing React Router pages, naming the page file and advising a separate API module.setu add events,cqrsandqueuewire managed handler barrels into the starter’s option arms, merging queue processors and transport options in either add order. Unwired queue advice now names the starter’squeuearm instead of a duplicateQueuePluginregistration. Jobs in a queue-owning starter refuse before writing untilsetu add queueinstalls the package and wires its processor barrel. -
Route matching with Hono’s
TrieRouter(#518, 0.11.0,@setu-ts/kernel). The kernel now matches routes with Hono’sTrieRouterinstead ofLinearRouter, which re-parsed every route pattern on every request (13-20% of request CPU on Bun).TrieRouterreturns the same full candidate set and never throws on overlapping parameter patterns, so the kernel’s static-over-parameter and registration-order tie-break is unchanged. Trailing slashes behave as they did underLinearRouter: a request may carry one extra trailing slash (/users/reaches/users), a static route registered with a trailing slash answers only that spelling (/reportsdoes not reach/reports/), and a parameter route’s trailing slash is not significant (/users/:id/answers/users/42). For a slash-terminated request the kernel ranks the routes matching both spellings together, so a wildcard never wins over the specific route. Route parameters reach Hono under the kernel’s own names and are mapped back, so a parameter named after anObject.prototypemember (:constructor,:toString) routes like any other, including after a*segment. A pattern written without a leading slash (admin) is the same route as/admin, and registering both is refused as a duplicate. Measured on Bun: the bare kernel matched faster in five of six alternating passes. -
Per-request work on the request path (#518, 0.11.0,
@setu-ts/kernel,@setu-ts/common,@setu-ts/runtime,@setu-ts/metrics-plugin,@setu-ts/http-security-plugin). From a Bun CPU profile of the REST scaffold, each change measured alone first: the middleware chain runs without anasyncwrapper per stage when diagnostics are off (koa-compose’s shape); the request identity guard is installed once per request class rather than per request; log redaction fills its copy without per-call scaffolding and walks only into nested values; a request path without%skipsdecodeURIComponent; a request-scoped service registry allocates storage only when something registers into it; a metric with distinct label names validates and keys an observation in one pass, and a histogram counts in a typed array; the default security headers are copied into each response in one step. Behaviour is unchanged apart from the two entries under Changed. -
Histogram boundaries: repeats,
InfinityandNaN(#518, 0.11.0,@setu-ts/metrics-plugin). AHistogramconfigured with the same boundary twice counted each observation once per repeat in that bucket; it is now onelebucket, counted once, as Prometheus expects. A configuredInfinitywas counted a second time beside the+Infbucket every histogram has; it is now that bucket. ANaNboundary, which corrupted the cumulative counts around it, is refused at construction.bucketsstill returns the configured list. -
Logging and metrics hot paths (#512, 0.11.0,
@setu-ts/common,@setu-ts/logger-plugin,@setu-ts/metrics-plugin). Compile literal and suffix field matchers and specificity once, bound matcher path results to 1,024 entries per instance, and memoize recursive wildcard states only for multiple globstars. Console children reuse root redaction compilation. Metrics cache byte-identical label keys by configured label values, retaining only observed combinations. -
Realtime subscriber failures (#511, 0.11.0,
@setu-ts/realtime-backplane-plugin,@setu-ts/cloudflare-plugin). Async subscriber rejections are captured and reported without an unhandled rejection; synchronous throws reach the logger by default. Repeated failures debug after the first warning, recovery logs once at info, anddescribeErrorcollapses CR/LF in diagnostics. TransporthandlerErrorsremains bounded to the latest 100 failures in either mode. -
Long-lived instance growth (#511, 0.11.0,
@setu-ts/service-discovery-plugin,@setu-ts/feature-flags-plugin,@setu-ts/sse-plugin). Amortised discovery sweeps drop fully expired ejection state without shortening an active ejection. LaunchDarkly evicts least recently used snapshots; an evicted context returns fallback while refilling. SSE membership is reclaimed when the last member leaves, held handles continue working by name, and disconnect cleanup visits only joined channels.peek(name)now returnsundefinedfor an empty channel, andchannelCountcounts channels with members. -
Expired in-memory auth state is reclaimed (#509, 0.11.0,
@setu-ts/auth-plugin).MemoryRateLimitStore.increment()sweeps expired per-key windows without evicting live limits;MemoryRefreshTokenStore.save()androtate()sweep expired records even when they are never read. Unexpired revoked refresh records remain available for rotation reuse detection. Both use doubling thresholds. -
HTTP method metrics have bounded cardinality (#509, 0.11.0,
@setu-ts/metrics-plugin). Unknown method labels now use_OTHERon success and error paths. The nine known uppercase HTTP methods keep their names; comparison is case-sensitive. Consumers querying invented method labels should query_OTHERinstead. -
Subscriber error histories are bounded (#509, 0.11.0,
@setu-ts/realtime-backplane-plugin). Memory, messaging, and Redis transports retain the most recent 100handlerErrors, oldest first. -
Logged mail has bounded retention (#509, 0.11.0,
@setu-ts/mail-plugin).LogProvider.messageskeeps the most recent 1,000 messages in send order; its optionalsinkstill receives every send. -
Idle diagnostics session budgets are reclaimed (#509, 0.11.0,
@setu-ts/diagnostics-plugin). Authenticated promotion sweeps fully refilled token buckets at doubling thresholds starting at 64; drained and partially refilled buckets retain their limits. -
Room lookup allocation is explicit (#509, 0.11.0,
@setu-ts/websocket-plugin). Document thatRoomRegistry.get(name)creates on lookup and never-joined rooms are reclaimed on disconnection; usepeek()for client-supplied room-name reads. -
A singleton no longer captures a scoped service, and
@Optionaldependencies honour the resolving scope (M125, 0.11.0,@setu-ts/di-plugin,@setu-ts/decorator-plugin). A scope shared its parent’s singleton cache, so a singleton first resolved inside one scope kept that scope’s instances for the life of the application, and an@Optionalclass resolved its dependencies from the container it was registered on, never from the scope resolving it. The first is now refused at boot (see the BREAKING entry above); the second registers as a class provider with optionalinjectentries. -
Documentation that matches the code (M119i, #528). The upgrade guide, guides, READMEs, agent cards and
PUBLIC_API.mdnow state what the code does: links into the released0.10.0sections (V10-7), generated Kubernetes probes (V10-8),swagger-ui-diston Deno (V10-15), the Workers full-stackallowJsrepair (V10-16), which 4xx paths log (V10-39), the module-scoped service token<module>.<name>-service(V10-38), three missing 0.10.0 upgrade entries (V10-46, V10-47, V10-48), the service-discoveryRetryPolicyexample (V10-12), the request logger’s unscrubbed path (V10-40), the agent cards’setu addwording (V10-43), React Router 8 (V10-56), unrepresentable Zod types (V10-57), the worker-pool overshoot (V9-29) and the resilience timeout. Every package README is now compiled by the fence gate, and absolute repository links and links to a heading a release renames are checked. The folded documentation adds M124 auth and M121 starter card guidance, scaffold-first development and getting-started paths, module-identity explanations, npm plugin publishing, a learning path and source walkthroughs for every example with app README pointers, and a homepage create command and current package count. -
Prisma input refusals are named 400s (M119e, 0.11.0,
@setu-ts/database-plugin). Unknown payload arguments becomeUnknownPayloadFieldError; other validation errors and known-requestP2007errors becomeInvalidFieldValueError. Conditional writes use the same mapping, replacing their previous 501. Prisma refusal causes are fresh errors with fixed messages naming only the original name and code; query text and payload values are discarded. -
D1 payload refusals are named 400s (M119e, 0.11.0,
@setu-ts/cloudflare-plugin). Invalid payload identifiers and SQLite unknown-column errors naming payload fields becomeUnknownPayloadFieldError; STRICT-table type refusals becomeInvalidFieldValueError. -
PostgreSQL refused values are 400s through Drizzle (M119e, 0.11.0,
@setu-ts/database-plugin). Malformed keys and mistyped payload values becomeInvalidFieldValueErrorwith a scrubbed cause; existing HTTP-hinted errors retain their identity. -
Missing configured Drizzle keys fail connect (M119e, 0.11.0,
@setu-ts/database-plugin). Registered tables with explicit incorrect primary keys throwMissingKeyColumnError. An id-less table registered for the typed query builder remains valid until repository creation. -
Unhandled Drizzle pool errors no longer exit the process (M119e, 0.11.0,
@setu-ts/database-plugin). The plugin attaches an idle-error reporting listener only when the app’s pool has none, usingdescribeErrorand the optional logger’swarn; without a logger the listener still attaches. -
A generated full-stack app can run behind a TLS-terminating proxy (V10-17, V10-41, M119c, 0.11.0,
@setu-ts/cli). Every React Router action answered 400 because nothing configured IP security. The template now readsTRUSTED_PROXIES(comma-separated CIDRs; empty leaves it off), and the react-router-plugin README anddocs/deployment.mdname the failure. -
The CLI installs
swagger-ui-dist@5.33.3exactly (V10-36, M119c, 0.11.0,@setu-ts/cli). A^5range resolved a newer release whose files fail the OpenAPI plugin’s SRI hashes, so a{ baseUrl }docs page rendered blank. -
setu add telemetryinstalls its OpenTelemetry peers (V10-6, M119c, 0.11.0,@setu-ts/cli). The printed console-exporter registration crashed at start; the four peers are now recorded, with@opentelemetry/apiheld at~1.9.0because the 2.x SDK line refuses1.10. -
Kubernetes discovery outside a pod names
token(V10-44, M119c, 0.11.0,@setu-ts/service-discovery-plugin). A missing service-account token file now gives one startup warning and aDiscoveryUnavailableErrornamingtokenandapiServer, instead of a silentdown; the README no longer liststokenas Consul-only. Only a missing file is reported that way; any other token read failure inside a pod still reaches the caller as a temporary outage. -
Documentation (V10-3, V10-14, M119c, 0.11.0). The http-security CORS + CSRF example allows the
x-csrf-tokenheader it requires; the session-plugin README says the bearer exemption also skips the session commit on acsrf.excluded path and thatexemptBearer: falserestores it. -
A malformed or oversized
Forwardedheader no longer erases the client behind an appending proxy (V10-19, M119d, 0.11.0,@setu-ts/http-security-plugin).parseForwardedrefused the whole field, so withforwardedHeader: 'forwarded'behind a proxy that appends its element a client sending one malformed element, more than 64 elements or more than 8 KiB resolved to no client — and then to the shared rate-limit/audit fallback bucket. A malformed element is now a boundary: the elements to its right are still read and the trust walk stops at the boundary instead of crossing into client-written text. An over-bound field is re-read from the right within 8 KiB and 64 elements. A direct client keeps its socket peer, and a boundary with only trusted entries to its right still resolves no client. A trusted proxy that writes a comma inside a quoted value to the right of malformed input loses that element to the boundary and resolves no client (fail closed). -
IPv4-mapped and non-canonical IPv6 clients have one normalized identity (V10-28, M119d, 0.11.0,
@setu-ts/http-security-plugin). IP security published a forwarded client exactly as the proxy wrote it while the runtime un-mapped the socket peer, so one client could key the rate limiter and audit under two forms. Every published address is now normalized — IPv4-mapped becomes dotted IPv4, and IPv6 becomes canonical lowercase compressed — and trusted literals are normalized the same way before comparison. Every spelling of a mapped address — dotted tail, hexadecimal tail (::ffff:c633:6407) or uncompressed — is one identity. Clients arriving in IPv4-mapped (::ffff:198.51.100.7) or non-canonical IPv6 (2001:DB8::1) form therefore get a NEW rate-limit/audit key after upgrading; the limiter windows are short-lived. -
Connection-error lines no longer read
error: ""for anAggregateError(V10-63, M119g, 0.11.0,@setu-ts/common).createConnectionErrorReporterloggederror.messageonly, and an ioredisAggregateErrorcarries an empty message, so a two-addressECONNREFUSEDoutage producederror: ""in every consumer (cache, idempotency, auth rate-limit store, messaging Redis Streams, queue, realtime backplane, scheduler). The line is now rendered bydescribeError, so it carries each inner message and itscode=…classifiers; the de-dup key is that rendering, so a stable outage still reports once atwarnand repeats atdebug. The idempotency plugin’s claim/release/settle lines use the same renderer. -
A sign-in provider outage names its reason at
warn, once per state change (V10-62, M119g, 0.11.0,@setu-ts/auth-plugin). A login route that answered503 provider-unavailablewrote no log line at any level, so a Keycloak issuer mismatch (X74 b) was silent.resolveEndpointsnow returns a fixed reason code (key-set-missing,discovery-endpoint-invalid, or the key set’s last discovery failure), and the routes reportauth-plugin: signIn['<provider>'] provider unavailable (<reason>)atwarnon a new or changed reason, atdebugon a repeat, and warn again after a successful resolve. The logout path shares the reporter. The outside-issuer bearer path reportsauth-plugin: issuer '<name>' key-set refresh failed (<reason>)the same way; token rejections stay atdebug. -
A service-discovery cold outage is answered as a named 503 (V10-34, M119g, #500, 0.11.0,
@setu-ts/service-discovery-plugin). With nothing cached, a backend failure threw an unbrandedDiscoveryUnavailableError, whicherrorHandlernormalized to a masked 500. That instance now carries a503 Service Unavailablestatus hint with the fixed detail “Service discovery is temporarily unavailable.”; configuration failures stay unbranded and are still answered as 500. -
Kafka watermark capture on a new topic (#493,
@setu-ts/messaging-plugin). When a new consumer group subscribed to a topic created moments earlier, the read of that topic’s offsets could fail withNOT_LEADER_OR_FOLLOWERbefore its partitions had leaders. The broker logged a warning and skipped the capture, so messages published aftersubscribe()resolved but before the group’s first join were not delivered. The read now retries errors kafkajs marksretriable, within theKafkaOptions.retrybudget, and stops ondisconnect(); a non-retriable error or an exhausted budget behaves as before. -
A queue-less NATS subscription no longer replays the stream on every start, and leaves no consumer behind (V10-13, M119a, 0.11.0,
@setu-ts/messaging-plugin). Before this release asubscribe()with noqueuecreated a durable consumer namedmessaging-<uuid>per process with the server’salldelivery policy, so every start replayed the whole stream and left another durable consumer behind — measured at an RPC responder’s call count 6 → 14 → 24 across restarts, 16 durable consumers after four boots. The subscription now shares one durable consumer per topic with the other replicas (the Changed entry above), and the per-instancerequest()reply inbox is an ephemeral consumer that is deleted on shutdown and re-created if the server removes it. Onconnect()the broker also sets a 5-minuteinactive_thresholdon the consumers earlier releases leaked — durable consumers named exactlymessaging-<uuid>orrr_2einbox_2e<uuid>whose recordedsetu.queuemetadata is absent or matches — so the server removes them once nothing reads from them; a replica still running an older version keeps its consumer while it reads. -
A fresh Kafka consumer group no longer delivers its first message twice (V10-2, M119a, 0.11.0,
@setu-ts/messaging-plugin). The first-join seek rewound every assigned partition whose committed offset was still-1, including one already delivered, so a message published aftersubscribe()resolved but before the first join’s seek arrived twice (4 of 8 runs in thev0.10.0run). Each delivery now records the first offset it saw for its partition, and the first join skips a partition already delivered from its start point — seeking only one that was never delivered, or whose first delivery was past the minimum start offset — so the delivery is not repeated when the consumer happened to start at the right offset. -
A Redis Streams
publish()no longer hangs against a paused Redis (V10-21, M119a, 0.11.0,@setu-ts/messaging-plugin). The publish had no time bound and held the call indefinitely — measured 14–21 s against a paused Redis — while every other arm failed at its bound. The publish’sXADDis now wrapped in the publish deadline and rejects withPublishTimeoutErroratpublishTimeoutMs(see Added and Changed above); consumer reads keep their own bounds and are unaffected. -
Publish timeouts reject with
PublishTimeoutErrorinstead of a bareError(V10-30, M119a, 0.11.0,@setu-ts/messaging-plugin). The Kafka, Pub/Sub and RabbitMQ arms’ publish timeouts rejected with a bareErrorwhose text named the bound but which a caller could not test for. They now reject with the exportedPublishTimeoutError(see Added); the message texts for those three arms are unchanged, so a caller matching on the text keeps working. -
The README’s outbox
store-write-failingclaim is corrected (V10-5, M119a, 0.11.0,@setu-ts/messaging-plugin). The README said only a relay paused inside its publish reports thestore-write-failinghealth reason; a pause at a settlement write (markSentormarkFailure) that outlivesstoreTimeoutMsdoes too, and the store’s late answer is still recorded. The behaviour is unchanged; the health row now reads “this instance’s last sweep ended on a store call that was rejected or did not answer withinstoreTimeoutMs”. -
V10-49: a shared mail outage no longer takes every replica out of rotation at once (M119b, 0.11.0,
@setu-ts/mail-plugin,@setu-ts/health-plugin). One SMTP outage stays visible on/health(503, alerting) without failing/readyon every replica at once, through themailindicator’s new readiness membership; the Changed entry above carries the default and the restore lever. -
V10-20: a paused Redis no longer takes the replica out of rotation through the
realtime-backplaneindicator (M119b, 0.11.0,@setu-ts/realtime-backplane-plugin). The indicator’s reachability check is now cached for 5 s and bounded at 2 s; a check that does not answer reportsreachable: 'unknown'and the status staysup(previously the unbounded RedisPINGoutlived the health indicator’s 5 s deadline and readdown). -
V10-22: the RabbitMQ queue indicator no longer reports
reachable: truewhile the broker is paused (M119b, 0.11.0,@setu-ts/queue-plugin). The adapter’s reachability check now opens and closes a throwaway AMQP channel on the connection instead of reading a supervisor flag: a refused round trip answersfalse, and a hung one resolvesreachable: 'unknown'through the queue probe’s 2 s bound. -
V10-31: a paused database no longer reads
downafter two stacked probes (M119b, 0.11.0,@setu-ts/database-plugin). The migration readiness wrapper now skips the ledger read unless the connection result is notdownand itsdata.reachableis notfalseor'unknown'; otherwise it returns the result unchanged withdata.migrations.state: 'unknown'added (an adapter with no reachability check is still checked). A paused database therefore readsdegradedwithin one ~2 s bound instead ofdownafter ~4 s. -
setu new --runtime cloudflare-workersrefuses a name wrangler refuses (V10-9, M119h, #506, 0.11.0,@setu-ts/cli). The project name is written intowrangler.toml, and wrangler accepts only lowercase letters, digits and-, at most 63 characters;setu new a.b --runtime cloudflare-workerstherefore scaffolded a Worker that wrangler refused to build or run. The name is now checked before any write (exit 2,Invalid Workers project name: "<raw>". wrangler accepts only lowercase letters, digits and -, at most 63 characters.), for the flag and the interactive answer alike. Other runtimes keep the path-segment rule. On every runtime, a name containing a control character (CR, LF, TAB, NUL, DEL) is now refused as typed rather than folded to-, asPROJECT_NAME_RULEalready said. -
A re-run of an interrupted
setu adoptreads the manifest where the interruption left it (V10-10, M119h, #506, 0.11.0,@setu-ts/cli). Plugin and runtime detection read only the original project root, so a SIGKILL after the manifest moved toapps/<member>/recordedhealthProbes/metricsEndpoint: false, and a moveddeno.jsonturned a Deno workspace into a Node one. Both probes now read the root and the planned member directory: plugins are their union, and a runtime marker counts when present in either. The member directory is read only when it is really inside the project, never through a link planted atapps/<member>. A fresh adoption is unchanged. -
A generated module’s 404 no longer echoes the caller’s id (V10-35, M119h, #506, 0.11.0,
@setu-ts/cli).GET/PATCH /<module>/:idrendered<Pascal> <id> was not found.into the Problem Details body and the warn log, so a caller-supplied value — including markup or control characters — was reflected back. The detail is now the fixed<Pascal> not found.; the request logger still records the path. -
The generated injectable-service comment names the class-level
@Inject(...)(V10-11, M119h, 0.11.0,@setu-ts/cli). It said the token goes “on a constructor parameter”, where the decorator does not apply; it now says consumers list the token in their class-level@Inject(...), one token per constructor argument in order. -
Documentation links printed by the CLI name its own version tag (V10-42, M119h, #506, 0.11.0,
@setu-ts/cli). The migration readiness link and the starter README link pointed atdevelop, which can move ahead of the installed release. Both now useblob/v<version>/….
0.10.0 — 2026-10-22
Fixed
-
CLI writes (M116,
@setu-ts/cli): every Node CLI write (new,generate,add,agents, …) snapshots regular files read-only, then writes and restores through a fresh same-directory temp file renamed over the target. Existing inodes are never written through; static leaf symlinks, hard links, special files and prior content over 1 MiB are refused independently of ownership. An existing file’s temp is created owner-only (0o600), so its contents are never readable by others mid-write; the replacement then keeps the file’s owner and group and only its0o777permissions, never setuid, setgid or sticky bits. A file the user cannot write, or whose owner or group cannot be kept, is refused unchanged; ACLs and extended attributes are not carried over. Cleanup covers handled failures only: a process killed between temp creation and rename can leave a.<name>.setu-<hex>.tmpfile. Specific tosetu agentsworkspace writes on POSIX, every root and member target’s directory chain is checked for ownership and symlinks (git’ssafe.directoryrule; permission bits are not checked); untrusted chains are refused with nothing written, whether invoked from the root or a member, and--checkskips the check. Missing directories are checked from their nearest existing ancestor; a missing member still exits1with<member>: not a project, writing nothing. The ownership check is skipped where uid/metadata are unavailable, including Windows; standalone projects are unchecked. -
CLI file reads and member paths: manifest reads now refuse non-regular files, files over 1 MiB and files that are not valid UTF-8; runtime-marker checks use metadata without opening content. A POSIX path containing
\is no longer read as a workspace member path. -
Documentation gaps (M113l). Corrected optional telemetry/storage peer installation, upgrade and runtime test instructions, locale error headers, worker hand-over sizing, repository/audit/health/OpenAPI/TOTP contracts, principal-based tenancy and construction-time config. Added inbound-webhook and backup/restore guidance, tier-C scheduler wiring, process-env secret loading, and Node/standalone deployment guidance. New examples compile and behavior tests pin the documented compositions; no package source changed.
-
An over-limit body on a validated route answers
413(M114,@setu-ts/validation-plugin). A body the runtime refuses for size (RuntimePlugin({ maxBodyBytes })set) now keeps its413on a route carryingvalidateBody— previously the validation middleware’s own body read masked the refusal and the route answered400. The413applies to JSON,urlencodedandmultipartbodies alike. -
A decorator barrel the config does not consume is reported (V9-8, M113h, #477, 0.10.0,
@setu-ts/cli). Service and controller comments and the decorator barrel headers name the inlineDecoratorPluginform and a starter’sdecoratorsarm. After writing a barrel,setu generateprints the exactDecoratorPluginor starterdecoratorslines and the import whensetu.config.tsdoes not reference the barrel’s export. -
Every NATS scaffold carries a stream subject set (V9-17, M113h, #477, 0.10.0,
@setu-ts/cli). Standalone and workspace scaffolds emitstreamSubjects: ['app.>', 'rr.req.app.>', 'rr.inbox.>'], so every published topic starts withapp.. The generated README and Compose note name the scope and theRealtimeBackplanePlugintopic: 'app.realtime'that the'messaging'transport needs. -
setu adoptrecords declared probe support (V9-38, M113h, #477, 0.10.0,@setu-ts/cli). The member record insetu.workspace.jsongainshealthProbesandmetricsEndpointfrom the project’s ownhealth-plugin/metrics-pluginor starter dependency, so its Kubernetes probes and Prometheus annotations follow declared support and survive later re-renders. -
setu adoptmoves the configured dotenv pair (V9-39, M113h, #477, 0.10.0,@setu-ts/cli). A single-segmentenvFilePathand its.examplemove into the member, and a multi-segment path stays put with one line saying to move it by hand and why the root ignore rule cannot cover it. The moved dotenv file also gainsapps/<member>/.gitignorewith/<name>, because a root rule such as/.envis anchored to the repository root and would otherwise stop matching after the move; it is written through the conversion’s transaction, so an interrupted adopt rolls it back. A custom name the generic.env/.env.*rules do not cover (for examplelocal.env) prints one line naming it by its path from the workspace root, the form the root.dockerignoreneeds. The workspace.gitignoregains.env,.env.*and!.env.example; the.dockerignoregains**/.env,**/.env.*and!**/.env.example, and its comment states that a custom--env-filename is not covered. A conversion that fails part-way is now rolled back, so no moved file is left under the member. -
A Workers full-stack scaffold type-checks and builds first (V9-9, M113h, #477, 0.10.0,
@setu-ts/cli). The Workerstsconfig.jsonnamesnodebeside@cloudflare/workers-typesand the manifest adds@types/node, so the emittedvite.config.tsno longer fails its own check.allowJsreads the compiled server build, and a Workers full-stackcheckscript isnpm run build && tsc --noEmitso that build exists first; every full-stack manifest pins TypeScript~6.0.3. -
The React Router typegen directory is generated output (V9-11, M113h, #477, 0.10.0,
@setu-ts/cli)..react-router/joins the build output in the standalone.gitignore, ESLint ignores,.prettierignoreand the Denofmt/lintexcludes, and the workspace root.gitignoreignores it for every member. -
setu adoptrewrites the entry the CLI emits (V9-42, M113h, #477, 0.10.0,@setu-ts/cli). The port rewrite matches the currentport: Number(runtime.env.PORT ?? '3000')form as well as the legacy literal3000, inserting the discovery import after thecreateAppimport, so an adopted member bindsSERVICE_PORTinstead of printing a hand-edit message for the form the CLI itself writes. The config import is found by scanning lines rather than by aimport { [^}]+ } from …regex, which backtracked quadratically on project-supplied text (80k repetitions ofimport {took ~20 s). -
setu add websocketdeclares thewspeer on Node (V9-67, M113h, #477, 0.10.0,@setu-ts/cli,@setu-ts/runtime).wsis written intopackage.jsonat^8.18.0, also when@setu-ts/websocket-pluginis already present, and an existing entry is never overwritten.HttpAdapterOptions.reportErrorlets a failed Node WebSocket handshake report once per distinct message through the registered logger, or the runtime sink when none is registered. -
The kernel announces it is listening and scaffolds log requests (V9-81, M113h, #477, 0.10.0,
@setu-ts/kernel,@setu-ts/cli). After a successful listen the kernel logsServer listeningwith the port and hostname, orServer listening on an ephemeral portfor port0, and logs nothing without a logger. New scaffolds enable request logging and exclude/live,/ready,/healthand/metrics, so probe traffic writes no line. -
Long scheduler waits no longer fire early (V9-63, M113m,
@setu-ts/scheduler-plugin).delay,every,cronand retry backoff now wait in chunks of at most 2147483647 ms and check the wall clock on each wake, so a job never runs before its due time. Previously, Node, Deno and Bun clamped an over-limit timer to about 1 ms:delay(30 days)fired at once, and monthly cron jobs could fire early. Delays and intervals have no upper limit. Disconnect, remove and pause cancel retry backoff without another attempt. A one-shot that already succeeded or exhausted its retries is removed even while paused and is not re-run; one paused mid-retry stays registered and resumes from attempt 1 using the original delay. Recurring jobs resume at the next cron/interval boundary. Cancelled fires settle diagnostics withfalse(counted infailed, neversucceeded). Disconnect releases one-shot slots regardless of outcome, and an old fire cannot remove or release a replacement registered under the same name. Each release at disconnect is bounded byacquireTimeoutMs.resume()now rejects on a disconnected service, waits for a fire still settling before arming, and re-claims a one-shot slot released at disconnect. -
JWT validity boundaries now honour fractional seconds (V9-70, M113m,
@setu-ts/auth-plugin). First-party tokens are refused at and afterexp, closing the window of up to one second after expiry;nbfis checked against the same unfloored time. Outside-issuer tokens are refused at and afterexp + clockToleranceSec. -
The SDK generator no longer emits source that shadows its own bindings (M113k, #476, 0.10.0,
@setu-ts/sdk). A path parameter whose identifier isclient,opts,requestorencodeURIComponentis emitted with a_prefix; before,clientmade the method call.requeston a string andoptsbeside a query parameter emitted a duplicate parameter. Path parameters are positional, so no caller changes. An operation whose method name isclientorencodeURIComponentis emitted as_clientor_encodeURIComponent; before,clientwas a duplicate identifier andencodeURIComponentshadowed the global every generated path calls. A document component namedOperationRequestOptionsis refused likeClientResponserather than shadowing the generated file’s import. -
Idempotency documentation corrected and completed (V9-14, V9-16, M113k, 0.10.0,
@setu-ts/idempotency-plugin,@setu-ts/http-security-plugin,@setu-ts/database-plugin,@setu-ts/sdk). Thehttp-security-pluginCORS example now listsidempotency-keyinallowedHeadersandidempotent-replayedinexposedHeaders, so a keyed cross-origin browser call preflights and the page can readIdempotent-Replayed. The tier-C table schema is documented in thedatabase-pluginREADME; the idempotency README names theioredisinstall step and theOptionalDependencyUnavailableErrorstartup failure; its guarantee 2, M105 purge-race sentence andwithinttlMsfloor are corrected; a “From a browser” section documents the CORS options, therequestparameter and a renamed key header. The SDK README’s Keyed retries names the per-call form and theAccess-Control-Expose-Headersrequirement. -
setu addwires the barrel generated handlers live in (@setu-ts/cli, #472). In a functional projectsetu add eventsregistered a bareEventsPlugin(), so every handler a latersetu generate event-handlerwrote intosrc/events/index.tswas subscribed by nothing and never ran, with no message saying so.setu addnow registers the seamsetu newemits, derived from the same registry:EventsPlugin({ handlers: EVENT_HANDLERS }),CqrsPlugin({ commandHandlers, queryHandlers }), and the indicator and metric barrels forhealthandmetrics, each with its barrel import, and creates the empty barrel when the project has none. Class-based projects are unchanged: their handlers reach the plugins throughDecoratorPlugin’s ingress barrel. A config that already imports the barrel, or that the command cannot rewrite, gets printed guidance naming the seam form and the import. Existing projects are not rewritten; re-runningsetu addon one whose call lacks the option names the option and the import to add by hand. A project composed through a starter keeps its config, and the guidance now names the arm value to pass (events: { handlers: EVENT_HANDLERS }), so the created barrel is reached. -
setu add healthon a Deno project says the start task needs--allow-sys(@setu-ts/cli, #472). Without it the built-inselfcheck fails and/healthreports down. The Deno permission notes (this one and storage’s--allow-write) are no longer printed on other targets, where they do not apply. -
setu generate jobpoints at the queue and scheduler registration options (@setu-ts/cli, #472). The generated job’s comment recommended callingqueue.process()orscheduler.cron()inside another plugin’sregister, where the capability may not be registered yet. It now showsQueuePlugin({ processors: [...] })andSchedulerPlugin({ jobs: [...] }), both checked to type-check against the published packages. -
The database plugin README’s Prisma example reads
process.env['DATABASE_URL'](@setu-ts/database-plugin). It readDeno.env.get(...), which does not exist on Node or Bun, the runtimes npm users run. -
A value a client sends no longer makes a database error read as a timeout or an outage (M113g, 0.10.0,
@setu-ts/database-plugin). PostgreSQL quotes a rejected value in its error message, and the node-postgres timeout messages were matched anywhere in the error chain, so a body sending"Query read timeout"to an integer column answered504, and the two connect timeout messages answered503. The three messages now match only as the whole message of an error that carries no SQLSTATE code anywhere in its chain. The pool-wait message (timeout exceeded when trying to connect) had the same flaw since 0.5.0. The SQLite unique-violation message is matched only as a message prefix too (UNIQUE constraint failed:orD1_ERROR: UNIQUE constraint failed:), so the same text no longer answers a false409, and an unknown-column refusal from the Drizzle and Memory adapters now quotes the field name escaped and cut to 64 characters, so a field name cannot forge a log line. The M113f scrub of a DrizzleFailed queryor a SQLSTATE class 22 error now removes the whole stack header even when a bound value contains a line shaped like a stack frame; before, that line and every later bound value survived into the logged stack. -
The second node-postgres connect-timeout message answered a masked
500(V9-34, M113g, 0.10.0,@setu-ts/database-plugin). pg-pool raisesConnection terminated due to connection timeoutwhen a new client’s connect times out — the common case during a pause, when the pool has room to open a connection — and the message was unclassified, so a call that never reached the database answered a masked500. It is now classifiedunavailableand answered503besidetimeout exceeded when trying to connect; under either message the query was never sent, soDatabaseUnavailableError’s “the operation did not happen, and may be retried” holds. A connect that does not complete inside the bound is also why the Drizzle probe reportsdegradedrather thandown. -
A paused relay’s late publish settlement (M113j, #469, 0.10.0,
@setu-ts/messaging-plugin). AmarkSentormarkFailurefor a publish the relay already invoked now runs to its ownstoreTimeoutMsbound even when a pause has exhausted the sweep budget, and its answer is observed when it arrives after the bound: amarkSentormarkFailurethat finds the row alreadysentrecords theduplicateoverlap, aclaim-lostanswer is booked as an overlap, a latemarkFailurethat made the rowfailedrecordspoisonedwithout touching the sweep budget, and a late rejection is swallowed. A sweep that overran by a pause can outlivesweepDeadlineMsby at most onestoreTimeoutMs, and the shutdown drain is bounded by the same amount. A publish the budget refused to invoke is no longer counted as a publish failure: the sweep endsdeadline, writes nothing and reports nostore-write-failing. The database outbox store’s unrecognized-status error no longer quotes the row id (@setu-ts/database-plugin), and thecheck:docscontinued-line rule (P3) joins each command once instead of from every line. -
Stale
npm:specifiers in committed docs (M113j, #469, V9-22’s widened scope and V9-10’snpm:zodhalf). Everynpm:import and every prose, table or comment claim that a package loads asnpm:<pkg>— about 80 lines across ~20 files, plus onecliJSDoc — now names the bare package the loaders have used since M111a. The fence engine no longer rewritesnpm:specifiers (the@hono/hono→honoalias rewrite stays), andcheck:docsrefuses a stale one by name: P7 names a quotednpm:specifier in an import position, P8 any othernpm:<pkg>reference (Denoadd/installarguments and@setu-ts/@jsrspecifiers are exempt). -
A Redis client built without a reporter wrote its errors to the console (M113c). Since 0.8.0 the cache store, queue adapter, Redis Streams broker, realtime backplane, rate-limit store, scheduler lock and idempotency store attached their
'error'listener only when given aconnectionErrorReporter. Each plugin passes one, butRedisRateLimitStore, which no plugin builds, and any store or adapter constructed directly got none, so ioredis printed[ioredis] Unhandled error eventon every failed attempt.attachConnectionErrorReporter’sreporteris now optional: omitted, the listener drops events, as a reporter with no logger already does, and the health probe still reports the outage. -
Kafka shutdown with sends in flight. With publishes abandoned at
publishTimeoutMsduring a broker pause,app.stop()waited on kafkajs’s pending-request drain (about 26 s) and the process never exited, because the abandoned sends kept retrying and reopened connections. The producer close is now bounded at 10 s, and a second close runs once those sends settle. -
npm installation pointers (M111f). Auth, queue and GraphQL README Deno install commands now select npm explicitly. The website install commands resolve the npm packages.
-
@setu-ts/cli the devtool runtime refusal names the situation it is in (V9-5, M113i, #479, 0.10.0). The refusal takes a required
contextof'scaffold'or'enable', so no call site can keep the old wording by omission. The shared first sentence (why the devtool needs Deno) stays, but its last clause now reads “could never start its diagnostics connector” so it is correct in both contexts: the scaffold remedy keeps “Scaffold without —devtool, or create the project with —runtime deno.”, and the enable remedy says “This project targets; the devtool can only be enabled on a Deno project.” with no “scaffold” wording. -
@setu-ts/cli the CLI documentation states what rollback actually covers (V9-13, M113i, #479, 0.10.0).
docs/cli.md,packages/cli/README.mdandPUBLIC_API.mdnow state that a write is rolled back on SIGINT (Ctrl+C) and a caught error only — SIGTERM, SIGKILL (kill -9) and power loss all end the command with no rollback — and that re-runningsetu adoptconverges while an interruptedneworgenerateleaves the files it wrote, which the next run’s refusal lists. -
@setu-ts/cli
setu addaccepts the published packages its help promises (V9-40, M113i, #479, 0.10.0). The addable catalogue gainsview→view-plugin,localization→localization-pluginandidempotency→idempotency-plugin(the full specifier works too), so the help line “The full specifier works too” is now true for the published framework packages.setu add diagnostics— andadd @setu-ts/diagnostics-plugin— refuses with “@setu-ts/diagnostics-pluginis installed and wired bysetu devtool enable.” and exits 2, because the devtool composition issetu devtool enable’s job and the bare package would produce a project that cannot start the connector. A refusal for any other@setu-ts/<name>specifier names that the package is not one this command installs and lists the catalogue, as before. -
@setu-ts/cli a CLI-owned file that is a link or a special file no longer hangs
setu generate(M113i, #479, 0.10.0). A barrel such assrc/jobs/index.tscommitted as a link to/dev/zeromadeg joband its--dry-runread forever. The CLI now reads an existing file it generates only when it is a regular file under 1 MiB, and refuses the path by name otherwise. -
@setu-ts/cli the schematic tables match the source (V9-62, M113i, #479, 0.10.0). The
packages/cli/README.mdtable now listspluginassrc/plugins/<name>.plugin.ts(it saidsrc/plugins/<name>.ts), itscontrollerrow no longer saysdecorator-pluginis required and names the functional and class-based shapes instead, and the class-basedsrc/ingress/<name>-<kind>.ingress.tsoutputs ofws-route,job,command-handler,query-handlerandevent-handlerare listed, with the functionaljobrow naming theQueuePluginwiring. Thedocs/cli.mdandPUBLIC_API.mdtables receive the same corrections. -
@setu-ts/cli a functional
setu generate jobis wired whenQueuePluginis installed (V9-75, M113i, #479, 0.10.0). In a functional project withqueue-plugin,g jobnow emits the module with its existing<SCREAMING>_JOBand<Pascal>JobDataplus an exportedcreate<Pascal>JobProcessor(services): QueueProcessorDefinition<<Pascal>JobData>factory and the managedsrc/jobs/index.tsbarrel exportingQUEUE_PROCESSORS, and the wiring rendersQueuePlugin({ processors: [...QUEUE_PROCESSORS] })—QueuePlugin({ adapter: 'redis', url: …, processors: [...QUEUE_PROCESSORS] })for a--queue redismicroservice — so a job is no longer written and registered nowhere. After a write,setu generatereports an unconsumed jobs barrel withPass the barrel to QueuePlugin: QueuePlugin({ processors: [...QUEUE_PROCESSORS] })and the import line. Withoutqueue-pluginthe standalone function is emitted unchanged and the jobs seam is not scanned, so a project holding standalonesrc/jobs/*.job.tsfiles gets no skip report onsetu generate; a class-basedg jobis refused withoutqueue-pluginand, with it, writes the ingress seam, so a class-based project never holds a jobs barrel. On a Cloudflare Workers microservice the runtime swap removesqueue-plugin, so the jobs seam contributes nothing there. The stale registry premise thatQueuePluginOptionshas noprocessorsoption is rewritten: a processor polls only its own job name, the same trade the class-based arm already makes.
Changed
-
The supported Node floor is a patched release (#489, 0.10.0, every package). Every manifest now declares
engines.node ^22.23.4 || ^24.22.1 || >=26.12.1(was>=22): the first release on each supported line carrying the Node.js security fixes of 2026-10-21. The compatibility workflow pins Node 24.22.1. npm warns rather than refuses on a mismatch unlessengine-strictis set. Rebuild container images with--pulland rebuild every single executable (SEA): a SEA embeds the Node binary it was built with. Until patched, any outboundfetchto a server an attacker controls reaches the Brotli decoder flaw (CVE-2026-111497), becausefetchdecompressesbrresponses itself; see the upgrade guide. The lockfile is refreshed in the same change (issue #449). -
Declared OpenAPI operation metadata now reaches the document (#487, 0.10.0,
@setu-ts/common,@setu-ts/decorator-plugin,@setu-ts/openapi-plugin). OptionalRouteSchema.operationIdandRouteSchema.descriptioncarry programmatic declarations and@ApiOperationmetadata. A declared id is used verbatim:@ApiOperation({ operationId: 'listUsers' })now emitslistUsersinstead ofget-users. Regenerating an SDK client renames the method and id-derived types:<Op>Args,<Op>Error,is<Op>Error,<Op>Error<status>Body,<Op>Body,<Op>Response<status>and<Op><Param>Param. Hoisted components also follow the id (for exampleGetUsersBody→ListUsersBody, only where a schema is hoisted), as doesx-setu-unrepresentable[].at. Declared ids are reserved first, so an undeclared route’sget-userscan becomeget-users-2when another route declaresget-users. Blank or duplicate included declared ids now failstart()with exportedOperationIdError(reason: 'blank' | 'duplicate'); directOpenApiGenerator.generate()calls throw it too. Excluded routes do not claim ids. A document with no declared ids or descriptions remains byte-identical. Distinct ids such asgetUsersandget-usersremain valid OpenAPI, but the SDK refuses their sanitized method-name collision withDuplicate operation name. Migration: check existing declarations, choose distinct nonblank ids, regenerate clients and update callers and references to renamed types, components and diagnostics. Seedocs/upgrading.md,## 0.10.0. -
Environment secrets use the config snapshot as a fallback (V9-77, #485, 0.10.0,
@setu-ts/secrets-plugin). The defaultEnvProviderreads the environment first, then string values fromCAPABILITIES.CONFIGwhen available..env-only secrets loaded byConfigPluginnow resolve, includingSessionPlugin’sSESSION_SECRETand secrets in full-stack starter apps. As a result, a dotenv file present at runtime now satisfiesSessionPlugin’s missing-secret startup refusal. SetSecretsPlugin({ options: { configFallback: false } })to retain environment-only reads. Non-boolean values are refused at construction for the env provider; other providers ignore the option. Schema-stripped keys remain missing, and config-sourced${NAME}references are expanded unless ConfigPlugin’sexpandVariablesisfalse. Environment strings are unchanged. -
agentsis a built-in CLI command (M116,@setu-ts/cli). Built-in verbs are matched before any plugin-registered command and always win, so a plugin command namedagentsis no longer reachable. No in-repo plugin registers one; rename the plugin command if yours does. -
Validated body media types (M114,
@setu-ts/validation-plugin,@setu-ts/decorator-plugin,@setu-ts/common). The first-party validation service requiresapplication/jsonor anyapplication/*+jsonmedia type, ignoring case and parameters;{ forms: true }also accepts recognised form types. Unsupported types, includingtext/plainand an absent header, now answer415 Unsupported Media Typebefore reading the body, through the application’s error responder. Migration: sendContent-Type: application/jsonwhen posting JSON; a string-bodyfetchwithout headers sendstext/plainand is now refused, and an empty body-validated request without the header answers 415 instead of 400. Directctx.request.json()reads are unchanged; replacement validation services own their media-type handling. Cookie-authenticated routes still needcsrfMiddlewareorcsrfFormMiddleware. -
Generated output: the functional module layout (M114,
@setu-ts/cli).setu generate module <name>in the functional style now writes the whole feature intosrc/modules/<name>/—<name>.routes.ts,<name>.service.ts,<name>.schemas.ts(withvalidation-plugin),<name>.test.ts,<name>.e2e.test.ts(socket targets only) and a CLI-ownedindex.ts— and writes nothing undersrc/controllers/any more; the module registers throughregisterModules(app.router, app.services)from the CLI-ownedsrc/modules/index.ts, which a functional scaffold calls right afterregisterGeneratedRoutes(...). Existing projects keep working: theirsrc/controllers/<name>.routes.tsfiles are still wired by the controllers barrel, and only newly generated modules use the new layout. To adopt the layout for a pre-M114 functional module, movesrc/controllers/<name>.routes.tsintosrc/modules/<name>/, change its service import to./<name>.service.ts, widen its registrar toregister<Pascal>Routes(router: IRouterApi, services?: IServiceRegistry)(importing the type from@setu-ts/common; the regenerated index passes both), replacesrc/modules/<name>/index.tswith the registrar the CLI now generates — it importsregister<Pascal>Routesfrom./<name>.routes.tsand exportsregister<Pascal>Module(router: IRouterApi, services?: IServiceRegistry), which callsregister<Pascal>Routes(router, services)— in the same change. The module scan admits a functional module only when itsindex.tsexportsregister<Pascal>Module, and the moved routes file no longer reaches the controllers barrel, so until the index is replaced the routes are not registered and every--module <name>command refuses the module. Then run anysetu generate … --module <name>command (or add the import line and theregisterModules(app.router, app.services);call, whichsetu generate moduleprints for a project whosesetu.config.tsdoes not call it yet) so the activation barrel is regenerated. -
Generated output:
restsample and generated tests (M114,@setu-ts/cli). Theresttemplate’s sample is now emitted as a generated module atsrc/modules/greeting/(routes stay/greetingsand/greetings/:name), so a new project’s first files are exactly whatg moduleproduces.<name>.test.tsreplaces<name>.service.test.ts: instead of asserting a stub, it drives the module in-process throughcreateTestApp({ app: await createApp() })and asserts a contract the generated code and the developer’s code both keep.setu newemitstest/support/server.ts(bootscreateApp()on a free loopback port) andtest/e2e/app.e2e.test.tson every socket target, andg moduleadds a per-module<name>.e2e.test.tsover real HTTP. Thetesttask now runs every*.test.tsexcept*.e2e.test.ts; a newtest:e2etask runs only*.e2e.test.tsover real HTTP. A module generated on Cloudflare Workers emits no test (a Workers project’stesttask permits an empty suite). -
Generated output: a functional module’s
index.tsno longer re-exports the service (M114,@setu-ts/cli). The CLI-ownedsrc/modules/<name>/index.tsexports exactly one symbol,register<Pascal>Module(router, services?); the pre-M114export * from './<name>.service.ts'is gone. Import the service from./<name>.service.tsdirectly. -
Generated output corrections (M113h, #477, 0.10.0,
@setu-ts/cli,@setu-ts/runtime,@setu-ts/kernel). New scaffolds opt into request logging with/live,/ready,/healthand/metricsexcluded, and a booted application logs oneServer listeningrecord when a logger is registered. NATS scaffolds carry a stream subject set under theapp.scope, every full-stack manifest pins TypeScript~6.0.3, and a Workers full-stack project’scheckscript builds before it type-checks. React Router’s.react-router/typegen and dotenv files are treated as generated/ignored output. -
Migration ledger and readiness (M113o, 0.10.0,
@setu-ts/database-plugin,@setu-ts/cli). AddedcreateMigrationLedger,IMigrationLedger,MigrationLedgerOptions,MIGRATION_LEDGER_ENTITY,MigrationLedgerError, andDatabaseReadinessOptions. Optionalreadiness: { migrations: MIGRATION_NAMES }drains replicas with pending migrations and marks unreadable ledgers down; unknown names preserve status for rolling deploys. SQL schema remains application-owned. Generated runners record successful migrations and skip applied names. Breaking generated signature: replacerunMigrations('up')withrunMigrations('up', { database, ledger: createMigrationLedger(database) }); migrations may accept{ database }. Socket entry points boot and stop the app; Workers emit only the callable runner. Because the runner imports@setu-ts/common,setu generate migrationnow also requires that package to be declared (every scaffold declares it), andsetu add commonadds it. A ledger created without an explicit entity uses its connection’sreadiness.ledgerEntity, so the generated runner and the readiness check agree. -
Discovery warm-cache readiness (M113o, 0.10.0,
@setu-ts/service-discovery-plugin). A serving warm cache now reportsupduring backend outages instead ofdegraded, keeping replicas in rotation.data.degraded,reachable, andeverResolvedremain visible; never-resolved and unreachable staysdown. Update status-based alerts to inspect the data fields. -
Breaking: proxy client identity and public URLs (M113n, #473, 0.10.0,
@setu-ts/runtime,@setu-ts/common,@setu-ts/http-security-plugin,@setu-ts/auth-plugin,@setu-ts/react-router-plugin). Node, Deno and Bun now populateIRequest.ipwith the socket peer, normalizing IPv4-mapped IPv6. Unauthenticated default rate-limit keys move from oneanonymousbucket toip:<peer>.trustedProxiesnow checks that peer before accepting forwarded entries; a direct caller’s forged header is ignored. With IP security configured and a trusted proxy, React Router loaders/actions receive the public URL. Migration: list every real proxy or sidecar intrustedProxies, review rate-limit bucket sizing, and update code that expected internal loader URLs. Workers and plain fetch have no peer;proxyHopsand leftmost selection retain their header-only semantics for the client IP and never publish a forwarded origin. A forwarded origin is believed only intrustedProxiesmode with a trusted socket peer, and a forwarded host must be a DNS name or bracketed IPv6 literal. In theX-Forwarded-*family aproxyHopsheader too short for the chain, or an all-trusted chain, now resolves no client rather than the proxy’s own address, matching theForwardedfamily; intrustedProxiesmode so does a trusted proxy sending no forwarding header. -
CSRF own origins behind TLS termination (M113n, #473,
@setu-ts/http-security-plugin).csrf: {}also accepts the published public origin and, when the browser sendsSec-Fetch-Site: same-origin, the validated leftmostX-Forwarded-Proto/X-Forwarded-Hostcandidate; without that gate a credentialed CORS origin could name its own host and pass. A request whoseSec-Fetch-Siteis present and notsame-originis checked against the URL origin andtrustedOriginsonly. Migration: remove redundant same-origin allowlist entries if desired; keep explicittrustedOriginsfor other application origins. The zero-config candidate grants no proxy trust to other consumers. -
Breaking: invalid scheduler time values reject at registration (M113m, 0.10.0,
@setu-ts/scheduler-plugin). The asyncdelaycall rejects withRangeErrorfor a non-finite or negativedelayMs;everyrejects for a non-finite or non-positiveintervalMs;delay,everyandcronreject for a non-finite or negativeretry.delay. Messages name the job and the option and rule without echoing the rejected value:delayMs must be a finite number >= 0,intervalMs must be a finite number > 0, orretry.delay must be a finite number >= 0. ZerodelayMsand zeroretry.delayremain accepted, with no upper delay or interval limit. Migration: fix the invalid value before registering the job; these values were previously accepted. -
Breaking: JWT expiry is exclusive (V9-70, M113m, 0.10.0,
@setu-ts/auth-plugin). First-partyJwtService.verifyuses fractional seconds (runtime.now() / 1000) without clock tolerance: it rejects withToken expiredoncenow >= exp, and withToken not yet validwhilenow < nbf. The outside-issuer verifier (issuers) rejects oncenow >= exp + clockToleranceSec(was>); its not-before check remainsnow + clockToleranceSec < nbf. Migration: refresh beforeexpand update tests or clients that assumed a first-party token remained valid during the second of itsexp; outside-issuer tokens also stop being accepted exactly at the tolerance-adjusted expiry boundary. -
Breaking: a primary-key change through
updateis refused on every adapter (M113g, 0.10.0,@setu-ts/database-plugin,@setu-ts/cloudflare-plugin). EveryupdateandupdateWhere(and the transaction-scoped variants) of the eight data sources — Memory, Drizzle, Prisma, MongoDB, DynamoDB, Cosmos DB, Cloud Bigtable and D1 — now runs one shared check (assertPrimaryKeyUnchanged, exported from@setu-ts/common) before any I/O: a payload that carries a key column whose value is notundefinedand differs from the addressed key’s value is refused withPrimaryKeyChangeError(branded400 Bad Request, fixed detail “An update cannot change a row’s primary key.”); the message names the entity and the column, never a key value. An equal value is accepted, compared as a string forstring,number,bigintandObjectId-like values and by time forDatevalues, soupdate('5', { id: 5 })works while'05'against5is refused. Before: Drizzle, Prisma and D1 moved the row; MongoDB, Cosmos DB and DynamoDB answered success with the key column stripped; Memory answered a masked500. -
Breaking: Drizzle refuses a missing key column and an unknown payload field by name (M113g, 0.10.0,
@setu-ts/database-plugin). A Drizzle data source whose key column is not a column of its table is now refused when the repository is created —getRepositoryand the Unit of Work’s data source — instead of surfacing lazily insidefindById,updateordelete. Acreate,updateorupdateWherepayload that names a field that is not a column of the table is now refused by name before any I/O (UnknownPayloadFieldError,400 Bad Request, fixed detail “The request names a field this resource does not have.”) instead of being dropped by Drizzle and surfacing as the driver’s rawFailed query. Before: the field was dropped and the row was written without it, or the write failed NOT NULL with raw driver text. -
Breaking: a cursor the backend cannot use answers
400withInvalidCursorError(M113g, 0.10.0,@setu-ts/database-plugin,@setu-ts/cloudflare-plugin). EveryfindPageimplementation of the eight data sources now refuses a cursor the backend cannot use — a malformed token, a sort-fingerprint mismatch, or a decoded key-value list whose length or kinds do not fit the target — withInvalidCursorError(branded400 Bad Request, fixed detail “The pagination cursor is not valid for this query.”) before any backend call; the message names the entity and which check failed, never the token or its values. On Drizzle and Prisma a cursor that decodes but carries a value of the wrong type for its sort column (PostgreSQL22P02,22003,22007or22008, or Prisma’s validation refusal) is answered the same way instead of a masked500; a page without a cursor keeps the driver error.UnsupportedQueryFeatureError(501) remains for a backend that cannot page the query at all, soinstanceof UnsupportedQueryFeatureErrorno longer matches a malformed cursor. -
Breaking: Bigtable’s key-change refusal answers
400withPrimaryKeyChangeError(M113g, 0.10.0,@setu-ts/database-plugin). A BigtableupdateorupdateWherewhose key differs from the addressed row now answers400 Bad RequestwithPrimaryKeyChangeError(fixed detail “An update cannot change a row’s primary key.”) instead of501withUnsupportedQueryFeatureError(row-key); the served detail no longer names the feature, and the message no longer echoes either key value. -
BREAKING V9-47 — public health bodies (M113f,
@setu-ts/health-plugin)./health,/liveand/readyomit indicator data and latency by default. Migration: setdetails: truefor dashboards needing the old body. Programmatic reports stay full; gRPC and diagnostics are unaffected. -
BREAKING V9-49 — Swagger self-hosting (M113f,
@setu-ts/openapi-plugin,@setu-ts/cli). Swagger now serves three fixed local assets and excludes those routes from its spec. The newSwaggerOptionstype selects self-hosted, CDN, or custom-base delivery. Migration: install the optionalswagger-ui-distpeer (^5) and allow filesystem reads on Deno, or selectswagger: { assets: 'cdn' }(a starter usesopenapi: { swagger: { assets: 'cdn' } }). Missing packages fail startup withOptionalDependencyUnavailableError. CDN and custom-base modes use pinned 5.33.3 files with SHA-384 SRI; a custom base must be printable ASCII with no backslash,?or#, an HTTP(S) URL in serialized form without credentials, or a path starting with a single/. On Workers the starters select the CDN when no Swagger asset mode is chosen. Generated Node, Bun and Deno projects declare the package, and a generated standalone or npm workspace-rootpackage.jsonsets"scarfSettings": { "enabled": false }:swagger-ui-distdepends on@scarf/scarf, whose npmpostinstallotherwise reports platform, Node version and dependency data. If you install the package yourself, add the same setting to your rootpackage.json(in a workspace, the root; Scarf ignores a member’s). Workers selects CDN and no longer declares the package. -
M113f logging (
@setu-ts/common,@setu-ts/runtime,@setu-ts/kernel,@setu-ts/exceptions,@setu-ts/logger-plugin,@setu-ts/database-plugin). Scrub Drizzle bound values from messages and multiline stack headers while retaining SQL and classification, and rebuild a SQLSTATE class 22 cause (whose PostgreSQL message quotes the rejected input) from its code. The scrub covers errors that pass throughIDatabaseService; a query built throughgetDrizzleDatabase(...)or a raw driver call is logged as the driver wrote it, and causes nested more than five deep are not walked. Log caught errors once, atwarnbelow 500 anderrorotherwise; request completion uses the responder’s pure optionalstatusForrule. Runtime adapters provide optionalreportErroras a last-resort JSON sink for unhandled errors and handled 5xx without a logger. LoggerexcludePathsnow accepts patterns. -
Breaking: outbox and inbox
releaserefusals carry HTTP status hints (M113j, #469, 0.10.0,@setu-ts/messaging-plugin). An unhandledreleaserefusal undererrorHandleranswers its own status instead of a masked500:400for a bad row id (invalid-id— the outbox’s former missing-idTypeErroris nowOutboxRowStateError('invalid-id')),404for an absent row (missing), and409for a row in the wrong state (not-failedoutbox,not-parkedinbox).InboxRowStateErrorandOutboxRowStateErrorare branded withwithHttpStatusHint, so the answer carries the mapped status with the class’s own title and detail; the row id and the stored status stay out of the body.OutboxRowStateError.outcomegains'invalid-id', so an exhaustiveswitchon it with nodefaultneeds a new case. -
Breaking scoped RBAC: principal authority no longer counts in every scope (M113e, 0.10.0,
@setu-ts/auth-plugin). A scoped check (requireScopedRole,requireScopedPermission,@ScopedRoles,@ScopedPermissions) is now decided by grants alone: therolesandpermissionsa principal carries itself — a JWTrolesclaim, a'*'permission — count in no scope. Before, each principal role was a global grant and each principal permission satisfied every scoped permission check, so a token roleadminpassedrequireScopedRole('admin')in every tenant with no grant rows. Migration: issue grants, or setscopedRbac.principalAuthorityIsGlobal: trueto restore the old answer (still subject tograntableIn). The unscopedrequireRoleandrequirePermissionguards are unchanged. -
Breaking form CSRF: a bearer request is exempt only when a bearer strategy authenticated it and it restored no session (M113e, 0.10.0,
@setu-ts/session-plugin). WithSessionPlugin({ csrf: {} }), a request carryingAuthorization: Bearer <token>skips form-CSRF verification only when a bearer (header-credential) strategy authenticated it AND it restored no session — the sessionsession-pluginparks is new — so a JWT- or API-token-authenticated JSON API in the same application reaches its guard instead of answering403. The exemption is decided by the credential that authenticated the request and the session it restored, not by the header’s presence: a request carrying a valid session cookie must present a token even with a valid bearer token, because the handler would otherwise act on that browser’s session (an origin CORS lets send credentials can send its own valid bearer token beside the victim’s cookie, M113e, L3). A non-browser API client sends no session cookie, so its new session keeps the exemption. A request the bearer rule exempts also gets no browser session: whatever its handler writes to the session is not committed and noSet-Cookieis sent (M113e, L4), so it cannot plant a session in a browser whose cookie no longer restores. Otherwise, a junk bearer header beside a session cookie is still verified, and an unauthenticated bearer header is verified too (it fails closed when the session or the credential source is missing). For that, the form-CSRF middleware moved from priority 275 to 325 — after authentication (300), before authorization (350) — so the credential source authentication recorded (theIAuthStrategy.credentialmember, published under common’sCREDENTIAL_SOURCE_STATE_KEY) and the session it parks exist when the check runs.Basiccredentials are not exempt. A non-booleanexemptBeareris refused with aTypeError.csrf.exemptBearer: falserestores the previous refusal;csrfFormMiddlewareandverifyCsrfTokenhonour the option alike. -
Breaking session storage: anonymous sessions stay in the cookie on the store strategy (M113e, 0.10.0,
@setu-ts/session-plugin). A session holding only the form-CSRF token and the tenant binding is sealed into the cookie and writes no store row, so 200 anonymous form views no longer add 200 rows (measured against Redis). The first write of any other key moves the session into the store, rotating its id so a copied anonymous cookie can never read the signed-in row; a commit leaving only those keys on a stored session deletes its row. During a rolling deploy, an older replica reading such a cookie starts a fresh session, so an anonymous form loses its token once; signed-in sessions are unaffected. -
Breaking password hashing default (M113e, 0.10.0,
@setu-ts/auth-plugin).PasswordHashernow hashes with PBKDF2-SHA256 at 600,000 iterations (OWASP 2023), up from 100,000; a hash costs about 80 ms on Node instead of 15 ms. Existing hashes keep verifying, becauseverifyreads the count stored in each hash. A deployed Cloudflare Worker is reported to refuse counts above 100,000 (unverified against a deployed Worker): pass{ iterations: 100_000 }there. -
Breaking recovery and shutdown behaviour (M113c, 0.10.0). RabbitMQ queue faults now reconnect automatically through the shared common supervisor; unacked jobs may be redelivered with their original attempt count. Make processors idempotent. Deno HTTP close now waits for the first of shutdown completion and a 1000 ms grace, catching rejected shutdown, so lifecycle hooks run after the bounded drain even with requests in flight; move resource cleanup into shutdown/close hooks. After the grace,
close()returns without waiting for those requests; they finish only if the process outlives them. -
Breaking Redis delivery claims (M113c, 0.10.0).
visibilityTimeoutMsdefaults to 30000, must be at least 1000, and is renewed every third of the lease. Redis 5+ server-time scripts reclaim expired claims; crashes count as attempts and an over-budget job is passed toonFailed, then dead-lettered without running. Drain old replicas before upgrading: their stuck processing entries now return. Redis and RabbitMQ reject stale settlements with internalStaleClaimError, reported as failed settlement while leaving the current delivery intact. Make processors idempotent. A Redis client withoutevalretains the prior behavior (no claims or reclaim), with one warning thatvisibilityTimeoutMsis inactive, sent through the connection-error reporter when one is configured (QueuePluginalways configures one). The claim is renewed while an over-budget job’sonFailedruns. Due jobs are still selected by the reserving replica’s clock, so skew between an application host and Redis does not shift delays. -
RabbitMQ queue connection bound and unreadable messages (M113c). Each connection attempt
RabbitMqQueuemakes is bounded at 10 seconds in total, so a broker that never answers, or answers too slowly, is retried; a connection that opens late is closed. A ready-queue message whose body is not a stored job is moved, bytes unchanged, into the dead queue with one warning, rather than throwing out ofreserveand stranding the messages already fetched in that batch. If the dead queue refuses the move, the jobs already claimed are returned and the message waits, unacknowledged, until the channel closes. -
Breaking processor definition mutability (M113c, 0.10.0).
QueueProcessorDefinition<T = unknown>.processoris now a method, admitting typed payloads in declarative lists and preservingthis. Code relying on its formerreadonlyrestriction must enforce immutability itself. -
Redis namespaces (M113c).
RedisQueueOptions.keyPrefixandQueuePluginOptions.keyPrefixconfigure every Redis key; the default remainsqueue:. Use distinct prefixes for applications sharing Redis; drain or migrate existing keys before changing a deployed prefix. RabbitMQ’sprefixdoes not configure Redis. -
Shared reconnect exports (M113c). Common now exports
ReconnectSupervisor,ReconnectSupervisorOptions,ReconnectModeandfullJitterDelay; messaging’s three supervisor callers import them from common. The implementation moves without behavioural changes. -
Workers builds and the edge cache (M113d). A Cloudflare Worker importing a Setu-TS package now builds and deploys whether or not that package’s optional peers are installed. Every bare dynamic
import()inpackages/*/srcis now awaited inside thetryblock of a same-functiontry/catch(the loaders of 14 published packages andcliconverted; the rest already were), and thecatchthrows the newOptionalDependencyUnavailableErrorfrom@setu-ts/common— a missing optional dependency now rejects with that named error, or with a package-specific wrapper that keeps it ascause(the original rejection remains the shared error’scause), instead of the runtime’s raw error.cacheApiMiddlewarestores the body it serves with or without awaitUntilhost, a failed write is logged with the requestpathinstead of the cachekey(which carried the query string and the tenant id) and by error class rather than message, since a custom cache’s rejection may quote its key (aputthat throws rather than rejects is handled the same way, and the response is still served), andCloudflarePluginwarns once on Workers when no host was passed. A newly scaffolded Workers project’s entry passes the platform’swaitUntiltocreateAppas its second parameter (named_waitUntiluntil something reads it); a full-stack Workers project passes its compiled server build from the entry (the newReactRouterPluginOptions.serverBuildoption), renders.dev.vars, and itswrangler.tomlcarries[assets]and[build]stanzas.setu add cloudflareprints thewaitUntilform and tells you to rename the factory’s_waitUntilparameter;docs/upgrading.mdcarries the edits for projects scaffolded by 0.9.0 or earlier.ReactRouterPluginOptionschanges from aninterfaceto a uniontype(serverBuildPath|serverBuild, exactly one required) — breaking for implementors whoextendsit; a caller passingserverBuildPathis unaffected. A customloadRequestHandlerreceives the module asoptions.serverBuild, its new optional third parameter. Ships in minor0.10.0. -
Breaking readiness behaviour (M113b, 0.10.0). Seven indicators now remain
upwhere they previously reporteddegraded, so/readychanges from 503 to 200 and/healthno longer changes overall status for these conditions. Migrate alerts to inboxdata.parked/data.reasons, outboxdata.reasons(all six reasons), realtime-backplanedata.reachable, worker-pooldata.available, static-filesdata.fs, authdata.current/data.issuers, and feature-flagsdata.healthy/data.detail.degradedstill means out of rotation:/ready503,/health200. Healthy Kubernetes and Consul discovery probes now preserve their receiver and allow readiness. The discovery probe is cached for 5 s, coalesced, and bounded at 2 s, with runtime-timed request cancellation for Consul and Kubernetes. Health reads now issue at most one backend probe request per 5 s window per service instance; before M113b they issued none because the detached provider call threw before I/O. -
Connection-fault code predicate (M113b). The unreleased
CONNECTION_FAULT_CODESexport is removed so callers cannot mutate shared outage classification. Replace.has(code)withisConnectionFaultCode(code)from@setu-ts/common; only the same eight string codes match. Database classifier behaviour is unchanged. -
Breaking Redis outage responses (M113b). Cache and rate-limit connection faults now carry 503 status hints instead of reaching
errorHandleras masked 500s; cache-backed sessions inherit the cache response. Update response handling to accept 503 and inspectcauseinstead of checking raw driver errors. WRONGTYPE/programming errors are unchanged. -
Breaking rate-limit wait bound (M113b). Built
RedisRateLimitStoreclients now use a 15000 ms command bound. ConfigurecommandTimeoutMshigher for slow Redis, or0for the previous unbounded wait; configure injected clients directly. -
Messaging broker bounds and recovery (M113a,
@setu-ts/messaging-plugin). Kafka and Pub/Sub publishes now reject after 15000 ms by default, including RPC and outbox traffic; setpublishTimeoutMshigher or to0for the previous unbounded wait, and account for possible late delivery before retrying. Kafka factories withoutadminnow report unknown reachability instead of true; supply an admin-capable factory for a probe. Lazy NATS connections reconnect forever; setreconnect: { maxAttempts: 10, waitMs: 2000 }to restore the finite budget, or configure injected connections in the application. Fresh Kafka groups now start at the high watermark captured at subscribe time: remove join sleeps and publish retries used to avoid losing the first message. First joins seek assigned uncommitted partitions to the minimum start offset advertised by the current members, covering concurrent first starts without replaying history before the first subscription when every group member is trusted (a client able to join the group, which needs only group Read, can move the start earlier without a log line; advertisements are keyed by topic and bounded at 64 KiB and 19-digit offsets). Existing committed groups are untouched; a group subscribing before the broker’s coordinator is ready still captures its watermarks, since the first join re-checks which partitions are committed. Without admin the step is skipped; injected factories without a loadable kafkajs module seek only their own offsets, so concurrent starts can miss messages. A member crashing before the join takes its start offsets with it; admin/metadata failures warn once per subscription and remain non-fatal. -
Governing toolchain instructions (M111f). Repository commands use pnpm, Node, Vitest, ESLint, Prettier and TypeDoc. A categorized prose gate prevents obsolete instructions. JSR metadata tooling derives npm migration pointers for the 49 existing JSR packages; applying them is a post-merge maintainer step. npm descriptions are unchanged.
-
CI, examples and website on Node and pnpm (M111e3). The root
deno.jsonanddeno.lockare gone: every repository command ispnpm run <name>, withpnpm run test:toolingandpnpm run test:tooling:coveragefor the root suite thatdeno task testused to run. The authoredpnpm-workspace.yamlis the workspace list. CI’s main job is now “Format / Lint / Check / Test” on Node, and the Deno runtime proofs run in their own “Deno runtime lane” job against a committedtest/deno-lane/deno.lock; Deno is installed only where it is the subject. The examples underapps/join the pnpm workspace and run throughtsx,apps/compiled-binarybecomes a Node single executable application, and the repository images move tonode:24-alpine(268–289 MB) and a Node SEA on distroless (211 MB, previously 44.9 MB as a Deno-compiled binary). The release’sresolved-set.jsonnow records the resolved set frompnpm-lock.yaml:{ version, lockfileVersion, packages }, where eachpackageskey is an exactname@versionwith its registryintegritywhen present; it no longer embedsdeno.lock. The generated API reference is TypeDoc HTML, so its page URLs change (modules/<package>_src.htmlreplaces<package>/src/index.ts/index.html), and the website builds with pnpm. -
Repository tooling on Node (M111e2). Repository scripts use Node 24 and a shared subprocess helper. Root tests join the Vitest
toolingmember, which enforces the existing script coverage inventory per file. Root tasks forward to pnpm; the tooling-only and all-member test commands keep their separate scopes. The type-check gate includes package tests and repository tooling. The JSDoc ratchet runs on TypeDoc 0.28.20, and the README export tables and documentation fence compiler use the TypeScript compiler API. Two README tables change as a result, both correctingdeno docoutput:@setu-ts/cli/mainnow listsrunMain,nodeProcess,emitInterrupt,MainProcessandMainRuntime(shipped in 0.9.0, previously rendered as an empty table), and@setu-ts/storage-plugin’s deprecatedIAwsS3Clientalias is listed as aninterfacerather thanreference. -
Errors keep their cause (M111e1). Four errors that were thrown from a
catchwithout the original error now carry it aserror.cause: theConfigPluginenv-file read failure, bothreact-router-pluginserver-build load failures (the build module andreact-router), and thelocalization-pluginrefusal of an invalidexposeCatalogues.cacheControl. Messages are unchanged. Found when ESLint’spreserve-caught-errorreplaceddeno lint. -
@setu-ts/cli
setu addregisters the zero-configuration plugins it installs (V9-30, M113i, #479, 0.10.0). Forworker-pool-plugin,secrets-plugin,storage-plugin,audit-plugin,mail-plugin,resilience-plugin,view-pluginandidempotency-plugin,setu addnow inserts the bare plugin call intosetu.config.tsexactly as it does forcache, and a secondaddis a no-op. The two packages that need a user choice print the exact wiring line instead and leave the config byte-identical: telemetryTelemetryPlugin({ serviceName: '<service-name>', exporter: 'console' })(inserting it adds span middleware to every request with no exporter) and localizationLocalizationPlugin({ supportedLocales: ['en'], catalogues: { en: {} } }). Starter-composed projects keep the existing starter-arm guidance path; on a Workers project the inserted calls are runtime-portable exceptWorkerPoolPlugin, which registers and reports itself unavailable (documented M45 behaviour), so no new runtime restriction is added. -
@setu-ts/cli
--dry-runreports the refusal the real run makes (V9-61, M113i, #479, 0.10.0). The--dry-runofgenerate,new,libraryandgenerate appnow classifies the files first and printswould create <path>,would update <path>for managed files that exist, orunchanged <path>, thenWOULD REFUSE: <path> already existsfor each collision, exiting 1 when any collision exists and 0 otherwise —setu adopt’s wording and exit code; before, a dry run printedwould createfor every file and returned 0 before the existing-file check ran.setu adoptkeeps its own branch because it also prints moves. -
@setu-ts/cli class-based ingress files carry their kind in the name (V9-60, M113i, #479, 0.10.0). The five class-based ingress schematics —
job,event-handler,command-handler,query-handlerandws-route— now writesrc/ingress/<kebab>-<kind>.ingress.tswith the class<Pascal><Kind>Ingress(for examplesrc/ingress/<kebab>-job.ingress.ts/<Pascal>JobIngress), so two kinds of the same name no longer collide. Every other symbol inside the file keeps its plain name, so the emitted addresses do not change, and existingsrc/ingress/<name>.ingress.tsfiles keep scanning as<Pascal>Ingressand stay registered. -
@setu-ts/cli generated text names a CLI invocation that resolves (V9-12, M113i, #479, 0.10.0). Generated files,
Next:lines and messages for a project whose runtime the command has already resolved render the CLI as the pinned package for that runtime: npm-family (Node, Cloudflare Workers)npx @setu-ts/cli@<VERSION>, Bunbunx @setu-ts/cli@<VERSION>and Denodeno run -A npm:@setu-ts/cli@<VERSION>, where<VERSION>is the CLI’s own version; before, the baresetunamed the unrelated npm packagesetu. Usage, help and remedies without a resolved runtime retainsetu(PROGRAM_NAME); Deno-only devtool guidance uses the pinned Deno invocation. The generated sites include the project README “Generate code” fence, the Workerswrangler.tomlcomment, the workspace root README fences and theports --reallocateinstruction, the full-stack architecture table cell, and theNext:linesetu new --workspaceprints. A provenance comment that names what wrote a file is unchanged, and no scaffold gains a dependency.
Added
-
Agent briefs and per-package cards (M116,
@setu-ts/cli). A new built-insetu agentswrites the project’sAGENTS.mdfrom its real state — installed@setu-ts/*packages, style, runtime, scripts, available schematics — keeping the CLI-owned section between<!-- setu-agents:begin -->and<!-- setu-agents:end -->and leaving every other byte untouched; a marker arrangement it cannot read is refused instead of guessed at.--checkreports a missing or stale section and exits nonzero without writing, for CI;--dry-runprints the paths;--dirselects the project. In a workspace it writes the rootAGENTS.mdand every member’s, run from the root or from a member’s own directory.setu new --agentsruns the same writer after scaffolding, and a successfulsetu addprints a reminder when a sectioned brief already exists. Every published library package now ships a hand-writtenAGENT_CARD.mdin its npm tarball, so a normal install delivers the card at thenode_modules/@setu-ts/<pkg>/AGENT_CARD.mdpath the brief names. -
Self-contained feature modules,
--moduleandg schema(M114,@setu-ts/cli).setu generate module <name>makes a functional module self-contained: the whole feature lives insrc/modules/<name>/(routes, service, schemas, tests, module index) and registers throughregisterModules(app.router, app.services)fromsrc/modules/index.ts; a class-based module keeps its controller/service/@Moduleshape and gains<name>.schemas.tsand the two tests. A new--module <name>flag places aservice,route(functional only),controllerorschemainside an existing module: functional mode writes the file into the module folder and regenerates the module’sindex.ts, mounting a module-scoped route or controller at/<module>/<name>; class-based mode writes the file and prints the@Module({ providers | controllers })line to add — it never edits the declaration — registering a module-scoped service under the token<module>.<name>-service(for examplebilling.invoice-service). Every other schematic is app-wide and refuses the flag. A newschemaschematic (requiresvalidation-plugin) writessrc/shared/schemas/<name>.schemas.ts— orsrc/modules/<module>/<name>.schemas.tswith--module— and never edits an existing file; a second run is the ordinary overwrite refusal.SchematicOptionsgains the optionaltargetModule?andmoduleArtifacts?fields for custom schematics.zodis declared by every template that installsvalidation-plugin,setu add validationadds it on every runtime, andg module/g schemaprint the runtime’s install command (npm install zod@^4,bun add zod@^4,deno add npm:zod@^4) in a project that lacks it. -
Generated e2e tests and the
test:e2etask (M114,@setu-ts/cli).setu newemitstest/support/server.ts(CLI-owned; boots the realcreateApp()on a free loopback port) andtest/e2e/app.e2e.test.tson every socket target;setu g moduleaddssrc/modules/<name>/<name>.e2e.test.ts, which drives the module’s routes over real HTTP (and re-emitstest/support/server.ts, so a project scaffolded earlier gains it). A newtest:e2etask runs only*.e2e.test.tsover real HTTP; thetesttask keeps running the in-process unit tests without a socket. -
ValidatorMiddleware/ValidatedOutputandvalue(ctx)(M114,@setu-ts/validation-plugin).validateBody/validateQuery/validateParams/validateHeaders/validateCookiesandcreateValidationMiddlewarenow returnValidatorMiddleware<ValidatedOutput<S>>— aMiddlewareFunction(so every existing call site type-checks unchanged) with one added member,value(ctx).ValidatedOutput<S>is the schema’s parsed output type — coercions and defaults included — for Zod 3 and 4 alike, inferred from the schema, so the handler reads a typed value from the same instance it registered —const body = validateBody(S), thenbody.value(ctx)— with no cast.value(ctx)throws aTypeErrornaming the target when that validator did not run on the request or a later validator for the same target overwrote it. Thectx.state.get(validatedStateKey(...))read still works and is unchanged. -
Form-body validation (M114,
@setu-ts/validation-plugin).validateBody(schema, { forms: true })and@ValidateBody(schema, { forms: true })also validateapplication/x-www-form-urlencodedandmultipart/form-databodies with the same schema they validate a JSON body with. Off by default: a cross-site HTML<form>reaches a route that accepts forms with no CORS preflight, so pair an opted-in route with form CSRF protection (csrfFormMiddlewarefrom@setu-ts/session-plugin). Without the option a form body answers415“Unsupported Media Type”.IValidationService.middlewaregains the matching optional third parameter,ValidationMiddlewareOptionsin@setu-ts/common. The form converts to a record: a field sent once is a single value, a repeated field becomes an array in wire order, a name ending[]is always an array (kept under the name as written); values are strings or the framework’sFormFile(not a webFile, soz.instanceof(File)refuses it — check theFormFileshape instead), and numbers and booleans arrive as strings, so schemas usez.coerce. A_csrffield from the session plugin reaches the schema, where a Zod object strips it by default andforbidNonWhitelistedrefuses it. The generated OpenAPI document still describes a validated body asapplication/json. -
Reverse-proxy origin support (M113n, #473,
@setu-ts/common,@setu-ts/http-security-plugin,@setu-ts/session-plugin,@setu-ts/kernel).REQUEST_ORIGIN_STATE_KEY,RequestOrigin,requestOriginOf(ctx)andpublicRequestUrl(ctx)share the trusted public origin;forwardedHeader: 'forwarded'selects bounded RFC 7239 parsing instead ofX-Forwarded-*; sessioncookie.secure: 'auto'follows the resolved protocol (default staystrue, and'auto'conflicts withsameSite: 'none');InjectRequest.ipsupplies a peer in tests. -
OperationRequestOptionsand a per-call key on generated operations (V9-15, M113k, 0.10.0,@setu-ts/sdk).@setu-ts/sdkexportsOperationRequestOptions, whoseidempotencyKeyandsignalcarry the same meaning as the same-named fields ofClientRequest;headersis deliberately absent, because header parameters the document declares are typed fields ofopts. Every generated operation now takes an optional trailingrequest?: OperationRequestOptions, after its path parameters and afteropts; the generated code forwards only those two fields. Existing calls are unaffected, and an object implementing a previously generatedApistays assignable. -
Primary-key and cursor refusals, and a database call timeout (M113g, 0.10.0,
@setu-ts/common,@setu-ts/database-plugin).@setu-ts/commonexportsassertPrimaryKeyUnchanged, the one shared check everyupdate/updateWhereof the eight data sources runs before any I/O, and the two classes that check and thefindPageimplementations throw:PrimaryKeyChangeError(a key change throughupdate, branded400 Bad Requestwith the fixed detail “An update cannot change a row’s primary key.”) andInvalidCursorError(a cursor the backend cannot use, branded400 Bad Requestwith the fixed detail “The pagination cursor is not valid for this query.”).@setu-ts/database-pluginexportsUnknownPayloadFieldError(a Drizzle write naming a field that is not a column,400 Bad Request) andDatabaseTimeoutError, the class aDatabaseServiceinterception throws when node-postgres’squery_timeoutfires (the driver’sQuery read timeoutrejection), branded504 Gateway Timeoutwith the fixed detail “The database did not answer in time.” The statement may have run, so a write’s outcome is unknown and a retry of a write must be idempotent. -
PasswordHasherOptionsandPasswordHasher.needsRehash(M113e,@setu-ts/auth-plugin).new PasswordHasher(runtime, { iterations })sets the count for new hashes (100,000–10,000,000, refused at construction otherwise,NaNincluded);needsRehash(stored)reports a hash made with fewer iterations so the application can replace it on the next successful sign-in, and never asks to downgrade one. -
scopedRbac.principalAuthorityIsGlobalandcsrf.exemptBearer(M113e). The opt-outs for the two breaking defaults above. -
Scheduler lock reachability (M113b).
IDistributedLockgains optionalisHealthy?(): Promise<boolean | undefined>andIRedisLockClientgains optionalping?(): Promise<unknown>. The scheduler indicator addsdata.lock.reachable(true/false/'unknown'), cached for 5 s and bounded at 2 s while connected. Status remains lifecycle; shared lock outages do not change readiness. Memory locks report reachable; Redis locks probe PING and clients without it report unknown. -
Redis outage vocabulary (M113b).
@setu-ts/commonexportsisConnectionFaultandisConnectionFaultCode; cache-plugin exportsCacheStoreUnavailableError; auth-plugin exportsRateLimitStoreUnavailableError.RedisRateLimitStoregainscommandTimeoutMs. -
Messaging reachability probes (M113a). Kafka uses a dedicated short-timeout metadata client; Pub/Sub uses bounded
getTopics. Both cache probes for 5 s and bound them at 2 s, including direct health calls. Kafka/Pub/Sub gainpublishTimeoutMs; NATS gains validatedreconnect. Stopped Kafka/Pub/Sub report down; a hung probe reports unknown, which still leaves health up.
0.9.0 — 2026-10-12
Added
-
The CLI is a Node program (M111d).
setuships as an npmbin(npx @setu-ts/cli new my-app) and the new@setu-ts/createpackage (exportingcreateArgs) makesnpm create @setu-ts my-appthe same assetu new. Project TypeScript —setu.config.tsfor plugin commands, custom schematics — loads throughtsx, a CLI dependency. An npm-target scaffold (Node, Bun, Workers) carries its own tooling:check(tsc, TypeScript~6.0.3),lint(ESLint withtypescript-eslint) andformat/format:check(Prettier); everything the CLI writes into such a project is Prettier-formatted, so it passes its ownformat:checkfrom the first run. A selected broker, queue or gRPC transport now adds its client package (ioredis,amqplib,nats,kafkajs, the cloud SDKs, Connect) to the project — package managers never install an optional peer, so such a scaffold used to fail at its first boot. The generated migration runner (src/migrations/run.ts, run withnpx tsx,bun runordeno run -A) and gRPC code generation (proto:gen) run on every target. -
npm publishing machinery (M111c). All 51 manifests gain publish metadata,
srcand LICENSE in their tarballs, and auditedsideEffectsdeclarations. The decorator metadata module is retained; other members declarefalse. Publishing packs with pnpm, checks the packed manifest and registry version, and publishes with npm; CI separates gates from the OIDC publish job. README-only placeholders support the future one-time bootstrap. Nothing is published here. JSR’s dry-run/slow-type and@module-first checks are replaced by the TypeScript build, npm dry run, and non-empty README check. Historical@sinceclaims stay on JSR through 0.8.x; later claims read npm tarball sources. Compatibility declares all 51 npm packages and arms on publish. -
Package tests on Node 24 (M111b). Every member uses Vitest, source export resolution, TC39 decorator lowering, shared-resource isolation, and enforced per-file V8 coverage. Deno-only runtime proofs retain a named lane; root tooling stays on Deno. The reproducible codemod, host helpers, count parity report, and CI lane guards accompany the move.
-
A Node build for every package (M111a). Every workspace member builds with TypeScript
6.0.3(tsc -b) todist/, with a generatedpackage.json(ESM only,engines.node >=22,exportsper subpath,workspace:^internal dependencies) andtsconfig.json, under a pnpm workspace.scripts/node-manifests.tsgenerates them — and each member’sdeno.jsonimports, from the same range table — from the member’s sources, and refuses drift (--check); every lazily imported driver is an OPTIONALpeerDependency, and@hono/node-serveris a regular dependency of@setu-ts/runtimeso a bare install can serve on Node.deno task check:node-buildpacks every member, refuses a tarball still namingworkspace:, installskernel,runtimeanddecorator-pluginwith plain npm into an empty project, and serves a request and atsc-compiled decorated controller on Node 24, Node 22 and Bun; a new CI job runs it with--require-all. Nothing is published to npm yet (111c). The Deno toolchain and every Deno gate are unchanged. -
@setu-ts/decorator-plugininstallsSymbol.metadataon Node and Bun (M111a). Neither runtime defines it, and TypeScript’s TC39 decorator emit creates decorator metadata only when it exists, so atsc-compiled decorated class crashed at definition with a bareTypeError. Importing the package now installsSymbol.for('Symbol.metadata')when the symbol is absent (never overwriting one — a no-op on Deno), and a decorator that still finds no carrier throwsDecoratorMetadataUnavailableErrornaming both remedies. It is the one module-level exception to AI_GUIDELINES §11.5. -
Per-field redaction in a policy (
@setu-ts/common, M101h).RedactionPolicy.fieldsaccepts aFieldRedaction—{ classification, redactor? }— as well as a classification string, so one classification can carry two treatments (mask the email, erase the name, bothpii) without an invented classification string. A newFieldRedactionexport names the object arm, andcreateRedactionServiceselects a redactor in this order: the field’s ownredactor, thenredactors[classification], thendefaultRedactor, theneraseRedactor. Every existing string-valued policy behaves byte-identically. One reader-side change: code that READSpolicy.fields[path]as aDataClassificationnow sees the union and must narrow withtypeof value === 'string'. A field’sredactorandclassificationare read only as own properties: an inheritedredactor(a pollutedObject.prototype, or an entry built withObject.create) is ignored, and an entry without its own stringclassificationis erased. -
Conditional repository writes (M105). Optional
updateWhereanddeleteWhereonIRepositoryandIDataSource, with sharedWritePreconditionandcheckWritePreconditionvalidation (WritePreconditionCheck), native adapter conditions, bounded Cosmos version guards and Bigtable newest-cell checks.checkWritePreconditionreturns private copies of the predicate and payload, and every implementation writes the copies, so the predicate the backend receives is the one that was validated. Non-finite numbers and an own__proto__field (in the predicate or the payload, which Node and Bun drop when MongoDB or Prisma rebuild the object) are refused.checkWritePrecondition’s refusal reasons quote no caller field name or value, and Prisma’s validation message, which renders the whole query, is replaced with a fixed sentence; other adapter and driver refusals may quote caller input, in server logs only, as for every repository operation. Tenant, outbox and inbox bridges use conditional writes when supported and retain their fallback for other sources. -
Scoped RBAC — a role held in a tenant, organisation, team or region (
@setu-ts/auth-plugin,@setu-ts/decorator-plugin,@setu-ts/database-plugin,@setu-ts/common, M110b). The newAuthPluginOptions.scopedRbac(requiresrbac) defines one built-in policy,scoped-rbac, on the M110a evaluator — no new capability token.requireScopedRole(any-of) andrequireScopedPermission(all-of) guard a route in the request’s scope, taken fromscopeFromTenant()(the default),scopeFromParam(param, type), a fixedScopeRefor a function;@ScopedRoles/@ScopedPermissionsare the class form, validated atregister(). Grants come fromstatic,claimsandcustomsources, unioned, and one failing source denies the check;inheritsFromwalks parent and delegating scopes with cycle refusal and bounds (child requests inherit parent grants); a route scope naming another tenant than the resolved request tenant denies;grantableInlimits where a role counts;customRoleslets a scope define roles bundling catalogue permissions;timingis'request'(default), a TTL cache, or'sign-in'(stored under a private session key, never in claims, and carried through a pending second factor); acustomsource receives only the principal’s id andissclaim, and aclaimssource is mapped from the credential on every check and never memoised, cached or shared, because two tokens for one principal can carry different claims. A scoped guard or decorator naming a permission or role outside the catalogue fails at startup.commonaddsScopeRef,ScopedGrant,GrantQuery,IGrantSource,IScopedRoleSource,ScopedRoleDefinition,ScopedRbacTarget,ScopeSource,SCOPED_RBAC_POLICY,scopedPermissionAbility,scopedRoleAbility,isScopeType,MAX_SCOPE_ID_LENGTH,scopeFromTenantandscopeFromParam;database-pluginaddscreateDatabaseGrantSourceandcreateDatabaseRoleSourcewithDatabaseGrantSourceOptions,DatabaseRoleSourceOptions,GrantFieldsandRoleFields(verified against real PostgreSQL and MongoDB).auth-pluginexports the option typesScopedRbacOptions,GrantSourceConfig,StaticGrant,ClaimsGrantMapper,ScopedRoleLimit,ScopedRbacTimingandScopedGuardOptions, andGrantResolutionError(a503hint, with aGrantResolutionReason), which a sign-in-timing source failure rejects with. The namescoped-rbacis reserved:AuthPlugin({ policies })refuses a policy of that name. Nothing existing changes, so there is no upgrade step. -
Authorization policies — an asynchronous, target-aware check (
@setu-ts/auth-plugin,@setu-ts/decorator-plugin,@setu-ts/common, M110a). Roles answer “does this principal hold this role, anywhere”; a policy answers “may this principal do this, to this target”.commonaddsCAPABILITIES.AUTHORIZATION_POLICIESand theIAuthorizationPolicyServicecontract (can,authorize,describe,define) withPolicyDefinition,PolicyAbility,PolicyCheck,AnonymousPolicyCheck,PolicyRef,PolicyAbilityInfoandPolicyTarget, plusauthorizationFailureInit, now the one owner of the refusal status, title and detail thatrespondWithAuthorizationFailurewrites.AuthPluginalways registers the policy service and gains apoliciesoption;auth-pluginaddsdefinePolicy, therequirePolicyroute guard, and theAuthorizationDeniedError(withPolicyDenial) andUnknownPolicyErrorrejections, and re-exportsIAuthorizationPolicyService.decorator-pluginadds the class form —@Policy,@Ability(withAbilityOptions),DecoratorPluginOptions.policies— and@RequirePolicy, typed byPolicyClassAbilityandPolicyClassTarget. The evaluation rules are fixed: only a literaltrueallows; a throwing check denies and is logged once, never with the target; an anonymous request is refused401unless the ability opted in with{ anonymous: true, check }; for a signed-in principalbeforeruns first (trueallows,undefinedfalls through, anything else denies); a denied signed-in principal gets403with the same bodyrequireRolewrites, and a thrownauthorizedenial gets that body too undererrorHandler. ArequirePolicyguard naming an unregistered policy or ability failsapp.start(), after which the policy registry is sealed;@RequirePolicyis validated atregister(), including against a same-named policy that declares an ability differently. Both refuse an anonymous request to an ability that needs a principal BEFORE running the target extractor, so an unauthenticated caller costs no lookup and cannot probe which records exist — the guard reads that from the registered policy on every request, so a route added afterstart()is covered too; anundefinedprincipal is anonymous; and anUnknownPolicyErrortruncates names over 128 characters. Both are branded for OpenAPIderiveSecurity. New guide:docs/authorization.md. Not breaking: a new capability, new types and new exports — no existing interface gains a member. -
Idempotency tier C — the work and its record committed together (
@setu-ts/idempotency-plugin,@setu-ts/database-plugin,@setu-ts/common,@setu-ts/sdk, M109b). A newIIdempotencyService.within(options, fn)opens ONE database transaction, creates a claim row in it FIRST, runsfnwith that transaction’s unit of work, creates a result row holdingfn’s JSON result and commits; a repeated key whose record is committed returns the stored result without runningfn, and a concurrent duplicate loses the race on the claim’s primary key and its business writes roll back with it — then it replays, or, where the backend reports a write conflict before the winner committed, rejects with a retryable409. The plugin gainsIdempotencyWithinError(withIdempotencyWithinErrorReason),IdempotencyVerifyTimeoutError,TransactionalIdempotencyOptionsandTransactionalIdempotencyPurgeOptions;commongains the portITransactionalIdempotencyStorewithTransactionalIdempotencyClaim,TransactionalIdempotencyRecord, theIDEMPOTENCY_RECORD_KINDdiscriminator, and theIdempotentWithinOptions/IdempotentWithinResultoption and result types;database-plugingainscreateDatabaseIdempotencyStore(withDatabaseIdempotencyStoreOptions) andTransactionalStoreUnavailableError; and@setu-ts/sdkgainsClientOptions.idempotency(ClientIdempotencyOptions) andClientRequest.idempotencyKey, so a keyed request keeps ONE key across every retry attempt (a request that already carries the configured header uses it as the key) and may retry any method and409, but keyedPOST/PATCHrequests are never retried after a2xxresponse arrives, including a body-read failure or a throwing response interceptor; the original thrown value reaches the caller unchanged. On both the first call and a replay,withinreturns the JSON round trip of whatfnreturned: dates become ISO strings,undefinedobject members are absent, and nestedtoJSON()output is respected. The service also exposespurgeTransactional()for retention cleanup. On PostgreSQL (Prisma), a concurrent loser blocks on the winner’s claim insert, which the adapter’stransactionTimeoutdoes not interrupt, and then replays; a loser whose own work exceeds that timeout answers'store-failed'(503), with no timeout-to-409mapping. -
A “How It Fits Together” guide (
docs/how-it-fits-together.md, PR #429). One page answers the questions a newcomer hits first: the orderstart()andstop()run in, why a capability lookup misses and what each error message is telling you, whether a service lives in the service registry or the DI container (and howDiPlugin’sautoRegisterfallback links them), and whether a test should useoverrideCapability,overrideProviderorwithout. Every example compiles under the guide fence gate. -
A Cloudflare Durable Object idempotency store (
@setu-ts/cloudflare-plugin, M109a). The plugin gainsDurableObjectIdempotencyStore(the Worker side, over adurable_objectsbinding and a requirednamespace) andIdempotencyObjectCore(the Durable Object side, withIIdempotencyObjectStateandIdempotencyObjectCoreOptions), plusDurableObjectIdempotencyStoreOptions. One object per store key, one alarm per record, and an input gate that keeps each claim atomic. -
@Idempotent()makes a decorated route idempotent (@setu-ts/decorator-plugin, M109a). The decorator plugin gainsIdempotent, which records a route’s idempotency options and appends the provider’s middleware LAST — after guards, declarative authorization, the middleware band and the validation band — so an invalid or unkeyed request never consumes a key. A safe-method route (GET/HEAD/OPTIONS) or a missingCAPABILITIES.IDEMPOTENCYprovider failsregister(). -
Idempotency contracts in
@setu-ts/common(M109a).CAPABILITIES.IDEMPOTENCY('idempotency') names the provider of the idempotency service. The package gains the store portIIdempotencyStoreand itsIdempotencyClaimRequest,IdempotencyClaimResultandIdempotencySettleResulttypes, the service contractIIdempotencyService, the route option typeIdempotentRouteOptionswithIdempotencyKeySourceandIdempotencyFingerprintSource, and the ingress option typesIdempotentIngressOptionswith its shared halfIdempotentIngressCommonOptionsand theIngressIdempotencyKeySource/IngressIdempotencyFingerprintSourcesources.IngressContextgains an OPTIONALconsumerdispatch identity so per-consumer state (an idempotency record) is keyed per subscriber rather than per topic. The mechanism itself ships in@setu-ts/idempotency-plugin. -
A repeat of a completed HTTP request, queue job or broker message is answered from its record or skipped (
@setu-ts/idempotency-plugin, M109a). This is deduplication, not a single-execution guarantee: a failure after a handler’s external side effect but beforecompletelets a retry repeat that effect, which only a derived key forwarded to a de-duplicating provider avoids. The new package exposesIdempotencyPlugin, which registers anIIdempotencyServiceunderCAPABILITIES.IDEMPOTENCY;idempotent()for a route’smiddlewarearray andidempotentIngress()for queue/broker behaviours; andderivedIdempotencyKey(), which a handler forwards to a provider that de-duplicates. It ships an in-process store, a Redis store (atomic Lua, an inject-or-lazy ioredis client, a connect-timemaxmemory-policywarning and a requirednamespace) and, from@setu-ts/cloudflare-plugin, a Cloudflare Durable Object store. A refusal carriesIdempotencyRefusedErrorwithIdempotencyRefusalReason; a bad option carriesIdempotencyConfigurationError;IdempotencyPluginOptions,IdempotencyStoreConfig,IRedisIdempotencyClient,IDEMPOTENCY_KEY_HEADERandIDEMPOTENT_REPLAYED_HEADERcomplete the surface. Theidempotencyhealth indicator reports lifecycle truth then a bounded store probe. -
A full-stack project’s
devtask serves route edits without a restart, on Deno, Node and Bun (@setu-ts/cli,@setu-ts/react-router-plugin, PR #426).setu new --template full-stacknow emits adev.tsentry and adevtask (deno task dev,npm run dev,bun run dev); a Workers project gets neither, since the entry runs Vite in-process. On Deno, Vite’s development SSR runner could not load a route that imports a@setu-tspackage: it resolves imports throughnode_modules, so every page answered500withCannot find module '@setu-ts/react-router-plugin', and a package it loaded itself would be a second copy whosecontextKeyFor()keys match nothing. The newviteDevExternals()resolves those packages with the runtime’s own resolver and keeps them external in the SSR environment; a client import, which a browser could not load from that specifier, is left to Vite. On Node and Bun the packages are innode_modules, which Vite externalises already, so the samedev.tsruns unchanged there. The plugin is typed structurally (ViteDevExternalsOptions,ViteDevExternalsPlugin,ViteDevExternalId,ViteHookContext,ViteResolvedEnvironments), so the package still imports no Vite. Verified on all three runtimes against the published packages, and on Deno against the workspace sources too:/loginrenders with its CSRF token, client modules load through the app port, an edited route is served on the next request, and SIGINT stops the app and Vite with exit code 0. If either fails to stop, the failure is logged and the process exits 1. -
overrideProvider()replaces a DI container provider in a test (@setu-ts/testing, PR #426). WithDiPluginregistered,DecoratorPluginputs each@Injectableclass into the container, whereoverrideCapability()cannot reach it, and the container refuses a second registration.overrideProvider('pricing-service', { useValue: fake })registers the double before the real class, so a decorated controller is constructed with it. A mistyped token, an application without a container, or a provider registered too early failsstart()instead of testing the real class. A child scope fromcreateScope()keeps the double even when it registers the token itself. -
Publish options (M106, PR #427).
IMessageBroker.publishtakes an optional third argument, typedPublishOptions—orderingKey,deduplicationIdandheaders— carried asx-setu-*transport headers (ORDERING_KEY_HEADER/DEDUPLICATION_ID_HEADER) beside each broker’s native primitive (Kafka messagekey, Pub/SuborderingKey, NATSNats-Msg-Id, Service Bus and RabbitMQmessageId), so the options are observable throughMessageMetadata.headerson all seven brokers and onWorkersBroker— the one exception is aNatsBrokerwith an injected connection and noheadersFactory, which drops caller headers and the ordering key (reported once) while still applying the de-duplication id as nats.js’s nativemsgID.enableMessageOrderingcreates the Pub/Sub broker’s own subscriptions with ordering; it is accepted only where the broker loads the SDK, and refused beside an injectedclient. The Pub/Sub SDK facade’s topic handle gains an optionalresumePublishing, so a hand-built module double still type-checks.publishIntegrationEventtakesPublishOptionsas an optional sixth argument and passes the envelope id as the default de-duplication id, anddefineIntegrationEventtakes an opt-inorderingKeyselector, called only when the caller supplied no key. Invalid options are refused by name, case-insensitively covering the header names a broker or its server acts on, and a refusal never echoes the refused value.commongains the shared rule set —publishIdProblem/isValidPublishIdwithMAX_PUBLISH_ID_BYTESfor the ids, andpublishHeaderNameProblem/publishHeaderValueProblemwithMAX_PUBLISH_HEADERS,MAX_PUBLISH_HEADER_NAME_BYTES,MAX_PUBLISH_HEADER_VALUE_BYTES,RESERVED_HEADER_NAMESandRESERVED_HEADER_PREFIXESfor the headers, andparsePublishOptions(returningParsedPublishOptions) — the one copy-once parse of a whole options object — so the seven brokers,WorkersBrokerand the envelope reader enforce one copy of every rule.WorkersBrokercarries the caller’sheaderson the envelope beside the two ids. Header names are bounded at 255 bytes, the AMQP limit, so a name is accepted on every broker or refused on all of them. The Pub/Sub adapter keeps at most 1024 idleTopichandles, evicting the least recently used one with nothing in flight. The docs state the guarantee honestly:orderingKeydecides placement, not the order handlers finish in. -
Outbox relay fencing (M107b). Versioned per-row leases and native conditional claims on every supported backend; held-version status writes, including immediate deletion, and a pre-publish time fence. Several relays preserve per-key first-publish order under the existing commit-order and writer-clock conditions; pauses after the fence and late broker acceptance remain duplicate windows. New
relay.claimLeaseMs(30 000) andmaxClockSkewMs(5 000);sweepDeadlineMsis now 30 000 with strict headroom over the claim/publish/status reserve. Unsupported conditional sources fail startup. The unreleased M107 schema requires 64-bitclaimVersionandleaseUntil; old SQL DDL is refused and old JSON rows without the fields are poisoned asinvalid-row.MAX_SAFE_INTEGERis exhausted; max-minus-one can be claimed exactly. The store bridge reads every integerbigint(Prisma Client’s type for aBigIntcolumn) as a number, so a Prisma-mapped outbox is not poisoned row by row.not-pending.sentByis removed, while the stored diagnostic remains. Health usesrelay-overlapforfenced,claim-lost,duplicate, andoutbox_overlaps_totaluseskindinstead oforigin. A shared scheduler lock now saves redundant scans; its TTL rule no longer bears on correctness. Retry clears the lease andlastErrorand keeps the version; shutdown failures preserve the claim. -
Transactional outbox (M107,
@setu-ts/messaging-plugin).MessagingPlugin({ outbox })writes an integration event as a row in the SAME database transaction as the business change —IOutbox.write(uow, definition, payload, input?)through the caller’s own unit of work, with the envelope, its ordering key and its deduplication id fixed at write time through one implementation shared withpublishIntegrationEvent— and relays committed rows afterwards: at least once, per ordering key in write order among committed rows, and never exactly once. The relay is a scheduled job (outbox-relay) that reads the PENDING set in keyset-paged laps rather than a watermark (a row that commits behind the relay is still published — measured on real PostgreSQL), blocks a key behind a failed or backed-off row, bounds each sweep with one deadline, drains on shutdown before any close hook, purges settled rows on its own interval (outbox-purge,retainSentMs), re-parents each publish to the trace that wrote the row, and reports anoutboxhealth indicator and optional metrics.IOutbox(write,dispatch,sweep,purge,release) is registered underCAPABILITIES.OUTBOX; the options areOutboxOptions(OutboxCommonOptionsplus exactly one ofstoreandstores, withOutboxStoreEntry),OutboxRelayOptionsandOutboxHealthOptions;writetakesOutboxWriteInputandsweepresolvesOutboxSweepResult. Refusals areOutboxEnvelopeTooLargeError(inside the transaction, so the business write rolls back),OutboxRelayUnscheduledError(a scheduled relay with noCAPABILITIES.SCHEDULER),OutboxUnknownTenantError,OutboxRowStateError(releaseof a row that is notfailed),OutboxNotReadyErrorandOutboxStoreVerifyTimeoutError(a store’s startupverify()did not settle withinrelay.storeTimeoutMs). On Cloudflare Workers the relay runs from a Cron Trigger withrelay: { schedule: false }andbackground: waitUntil. Driven against real PostgreSQL with real RabbitMQ 4 and Redis Streams (including a process dying between a publish and its status write), a MongoDB replica set, DynamoDB Local, the Bigtable emulator, the Cosmos emulator, and the real OpenTelemetry SDK; the Workers composition is driven at unit level, not on real workerd. -
The outbox store port in
@setu-ts/common(M107).IOutboxStore—append,scanPending,failedKeys,claim,markInvalid,markSent,markFailure,release,stats,purgeandverify, every method rejecting rather than throwing — withIOutboxWriteScope(the one-method slice of a unit of work the write needs, satisfied bydatabase-plugin’sIUnitOfWork), the row shapeOutboxRecordandOutboxStatus,OutboxKey,OutboxTransition,OutboxStoreStats, the'setu-outbox'discriminatorOUTBOX_RECORD_KIND, and the tokenCAPABILITIES.OUTBOX('outbox',outbox.<name>for a named messaging instance). It is incommonso a database package can implement it without importing the messaging plugin. -
createDatabaseOutboxStorein@setu-ts/database-plugin(M107). The shippedIOutboxStoreoverIDatabaseService, as aRegistryFactory(DatabaseOutboxStoreOptions:entity, default'Outbox', anddatabasefor a named connection). Every row carrieskind: 'setu-outbox'and every read requires it, so an outbox sharing a table or Cosmos container with business documents never touches them; transitions write only from the expected status. Itsverify()refuses a backend that cannot serve the relay withOutboxStoreUnavailableError, naming the reason: Bigtable, a DynamoDB entity without the{ partitionKey: 'status', sortKey: 'position' }GSI, a standalone MongoDB, or a missing or unreadable table. The DDL for PostgreSQL and SQLite/D1 is in the messaging-plugin README. -
Consumer inbox (M108,
@setu-ts/messaging-plugin).MessagingPlugin({ inbox })plusonIntegrationEvent(definition, handler, { inbox: { consumer } })applies an integration event’s database writes once per consumer: a marker keyed by(consumer, topic, envelope id)is created in the SAME transaction as the handler’s writes, and the handler receives that transaction’s unit of work as a fourth argument (IntegrationEventInboxHandler, its type inferred from the annotation). A duplicate delivery is acknowledged without running the handler; a failed handler leaves no marker, so the redelivery runs it again; after any rejection the marker is re-read to tell a lost race from a real failure. The brokerqueuedefaults toinbox.plus a 16-hex-character hash of the consumer and the topic. With theinboxoption the call returns aRegistryFactory<SubscriptionDefinition>resolved atonInitafter the store is verified; without it nothing changes. OptionalmaxAttemptscounts failures outside the transaction and parks a delivery at the limit (so a Kafka partition or a NATS consumer moves on);IInboxlists parked deliveries and releases them (retryhands back the envelope to re-publish,discardkeeps them skipped). A scheduled purge (inbox-purge,retainMs) — which deletes parked markers past the window too — and aninboxhealth indicator ship with it. Refusals:InboxNotConfiguredError,InboxConsumerConflictError(two handlers sharing a consumer name on one topic),InboxNotReadyError,InboxPurgeUnscheduledError,InboxStoreVerifyTimeoutError,InboxRowStateError. The types areInboxOptions(withInboxPurgeOptionsandInboxStoreEntry),IntegrationEventInboxOptions,IntegrationEventSubscribeOptions,ParkedInboxEntryandInboxReleaseResult. Driven against real PostgreSQL with real RabbitMQ 4 and Redis Streams (duplicates, a failure rolled back and redelivered, two consumer groups, and an outbox row relayed twice handled once), a MongoDB replica set and the Bigtable emulator; D1 at unit level over real SQLite. -
The inbox store port in
@setu-ts/common(M108).IInboxStore—find,run,recordFailure,park,parked,release,stats,purgeandverify, every method rejecting rather than throwing — withInboxRecord,InboxStatus,InboxIds,InboxFailureUpdate,InboxReleaseOutcome,InboxStoreStats, the'setu-inbox'discriminatorINBOX_RECORD_KIND, and the tokenCAPABILITIES.INBOX('inbox',inbox.<name>for a named messaging instance). -
createDatabaseInboxStorein@setu-ts/database-plugin(M108). The shippedIInboxStoreoverIDatabaseService, as aRegistryFactory(DatabaseInboxStoreOptions:entity, default'Inbox', anddatabase). Every row carrieskind: 'setu-inbox'and every read requires it. Itsverify()refuses Cosmos DB and Bigtable by adapter arm or class, then runs a two-row transactional probe that always rolls back, refusing a standalone MongoDB and a one-row-per- transaction adapter withInboxStoreUnavailableError. The DDL for PostgreSQL and SQLite/D1 is in the messaging-plugin README. -
SpanOptions.rootin@setu-ts/common(M107). An optionalroot: truestarts a parentless span, ignoring bothparentContextand the active span;@setu-ts/telemetry-plugincarries it to OpenTelemetry’s ownSpanOptions.root. The outbox relay uses it for a row with no valid storedtraceparent, so a sweep dispatched from inside a request no longer attributes that row to the request. AnITelemetryServiceimplementation that ignores it starts the span under the active span. -
DuplicateKeyErrorin@setu-ts/common(#420). A write that would duplicate a primary key or a unique index, branded409 Conflict. It carries the targetedentitywhen known and the driver error ascause, and it is not retryable. It lives incommonso@setu-ts/cloudflare-plugin’s D1 adapter, and any store that records “already processed” with a unique insert, can raise or recognise it without importing@setu-ts/database-plugin. -
Scaffold testing (M101g, V8-12, V8-39). Generated factories return
IKernelApplication, or its promise for starter compositions, socreateTestApp({ app: await createApp() })type-checks.setu add testingpins@setu-ts/testingin Deno’s import map or npm’sdevDependencies. New Deno, Node, and Bun scaffolds include a smoke test of the real application composition; Workers requires its platform environment and permits an empty test suite. Devtool factory-upgrade instructions name the kernel type and import. Existing projects: replace the factory’sIApplicationreturn annotation withIKernelApplicationfrom@setu-ts/kernel(seedocs/upgrading.md). -
Wired scaffolds (M101g). Workspace full-stack SSR builds externalize library package identities — declared in
deno.jsonordeno.jsonc, with their exported subpaths; projects that acquire npm dependencies ignore node_modules. Adding safe zero-configuration plugins wires both generator styles, starter compositions name their option arm, and dependency additions preserve the manifest’s existing order. -
Development source policies and browser gate (M101g). Opted-in scaffolds and devtool enable generate names-only diagnostics approval maps, gated off in production; adding a plugin refreshes an existing policy. A dedicated pinned Chromium gate exercises both the example and a fresh scaffold, including missing-client and missing-asset controls. Full-stack configurations select same-origin referrer policy for native form actions; existing projects can configure
httpSecurity: { headers: { referrerPolicy: 'same-origin' } }. CSRF checks stay enabled. -
Devtool lifecycle (M101f). Enabling verifies both composition spreads and refuses mismatched framework pins. Pin and edited-entry refusals escape project-controlled fields so embedded newlines cannot forge additional CLI output lines. The entry detects dropped composition and stops with a named error. Workspace connectors use a recorded
devtoolBasePortrange, and application ports stay in their own sequence after a connector is allocated (skipping, never walking past, a connector port); reallocation updates CLI-rendered development entries and refuses edited entries before writes. Standalone defaults probe 4919–5019; the generated README reports the selected port; subsequent enablement retains the existing entry port even when availability changes. New entries are formatted; an unedited entry rendered by the 0.8.0 CLI is still recognized as CLI-owned, so reallocation and re-enabling upgrade it instead of refusing it as edited. Production images excludemain.dev.tsand remove the development connector pin before installing the remaining import map, including JSONC and inherited-import layouts. Diagnostics aliases now refuse Unicode format (Cf), line separator (U+2028) and paragraph separator (U+2029) characters throughhasForbiddenAliasCharacterin common, in addition to controls (Cc), and the twelve refusal messages now read “contains a control, format or line-separator character”; replace bidi, zero-width and separator aliases with printable names. Re-enabling an unchanged workspace member now reports that nothing changed rather than “Enabled the devtool”. Launcher dependency: thesetu-ts-devtoolframework pin must be updated to the merge commit and its recipe catalog must retain the previous entry renderings, because the probe changes the generated entry text. This repository does not change that pin. -
CLI write safety (M101e). Writing commands now cooperate with SIGINT and roll back partial batches, interactive cancellation exits
130, generation refuses non-project directories and workspace roots, JSONC manifests are read safely, andsetu addrefuses runtime-incompatible plugins. Interrupted adoption restores relocated project files as well as workspace writes; plugin commands stop awaiting their handler and run application shutdown before returning130. Adoption supports prototype-based filesystem adapters and retains exit130when nested recovery fails. JSONC reads preserve comment token boundaries instead of accepting malformed literals. -
BREAKING: Programmatic CLI prompts (M101e).
Prompter.selectnow returns the discriminatedPromptSelectiontype instead of a string orundefined. Implementors must wrap answers as{ kind: 'answer', value }and returnunavailableorcancelledfor the two no-answer cases. This change ships in the0.9.0minor. -
Tenant data-store bridge (M101c, V8-8).
@setu-ts/database-pluginnow ships the oneITenantDataStoreover a real backend:createDatabaseTenantDataStore()returns aRegistryFactorythe multi-tenancy plugin resolves inonInit, soDatabasePluginmay be registered before or after the tenancy plugin. It returns aDatabaseTenantDataStoreoverservice.getRepository(entity), supporting the'column-per-tenant'strategy only — the tenant column is conjoined to every read (spread last, so a caller’s filter cannot override it) and stamped on every write, and stripped from everyupdatepayload so no write can move a row between tenants. The column comes from the plugin’s isolation strategy ('tenant_id'by default,database: new ColumnPerTenant('org_id')for another), and'schema'/'database'throwTenantStoreStrategyUnsupportedErrorbecauseIRepositoryoffers no schema or database switch. Lookups by key go through the repository’s ownfindById, so an entity whose primary key is notidis addressed the way its adapter is configured, and the tenant column is checked on the row.MultiTenancyPluginOptions.dataStorewidens toITenantDataStore | RegistryFactory<ITenantDataStore>; a factory is resolved once atonInit, validated, and handed the strategy, and the service’s late-binding slot throwsTenantDataStoreNotReadyErrorfor a repository call made beforeonInit. The port typesITenantDataStoreandITenantIsolationStrategymove to@setu-ts/common(the multi-tenancy plugin re-exports both), so the two plugins can type the bridge across the §2.2 boundary. -
Session tenant binding, two-sided (M101c, V8-7). The binding compare now runs on whichever side sees the tenant second: the session middleware keeps its load-time compare, and the tenant middleware compares the session it finds in
ctx.stateright after it resolves a tenant. Both sites share the puretenantBindingMismatch(session, tenantId)in@setu-ts/common, which also owns the keys the two packages must agree on byte-for-byte:SESSION_STATE_KEY,SESSION_TENANT_BINDING_KEY, andSESSION_TENANT_BINDING_STATE_KEY, which the session middleware sets totruewhentenantBindingis on. The tenant side compares only then, soSessionPlugin({ tenantBinding: false })disables both compare sites in every middleware order. -
CSRF path exclusion (M101c, V8-9).
CsrfOptions.exclude(http-security) lists paths — exact string orRegExp— that skip the CSRF check entirely, checked before the method test so an excluded path is never inspected; it matches the session plugin’s existingCsrfFormOptions.exclude. The documented SAML recipe becomesexclude: ['/auth/<provider>/acs']on BOTH plugins, because an origin allowlist cannot admit an IdP that servesReferrer-Policy: no-referrer(Keycloak does): the browser posts the ACS withOrigin: null, andtrustedOrigins: ['null']would admit every opaque-originPOSTon every route. -
Versioning policy from
0.9.0, with two gates. A patch is the normal release and breaking changes are batched into an occasional minor (README “Versioning”, ROADMAP “Versioning Policy From0.9.0”).release:verifygains check 10 (scripts/release-shape.ts): it classifies the bump from the previous tag and refuses a patch whose shipping changelog section carries aBREAKINGentry, and a minor whose section carries none unless--allow-quiet-minoris passed.deno task check:planrequires every plan’s “Exported surface” section to carry a**Breaking for implementors:**statement —none, or the break and the minor that carries it. No package source changed. -
Release tooling:
release:bumpandrelease:verifycheck 9.deno task release:bump <version>moves every site a version bump has to touch — discovered by package name wherever a reference carries one, enumerated with its reason where it does not — and re-sweeps the result withcheck:versions’ own reader;--dry-runprints the plan.release:verifygains check 9 (scripts/changelog-prs.ts): every pull request merged since the previous tag is represented in the shipping changelog section, and no milestone PR’s entry sits under an already-published heading — the two manual release-cutting checks that each saved a release. CI’s publish-dry-run job checks out full history so the merge log is reachable, and the release workflow calls therelease:verifytask rather than an inline copy. No package source changed. -
Service-call agreement (M101d). The SDK adds
createTraceContextInterceptorandClientRetryPolicy; the full-stack starter addsfullStackConfigOfandFullStackConfigUnavailableErrorfor the exact composition snapshot; andcontextToTraceparentaccepts the newTraceparentSourceshared by both telemetry context shapes. -
withDeadline,deadlineRangeErrorandDeadlineOptionsincommon(M101a). A bound on one backend call whose expiry is a REJECTION with the caller’s own error, never a swallowed timeout: the call receives anAbortSignal, is raced against the deadline in case it ignores the signal, and its own rejection is never masked. It is the request-path counterpart ofcreateCachedProbe, which never rejects.deadlineRangeErrorlets an option holder refuse a bad bound at construction. -
New bound options (M101a).
CachePlugin’s Redis store andQueuePlugin’s'redis'adapter takecommandTimeoutMs(default15000,0disables), applied as the iorediscommandTimeouton the client they build.SchedulerPlugin’sDistributedLockOptionstakeacquireTimeoutMs(default5000,0waits) andcommandTimeoutMs(defaultacquireTimeoutMs, or15000when that is0). Thevaultsecrets provider takesrequestTimeoutMs(default5000). EachcommandTimeoutMsapplies only to a Redis client the plugin builds, never an injected one;acquireTimeoutMsbounds every acquire, an injectedlockincluded, andrequestTimeoutMsbounds an injectedhttptoo. An out-of-range value — includingNaN, whichNumber(env.X)yields for an unset variable — throwsRangeErrorat startup. -
SecretProviderUnavailableError(M101a). Exported by@setu-ts/secrets-pluginand carrying a503status hint, so an application runningerrorHandleranswers an unreachable provider with a retryable503instead of a masked500. The transport error is kept ascauseand never reaches the response body. -
Mail bodies rendered through the view engine (M102).
MailPluginOptions.templatesgains a component arm beside the released string arm:{ view, text? }, whereviewandtextareComponents (a JSX function, anhtmltagged template, or a plain(props) => string) rendered through theIViewEngineregistered underCAPABILITIES.VIEW, withsendTemplate’sdatapassed verbatim as each component’s props.MailPlugindeclaresCAPABILITIES.VIEWinoptionalDependenciesand resolves the engine once atregister(); a component template configured with no provider fails atregister()naming both remedies, never on the first send.MailTemplateis now the unionMailStringTemplate | MailComponentTemplate(both exported), and the two arms cannot mix in one template (a compile error). Escaping is the rendering runtime’s, and the component arm performs no missing-key check — an absent key renders asundefined. The committedIMailercontract is unchanged, so every holder callingsendTemplategets the arm with no code change. -
@setu-ts/localization-plugin(M103). A new package:LocalizationPluginregisters anILocalizerunder the newCAPABILITIES.LOCALIZATION, validates every catalogue atregister()(a malformed orIntl-unknown tag, a catalogue for an unlisted locale, a malformed message, and a locale missing default keys are refused by name;allowPartialCataloguesdowngrades the last to one warning per locale), and resolves each request’s locale at middleware priority 45 — query parameter, cookie,Accept-Language(bounded parse, q-values,de-AT→de,q=0honoured under*), atenantLocaledefault, then the default — matching every candidate against the supported set only. Every governed response carriesVary: Accept-Language(plusCookiewhile the cookie source is on, a stated CDN cost thatmiddleware.cookie: falseremoves) and aContent-Languagewritten after the handler from the final locale.localizerFor(ctx)binds the localizer to the request;localeMiddlewareregisters the resolution per route group;exposeCataloguesservesGET <basePath>/:localefor browsers,private, max-age=3600by default (a session refreshing its cookie must never reach a shared cache);MissingMessageError,MissingPluralCountErrorandUnsupportedLocaleErrorare exported, with the option typesLocalizationPluginOptions(exactly one ofcataloguesandsource, a compile error otherwise),LocaleMiddlewareOptionsandIMessageSource(catalogues loaded once atregister()). The formatter and locale negotiation ship as the import-free subpath@setu-ts/localization-plugin/format(format,negotiateLocale,parseAcceptLanguage, and the typesFormatValues,FormatOptionsandAcceptLanguage), whose runtime graph a test confines to its own modules. It escapes nothing — escaping is the renderer’s — and promises one implementation, not identical output across runtimes:Intldata differs, so dates take an explicittimeZone. Zero npm dependencies. -
Localization contracts in
common(M103).CAPABILITIES.LOCALIZATION,ILocalizer,LocalizationMessage,PluralFormsandMessageCatalogue, so a plugin formatting text for a person resolves the localizer without importing the localization plugin. -
IRequest.localeandreplaceLocaleincommon(M103). The request’s resolved BCP 47 tag is a first-class optional field on thetenantprecedent, sealed by the same one-implicit-write guard (a second plain assignment throws namingreplaceLocale), withreplaceLocale(request, tag)as the deliberate escape — for example, to apply a signed-in user’s saved preference once authentication has run. Optional and source-compatible: noIRequestimplementor breaks. A reader running below priority 45 seesundefined, exactly as one below 40 sees notenant. -
@setu-ts/testing’screateTestContextcarries a seededrequest.locale(M103), sealed as the kernel seals a request, so a test seeding a locale gets it — and the same one-write guard. -
Three named real-broker errors in
@setu-ts/messaging-plugin(M101b).PubSubSubscriptionBoundElsewhereError,NatsConsumerNameCollisionErrorandKafkaTopicUnavailableError, each thrown bysubscribe()(so a declared subscription rejectsstart()) for a naming or startup rule the real broker imposes and a permissive fake accepted. -
KafkaOptions.retry/ thekafkaarm’sretry(M101b). Forwarded tonew Kafka({ retry })unless aclientis injected, and read bysubscribe()as its unknown-topic retry budget. Every field is validated at construction,factor(kafkajs’s jitter) included, held to [0, 1].
Changed
-
The installation documentation teaches npm.
0.9.0is published to npm only, while the README,PUBLIC_API.md, the getting-started, runtime-deployment, decorators and custom-plugins guides and eighteen package READMEs still taughtjsr:installs — pins that could never resolve at0.9.0. Each install line is nownpm install @setu-ts/<pkg>(bun add, ordeno add npm:@setu-ts/<pkg>on Deno), and the examples importhonorather than@hono/hono.PUBLIC_API.md’s description ofsetu addpins, the Workers manifest and the workspace root no longer mentions the@jsrscope or an.npmrc, which the CLI stopped writing in M111d. Earlier releases stay on JSR. -
Breaking:
nodeis the default generated target, and every target installs the framework from npm (M111d).setu newandsetu generate appwithout--runtimenow scaffold a Node project; pass--runtime denofor the previous default. Generated manifests pin@setu-ts/*from npm under their own names —^0.9.0inpackage.json,npm:@setu-ts/…@^0.9.0in a Deno import map — with no.npmrcand no@jsr/aliases. A Deno project setsnodeModulesDir: "auto", because the CLI imports its config from Node: rundeno installbeforesetu <plugin-command>, which otherwise refuses by name. A Deno project pinning the framework from JSR is refused the same way, pointing at the upgrade guide;setu adoptrewrites pre-0.9 pins (jsr:@setu-ts/…,npm:@jsr/setu-ts__…) onto npm and reports each. A directory with no manifest now reads as Node rather than Deno wherever the CLI detects a target. -
Breaking for embedders:
@setu-ts/cli/main(M111d).MainProcess.promptis asynchronous,(message, signal?) => Promise<string | null>, so an interrupt ends a pending question instead of waiting for Enter;denoProcessis removed andnodeProcessreplaces it. Both executables setprocess.exitCoderather than callingprocess.exit. -
Lazy-import error messages and reported specifiers name the bare package (M111a). Source imports
ioredis, notnpm:ioredis@5.x, soGrpcRuntimeLoadError.specifier, the telemetry instrumentation loaders’ reportedspecifier,SAML_SPECIFIERand the React Router load failure now read@connectrpc/connect,@opentelemetry/instrumentation-http,@node-saml/node-samlandFailed to import 'react-router'. Code matching the oldnpm:…strings must match the bare name. The install guidance in those messages is unchanged and still namesdeno add npm:…for Deno. -
Worker pool sizing (M45c, #433).
WorkerPoolPluginOptions.maxWorkersnow bounds the sum of module pools, defaulting to the largest of available parallelism, defaultPoolSize and the sum of explicit pool sizes. Multi-module applications share the cap fairly; passmaxWorkers: Infinityto restore the previous unbounded sum. Health reports the budget. -
A generated full-stack
setu.config.tstakes anssrparameter, and itsvite.config.tsexportsframeworkPackagesandworkspaceLibraries(@setu-ts/cli, PR #426).dev.tspasses the development SSR runtime ascreateApp’s third argument, which replacesassetsDir; omitted, production behaviour is unchanged. Workers projects declare the parameter as_ssrand read none of it. Existing projects are unaffected; to adopt the dev loop, copy those changes anddev.tsfrom a fresh scaffold. -
BREAKING:
DiPlugin()now defaultsautoRegistertotrue(@setu-ts/di-plugin, PR #426). With the oldfalsedefault, a bareDiPlugin()could not resolve any framework capability, so@Inject(CAPABILITIES.LOGGER)orCAPABILITIES.CACHEfailed at startup with “No provider registered for DI token”. The container now falls back to the kernelServiceRegistryfor a token it does not hold; explicit container registrations still win. Migration: passDiPlugin({ autoRegister: false })to keep resolution confined to the container. A starter’sdi: {}arm passes its options through, so@Inject(CAPABILITIES.LOGGER)— the pairing both starter READMEs show — now resolves there too.createContainer()outside the plugin is unchanged. -
BREAKING: RabbitMQ consumer retry defaults (#421). Durable messaging consumer groups now retry failures with five total attempts and tiered delays, create durable retry/dead queues (retry queues are quorum queues with at-least-once dead-lettering, so an expiring copy is never dropped), and bound unacked deliveries to prefetch 32 per consumer. Handlers must be idempotent. Configure
consumerRetry: falsefor the existing operator-DLX behavior; private queues and RPC reply inboxes retain discard behavior.ConsumerRetryOptionsnames the shared RabbitMQ/Redis shape. Retry topology reserves group suffixes.deadand.retry.<digits>ms; rename conflicting groups or disable retries. Generated names must fit 255 UTF-8 bytes. Injected recovery channels require confirms,on/offreturn listeners andclose(); without themsubscribe()on a retrying group rejects. The RabbitMQ user needs configure permission onQ.dead/Q.retry.<delay>ms, read onQ.retry.<delay>ms, and write onamq.default; otherwise the helper declarations fail with403. -
BREAKING: Redis Streams messaging recovery (#419). Failed messages now retry across restarts; handlers must be idempotent. Redis ≥6.2 is required. Injected
IRedisStreamsClientfacades must implementxpending,xclaim,xinfo, andxgroup('DELCONSUMER', ...). ConfigureconsumerRetry,reclaimIntervalMs,deadLetterMaxLen, andconsumerIdleSweepMs; seedocs/upgrading.md. -
Generated guards, class-based jobs and
setu addon a Deno project (M101g, V8-32, V8-33, V8-40). These change what the CLI writes or refuses.setu generate guard <name>now emitsrequirePermission('<name>')from@setu-ts/auth-pluginwhere it used to emit a stub that let every authenticated caller through. The permission is evaluated by the authorization serviceAuthPluginregisters only when given anrbacoption, so without one every authenticated request to a guarded route answers501.setu add auththerefore printsAuthPlugin({ jwt: { … }, rbac: { roles: {} } }). Guards generated earlier are unchanged. In a class-based project without@setu-ts/queue-plugin,setu generate jobnow refuses, namingsetu add queue, where it used to write an unwired function. On a Deno targetsetu addno longer writes annpm:@jsr/…entry intopackage.json, which there holds only the frontend build’s own npm dependencies; a Node or Bun project is unchanged.setu addandsetu devtool enablenow editsetu.config.tsonly when they can read its meaning from the file alone; otherwise they leave it untouched and print the registration line to add by hand. A configuration that imports a starter or whose import map points a@setu-ts/name at another package, a computed member access such asx['k'], an import from a package that is not a framework package or a relative file, and template literals or escapes in code all get the printed line instead of an edit. The managedsrc/devtool/diagnostics.tsdeclares every source key, so a row it stops emitting still type-checks wheresetu.config.tsspreads it. Import maps are read from the project’s manifests, anyimportMapfile and the workspace root. A backplane options object that names notransportno longer receives the development policy, and a configuration that binds its ownsourcesgets the printed line rather than the CLI’s declaration. -
RabbitMQ
publish()andqueue.add()now wait for the broker (#418). They resolve only once RabbitMQ has accepted the message, which costs one broker round trip per publish, and they can now reject — when the broker refuses the message, the channel closes first, orpublishTimeoutMsexpires — where they used to resolve unconditionally. Seedocs/upgrading.md. -
The memory adapter refuses a duplicate primary key (M101c). A
createwhose caller-supplied primary key is already stored now rejects (Entity '<name>' already has a row with this primary key), in and out of a transaction — a commit re-checks its buffered creates against rows committed since and writes nothing on a collision — where it used to store a second row that nofindById,updateordeletecould address. Every real backend already refuses this; a test that relied on inserting the same key twice must use distinct keys or let the adapter generate them. -
The memory adapter’s primary key is immutable (M101c). An
updatewhose payload changes a primary-key value now rejects (Entity '<name>': an update cannot change the primary key), in and out of a transaction; restating the same value is allowed. Rewriting a key onto another row’s key recreated the duplicate the entry above refuses. To change an identifier, delete and recreate the row. -
The tenant data-store bridge’s
findaccepts equality filters only (M101c). A filter key starting with$or a non-scalar value rejects with aTypeError, because MongoDB read them as query operators ($whereran server-side JavaScript). Anupdatewhose written row turns out to belong to another tenant — possible only if a key is reused across tenants between the ownership check and the write — rejects instead of returning the row. -
A tenant-bound session is never re-bound (M101c, V8-7). The session plugin now seals the tenant only into a session that carries no binding yet. Previously a session whose tenant was resolved after the session loaded could be re-sealed to a different tenant on commit, so a refused request re-bound the session to the tenant it was refused under. A regenerated session still adopts the current tenant, since
regenerate()drops the binding. -
SAML ACS binding checked before the request is consumed (M101c, V8-25). The binding compare moves into the per-request request-cache adapter’s
getAsync, the first point at which the library has parsed the response’sInResponseToand before any consumption. A refused peek sets a per-request flag that makesremoveAsyncreturn without consuming — load-bearing, because node-saml callsremoveAsyncon its failure path and on an unmatchedInResponseTo, which is how a foreign post consumed the victim’s request. A refused peek now answersstate-invalid(the code the case already uses) instead ofassertion-invalid. -
cloudflare-plugin’scacheApiMiddlewarekeys a localized request on its locale (M103). Its key is a URL string, which the Cache API matches with no request headers, soVarycould never separate locales there and one locale’s page was served to everyone for the TTL. Whenctx.request.localeis set, the default key is the URL text as sent with asetu-cache-localeparameter naming the locale concatenated after it (on the key only; a client-supplied copy and every encoding variant stay distinct in the key, so a client can reach neither another locale’s entry nor an entry another URL is served from). Without a locale the key is unchanged, except in the last case below. Four kinds of request are now served uncached, because no key can keep them apart from another: a URL whose text is not in its parsed form (Deno, and Node for some targets, hand the handler the request target as sent —/a/./b, a raw"in the query — while the Cache API parses a key before matching, so such a request could fill its normalized sibling’s entry), a URL carrying a fragment (Deno, Node and Bun hand it to the handler, while the Cache API ignores it when matching), a locale that is not well-formed UTF-16, and a request with no locale whose URL already containssetu-cache-locale=(its key would equal a localized request’s). Workers normalizes the URL and strips the fragment, so the first two do not arise there. As forcache-plugin, the middleware must run after the locale middleware, and a handler-timereplaceLocaleis not reflected. A customkeyis untouched and must include the locale itself. -
cache-pluginkeys on the resolved locale (M103). The cache key gains a length-prefixed locale segment fromctx.request.locale, between the tenant andvarysegments, so one locale’s cached body is never served to another. An application without the localization plugin has no locale on its requests and keeps byte-identical keys. The segment is read whencacheMiddlewareruns, so it must run after the locale middleware: a GLOBALcacheMiddlewareneeds a higher priority number than that middleware’s (45 by default, or the configuredmiddleware.priority), and where the locale middleware is applied per route, the cache must be on that route after it. AreplaceLocaleoverride is reflected only when it runs before the lookup. -
SDK-generated clients keep an error arm for every declared
3xx, the auto-follow statuses (301,302,303,307, and308) included, since Fetch returns one unfollowed when it carries noLocation. Operations that may follow a redirect returnunknown. Retry policies now cap honouredRetry-Afterdelays (retry.maxRetryAfterMs, defaulting to the policy’s largest computed backoff) and surface the original response error immediately when the hint exceeds the cap. Client construction rejects invalid/non-finite retry counts, delays, and derived exponential backoffs so malformed configuration cannot disable that cap; delays above the portable JavaScript timer maximum are also rejected so runtimes cannot clamp a long wait into an immediate retry. The client keeps a frozen copy of the validated policy, so mutating the caller’sretryobject afterwards no longer changes the cap. -
Every backend call M101a covers is now bounded by default, and an expired bound is a recorded failure. A paused or partitioned Redis keeps its socket open, so a cache, queue or scheduler-lock command used to wait forever. It now rejects inside the bound: a cache call is counted
failedand thecacheindicator reportsdown;queue.add()rejects and a poll records the failure — though the server may still apply a timed-out command, so a rejectedadd()can have enqueued the job and a retry can run it twice; a lock acquire that does not settle is a skipped fire, logged, countedlockFailedand re-armed for the next slot, so a paused backend skips fires instead of stopping the schedule. A token an abandoned acquire returns later is released, andRedisLockreleases the exact token of a timed-outSET, which can still apply once Redis answers. Verified against a real paused Redis 7. -
RedisQueuetransitions are atomic (M101a).enqueue,reserve,ack,requeueand dead-lettering each run as one Lua script when the client exposeseval— always true of the client the adapter builds. They used to run as separate commands, so a command that timed out locally but was applied by the server afterwards could leave a reserved job in neither the ready nor the processing set, lost with its payload still stored. An injected client withoutevalkeeps the separate commands. Areservethe server applies after its local timeout still leaves the job in the processing set, and nothing reclaims it, so it needs moving back by hand. Verified against a live Redis 7. -
A Vault request that fails on the network or does not answer in
requestTimeoutMsnow rejects withSecretProviderUnavailableError(503) instead of a plain error served as a masked500. The bound covers reading the response body too.connect()now refuses an address that is not an absolutehttp:/https:URL, so a malformed address fails at startup rather than as a503on every read. A Vault that answers with an HTTP error keeps its handling:404reads asnull, any other error status rejects with a plainError. -
The Vault provider encodes secret names and caps response bodies (M101a security audit). Each
/-separated segment of a name is percent-encoded, and a name with an empty,.or..segment is refused before any request —../../sys/healthused to reach another Vault endpoint with the token attached. A name that relied on a literal%,?or#reaching Vault unencoded now reads a different path. A name longer than 4096 characters once encoded is refused as an input error instead of failing in the transport and reading as an outage (503). A read body over 1 MiB is refused with a plainError, and the memory a read holds stays within that 1 MiB however small the chunks a dependency sends. Secret names quoted insecrets-pluginerror messages have C0 and C1 control characters, DEL, and U+2028/U+2029 escaped, so a name cannot start a new log line, and quoted only up to its first 256 characters. -
The
databaseindicator tells pool saturation from an outage (M101a). When a Drizzle registration’spoolStatsreports every connection busy with callers waiting, the probe queues noSELECT 1and the indicator reportsupwithreachable: 'unknown', so/readydoes not pull every saturated replica at once — but only while queries through the adapter keep completing (one within the last 10 seconds). A full pool with no completed query is a hung database and reportsdegraded. OncegetDrizzleDatabaseorgetDrizzleTransactionhas handed out the native instance, its queries bypass the adapter, so progress is unobservable and a saturated pool readsup. A pool connection timeout now reportsdegradedrather thandown— the probe used to answerfalsefor it, though the database never answered either way. Verified against a live PostgreSQL 16 pool. -
A retained Service Bus outage is answered at once (M101a). With a recorded network failure,
reachability()now returnsfalseimmediately and runs the management probe in the background, where atrueanswer clears the outcome for the next read. It used to await that probe, whose 2-second bound tied the indicator’s, so the indicator’s bound fired first and a recorded outage was reportedup. -
Queue depth observations drop a row the latest cycle did not read (M101a) instead of keeping the previous count. The source’s
failureanddepthCoverage: 'partial'say why; an unreadable depth is never reported as a retained zero. -
BREAKING:
TemplateEngine.renderis asynchronous (M102). The exported class’srender(name, data)now returnsPromise<RenderedTemplate>, becauseIViewEngine.rendermay answer a promise and both template arms share one lookup; every refusal (unknown template, missing placeholder key) is now a rejection rather than a synchronous throw.MailServiceis unaffected (it awaits). A direct caller ofTemplateEngineadds anawait; the constructor also gains an optional second parameter, the view engine, which the plugin supplies. Seedocs/upgrading.md. -
BREAKING: the log and SendGrid mail providers refuse a send while not connected. Both accepted a send outside
connect()..disconnect(), unlike the SMTP and SES providers: a send afterapp.stop()reported success on the log provider and still POSTed a real email through SendGrid. Both now reject with'LogProvider is not connected'/'SendGridProvider is not connected'. ThroughMailPluginnothing changes beforestop(), since the plugin connects the provider duringregister(). A test constructingLogProviderdirectly must now callawait provider.connect()before sending. -
BREAKING:
MailTemplateis a union type, no longer an interface (M102). It is nowMailStringTemplate | MailComponentTemplate, sointerface X extends MailTemplatefails withTS2312andclass Y implements MailTemplatewithTS2422. Extend or implementMailStringTemplateinstead, which has the released{ html?, text? }shape. A value typedMailTemplate, and every object literal assigned to one, compiles unchanged. -
BREAKING: a Pub/Sub subscription with no
queueis named per topic (M101b, V8-2). The default is<defaultQueue>.<topic ID>(messaging-consumers.<topic>; a fully-qualified topic name contributes only its ID, since/is illegal in a subscription ID) instead of one sharedmessaging-consumers. Subscription names are project-global, so the shared default attached a second topic to the first topic’s subscription — its handler consumed the other topic’s messages with no log — and the RPC channel of one run to the previous run’s. An existing subscription bound to another topic is now refused withPubSubSubscriptionBoundElsewhereErrorrather than attached to, and a name over 255 characters is refused atsubscribe(). Migration: an existingmessaging-consumerssubscription keeps its backlog with no consumer after upgrade; passSubscribeOptions.queue: 'messaging-consumers'on the ONE topic that owns it until it is drained, then delete it. -
A Kafka subscription to a topic that does not exist yet retries, then is named (M101b, V8-26). A KRaft broker with auto-creation answers
UNKNOWN_TOPIC_OR_PARTITIONto the request that creates the topic — measured on Kafka 4.0 — and kafkajs does not retry that, so such a subscription died at boot with a rawKafkaJSProtocolErrornaming no topic.subscribe()now retries that one error withinretry(kafkajs’s defaults, about 9 s) and the topic is found; a broker that does not auto-create rejects after the budget withKafkaTopicUnavailableError. A consumer that failed to join is disconnected rather than leaked, and a rejectedrun()(kafkajs’s crash handler rethrows a disconnect that fails) is reported throughKafkaOptions.logger(its first reader) instead of escaping as an unhandled rejection. -
A NATS consumer records its raw queue as
setu.queuemetadata (M101b). Consumer metadata needs NATS 2.10 or later; on an older server everysubscribe()now rejects.
Fixed
-
release:bumpcompletes, and leaves history alone. The task granted run access togitonly, so a real bump rewrote every file and then died withNotCapablebefore regenerating the member manifests. It also rewrote the versions recorded in archived plans underplans/archive/and the fixtures of a colocated*.test.ts; both are now left as written. -
setu adoptmoves a project’sdeno.jsonc(M111d). Adoption moveddeno.jsonbut left adeno.jsoncat the repository root, beside the workspace’s newdeno.json, so the member had no manifest and the project’s own was ignored. It now moves with the member, and its pre-0.9 pins are rewritten in place like adeno.json’s, comments kept. -
setu generate custom <name>refuses a path-like schematic name (M111d).../../xor a percent-escaped%2e%2esegment imported, and ran, a module outside.setu-ts/schematics/. A name is now letters, digits,-and_, and anything else exits 2 naming the rule. -
Consolidated SSE writes and cache coalescing settlement cleanup, preserving closed-connection and owner-isolation behavior across runtimes.
-
Node filesystem removal now removes an empty directory without recursive deletion, so CLI rollback can restore the original workspace while preserving nonempty directories.
-
Messaging transport headers preserve own
__proto__fields and framework-header precedence without invoking the object prototype setter. -
A streamed response without a content-type no longer reaches Node clients labelled as text (M111b).
@hono/node-serverwritestext/plain; charset=UTF-8for any body that lacks acontent-type, soctx.response.stream(...)with no type set arrived as text on Node while Deno, Bun and Workers send none. The Node adapter now removes that default whenever the framework response is a byte or stream body that sets no type, matching the Fetch standard, which implies a type only for a string body. Explicit types (send,text,json, a set header) are unchanged. A Node client that relied on the injectedtext/plainmust now set the header itself. A body with no type can now be MIME-sniffed by a browser, as it already could on the other runtimes: set a type on any streamed body that may carry untrusted bytes, or sendX-Content-Type-Options: nosniff.NodeServeHost.serve’sfetchcallback gains an optional secondenvparameter (node-server’s{ outgoing }); a host that never passes it is unaffected. -
MongoDB refuses an operator-shaped key (
@setu-ts/database-plugin, M105). Since M78 a key value that was an object —findById({ $ne: 'x' }), or such a value inside a composite key — reached the driver filter and was read as a query operator, addressing other documents. Every keyed operation now rejects a key value that is not a string or number, before any driver call, without quoting it. -
Cosmos DB and DynamoDB refuse a
__proto__payload field (@setu-ts/database-plugin, M105). A JSON payload carrying an own"__proto__"key reachedcreate/updateunchecked. On Cosmos (since M81) every row read back then carried that object as its prototype on Node and Bun (row.isAdmin === true), because the read mapping assigned keys; on DynamoDB (since M80) the row became permanently unreadable on every runtime, because the AWS SDK returns a__proto__attribute with no value. Both adapters now refuse such a field on write without quoting it — at any depth on DynamoDB, whose SDK drops nested ones too — and their read paths no longer let a stored one (written by anything) inject a prototype or crash the read: Cosmos keeps it as an ordinary field, and DynamoDB skips the valueless attribute the SDK delivers (one an injected client delivers with a value stays an own property). -
Bigtable and DynamoDB field maps read own entries only (
@setu-ts/database-plugin, M105). A field named after an inherited member (constructor,toString) resolved the inherited function as a column address or date encoding; Bigtable then threw aTypeError. Both maps are now null-prototype records, so such a field is an ordinary unmapped field. -
Worker lifecycle and example hardening (M45c, #433). Expired pending tasks reclaim excess starting slots; stale startup callbacks cannot reject unrelated work. Termination throws and rejections are contained, and shutdown bounds its wait to one second per termination, including retired workers. NaN legacy sizes no longer poison the default shared budget for valid modules. The example binds loopback and uses scoped Deno permissions. The budget limits managed slots, not physical worker exit when a host fails to terminate.
-
Worker spawn failures (M45c, #433). A synchronous
host.spawnthrow now rejects the oldest queued task as a crash, clears its timeout and holds no budget, instead of leaving a ghost task queued after its caller rejected. Remaining queued tasks continue scheduling after a failed spawn, including from a budget hand-over. Hand-over continuations cannot leak the spawn error. -
Worker slots held by workers that never start (M45c, #433). A spawned worker that does not signal ready within the new
WorkerPoolPluginOptions.startupTimeoutMs(default 10 000 ms, applied even withtaskTimeoutMs: 0, not disableable, and refused above 2 147 483 647 ms because a larger timer delay overflows and fires at once) is terminated, its slot returns to the shared budget, and the oldest waiting task for that module rejects withWorkerTaskError(remoteName: 'WorkerStartupTimeout'). Before this, one call to a module that never became ready held a shared slot until restart with task timeouts off, and under steady demand starved every other module even with them on; a queued task expiring now also yields a starting worker to a module with no worker. A worker whose listeners fail to attach is no longer charged a slot.register()warns whentaskTimeoutMsis0under a finitemaxWorkers, because a task that never settles keeps its slot. Every sizing and timeout option, including each ownpoolsentry, is read once into a validated snapshot that the pools use (an inheritedpoolsentry is ignored; anullentry means no overrides), a per-calltimeoutMsis read once, refused values are rendered bounded and escaped, a SymbolmaxWorkersis refused with the documentedRangeError, and anInfinitylegacy pool size no longer makes the derived default unbounded. Breaking:taskTimeoutMs(plugin-wide and per pool) and the per-calltimeoutMsmust now be0or a positive integer no greater than 2 147 483 647.NaNand negative values used to disable the timeout silently and larger values timed every task out after about 1 ms; they now throwRangeErrorat construction or reject thatrun(). Pass0for no timeout. The sizing documentation now states that the bound counts slots, not threads: on Deno a timed-out CPU-bound task keeps running, and can keep writing to aSharedArrayBuffer, after its promise rejects. -
Idle worker crashes are reported (M45c, #433). A worker error that settles no task (an idle worker crashing, or a startup crash with nothing queued) is now logged by the worker-pool plugin as a warning naming the task module; before, it rejected nothing and was visible nowhere.
-
Prometheus label values cannot split exposition lines (metrics-plugin, M45c, #433). Control characters the 0.0.4 text format cannot escape (carriage return, NUL, the rest of C0 and DEL) are encoded in label values and HELP text as U+FFFD plus two hex digits (a lone UTF-16 surrogate as U+FFFD,
uand four hex digits), with a literal U+FFFD doubled, so distinct values never collapse into one sample line. A worker-pooltask_modulelabel carrying a raw CR or NUL passed straight through before. -
A Deno task module that throws at import no longer kills the host process (runtime, M45c, #433). The web-worker host now cancels the worker
errorevent after reporting it to the pool. Without that, Deno re-raised it in the parent asUnhandled error in child workerand the application exited. -
Two JSDoc corrections (M107).
ITelemetryService.withSpanin@setu-ts/commonnamed aparentSpanoption; the option isparentContext.MongoAdapter.beginTransactionin@setu-ts/database-pluginsaid a standalone server fails there; measured against a standalonemongo:8, the real driver’sstartTransaction()resolves and the refusal surfaces at the FIRST operation inside the transaction as the driver’s ownMongoServerError(code: 20,IllegalOperation), unwrapped —MongoTransactionUnavailableErrortherefore never fires on the real driver (a behaviour fix left for afix/…branch). The outbox’s startup check detects that code itself. -
A capability lookup that misses now names the actual cause (
@setu-ts/kernel, PR #428). The error said “Register a plugin that provides it, or check the token spelling” for every miss, which is wrong in the two commonest cases. Beforestart(), the plugin is listed but has not run, so the error now says the application has not started and to resolve the service in a handler, in a lifecycle hook, or afterawait app.start(). During a plugin’sregister(), a capability that a later plugin provides now names that plugin and says to add the token to the consumer’sdependenciesoroptionalDependencies. Every other miss keeps the old advice, and the first sentence of the message is unchanged. -
A full-stack application could not start on Cloudflare Workers (
@setu-ts/full-stack-starter, PR #426). The starter always registeredSchedulerPlugin, which refuses Workers atregister(), socreateFullStackApp(), and every project fromsetu new --template full-stack --runtime cloudflare-workers, threwSchedulerUnavailableErrorbefore serving a request. The starter now leaves the scheduler out on Workers unless aschedulerarm is passed; schedule there withcloudflare-plugin’sWorkersCron. Verified by booting the composed set with the runtime forced tocloudflare-workerson Deno, not on workerd. The starter README’s Workers section, which said the starter does not run there, is corrected. -
A newly scaffolded full-stack project’s smoke test now also requests
/(@setu-ts/cli, PR #426).populateLoadContextruns only on an SSR request, so any DI misconfiguration left/healthat 200 while every page answered 500 and the generateddeno task testpassed over it. The emittedtest/app.test.tsnow fetches/throughapp.fetch— an SSR body streams, whichinject()refuses — and asserts 200; other templates are unchanged. -
export default { fetch: app.fetch }answered every Workers request with 500 (@setu-ts/kernel, PR #426). It is the Workers entry thecloudflare-pluginREADME,PUBLIC_API.md, therest-starterREADME and the Workers adapter’s ownlisten()error all name, and Cloudflare calls it asexported.fetch(request, …), sothiswas the exported object andApplication.fetchthrewCannot read private member #registry. Measured on workerd. The application now bindsfetchat construction, so it works passed around as a bare function. -
A fresh full-stack project on Node or Bun crashed on its first
start(@setu-ts/cli, PR #426).setu new --template full-stack --runtime node(orbun) printsnpm install && npm start, and the generated README says the same, but thestartscript rantsx main.ts(orbun run main.ts) without building first, so the React Router plugin failed with “Failed to load React Router server build” becausebuild/server/index.jsdid not exist.startnow runs the build first, as the Denostarttask and the npmtestscript already did. An existing project can prefix its ownstartwithnpm run build &&(orbun run build &&). -
A generated README now says what the project serves (
@setu-ts/cli). A new section lists each route a fresh project answers, such as/docs(Swagger UI),/openapi.json,/healthand/metrics, with the address to reach them. The scaffold e2e requests every listed path on a booted project, so the list cannot name a route that does not answer. -
The runtime deployment guide’s setup commands did not run.
npm install jsr:…,npm add jsr:…andbun add jsr:…all fail, andwrangler init --typetakes no--type; the guide now usesnpm install/bun addagainst the npm packages (see Changed) andsetu new. It also showed apackage.jsonimportsblock Node cannot use, anode:20Docker base below the documented Node 22 floor, Workers startup that cached a failed start for the isolate’s life,ctx.runtimein request handlers (wherectxhas noruntime), and a 120-second Workers execution limit that does not exist: an HTTP Worker has no wall-clock limit, and its CPU time is what is capped. Each startup retry constructs a fresh application, because the kernel refuses restarting one after plugin registration has begun. -
The getting-started guide’s CLI install command did not work. It omitted
-g, which Deno 2.9 requires for a global install, and-n setu, without which the binary is namedcliand everysetu …command in the guide is not found. It now matches the other four documented sites, and a test fails if any documented install command drifts from them. -
An absent header resolves to
undefined, and a validated query can be typed (@setu-ts/decorator-plugin, PR #426).@Params(Header('X-Name'))declared its source asstring | undefinedbut passednullfor an absent header, becauseHeaders.getanswersnull. It now passesundefined, like a missing cookie or query parameter. Migration: a handler that tested=== nullfor a missing header must test=== undefined. The whole-query source is now generic likeBody, soQuery<z.infer<typeof schema>>()declares the shape@ValidateQuerywrote instead of a cast.apps/static-sitetakes its port as the first argument (default8000). -
A name over 255 bytes no longer jams a RabbitMQ channel (
@setu-ts/messaging-plugin, M106, PR #427). AMQP limits the exchange, a queue, the routing key (the topic), the message id and each header name to 255 bytes, and amqplib prepares a publish’s confirm callback, or a channel operation’s reply slot, before it encodes. An oversized value was rejected, but it left the channel wrong for the rest of its life: after a publish, each later awaited publish resolved only when the one after it was confirmed; after asubscribe(), every later channel operation waited forever, so publishes timed out and subscriptions never completed. A payloadmessageIdor a topic over 255 bytes reached this before 0.9.0.RabbitMqBrokernow refuses each of these with aRangeErrornaming the field before the channel is touched: the topic, queue, message id and header names onpublish/subscribe, andexchangeName(and adefaultQueueover 218 bytes, which leaves no room for the private queue’s uuid suffix) at construction.@setu-ts/queue-plugin’sRabbitMqQueuehad the same defect through its derived queue names,<prefix>.<name>.ready|.delay|.dead: a job name that makes them exceed 255 bytes (240 with the default prefix) is now refused before the channel is touched — at registration too, byprocess()andaddRecurring(), so a declared processor with such a name failsstart()— and aprefixover 247 bytes at construction.QueueServicealso isolates each name in its poll and recurring loops: a reserve or recurring enqueue that fails for one name is reported and skipped, where it used to abort the tick and starve every processor and recurring job registered after it. -
A new Cloudflare Workers project installs and type-checks again (
@setu-ts/cli, #424).setu new --runtime cloudflare-workersemittedwrangler: '^4.0.0'beside@cloudflare/workers-types: '^4.20250109.0', andwranglermoved itsworkers-typespeer from^4to^5inside its own 4.x line — sonpm install, the first step the CLI prints, failed with ERESOLVE againstwrangler@4.148.0for every new Workers project. The scaffold now pinswrangler: '~4.148.0'and@cloudflare/workers-types: '^5.20261006.1': a tilde range because a floating^4was never a promise about that peer, while a patch release keeps it on^5. Itstsconfig.jsonalso gainsallowImportingTsExtensions,noEmit,lib: ['ES2022']andtypes: ['@cloudflare/workers-types']: without them the generatednpm run checkfailed on a pristine scaffold (TS5097, every emitted import carries.ts) and the types package was installed and read by nothing, socloudflare:workersandKVNamespacedid not resolve. A template’s owntypesandlibare kept. Verified end to end: scaffold,npm install,npm run check,npx wrangler dev, andGET /answers200on workerd. A new guarded e2e runs a dry-runnpm installof every Workers-capable template’s generated manifest, so the next upstream peer change fails the suite (including the weekly dependency-drift job) instead of reaching users; the root suite grants--allow-run=npmfor it. Existing projects can apply the same two pins and fourtsconfig.jsonoptions by hand. -
A scaffolded Worker kept its old bindings after a bindings-only deploy (
@setu-ts/cli, #423). Thesrc/index.tsthatsetu new --runtime cloudflare-workersgenerates memoized the application built from the first request’senvand reused it for every later request. Cloudflare may keep an isolate running across a deploy that changes only bindings, so that application kept serving with the previous bindings, variables and secrets. The entry now keeps one application perenvobject. On workerd,fetchandqueuehandlers receive the same object while bindings are unchanged, so the steady state is one lookup. Up to two applications are kept, so requests alternating between two versions during a gradual deployment reuse both instead of rebuilding each in turn; a third evicts the least recently used, and an application no request has used for 100 requests is retired. Retirement waits until the application now in use has started, so a broken binding never costs the working application, and a retired application is stopped only once no request or queue batch still holds it. Only newly scaffolded projects change; an existing project can copy the new cache, frominterface BootedAppthroughstopQuietly, and theacquire/releasecalls in each export into its ownsrc/index.ts. -
A refused NATS publish resolved as a success (
@setu-ts/messaging-plugin, #425).NatsBrokernever awaited JetStream’s acknowledgement, so a publish to a subject no stream captures resolved while the server’s503escaped as an unhandled rejection, which terminates a Deno or Node process by default; a successful publish was not confirmed either.publish()now resolves once JetStream has stored the message and rejects with an error naming the subject (and, for503, thestreamSubjectsremedy), keeping the client error ascause. An unresponsive server rejects after the client’s own 5 s timeout. A failed RPC reply now surfaces as a responder failure, so the request is redelivered rather than its reply silently lost. -
A duplicate key answers
409 Conflictinstead of a masked500(@setu-ts/database-plugin,@setu-ts/cloudflare-plugin, #420). Every backend’s unique violation reachederrorHandleras a plainError: measured on the memory adapter, PostgreSQL through Drizzle, MongoDB and DynamoDB Local, a duplicate primary key and a duplicate secondary unique key both answered500.DatabaseServicenow classifies SQLSTATE23505, PrismaP2002, MySQLER_DUP_ENTRY, MongoDB11000, Cosmos409and SQLite’sUNIQUE constraint failed(the only signal D1 carries) asDuplicateKeyError, and the memory, DynamoDB and Bigtable adapters raise it themselves. A duplicate refused atcommit()is mapped too, including a DynamoDB transaction cancelled by a create’s guard; any other commit rejection is still rethrown unchanged. The serveddetailis fixed and never quotes the duplicated value. -
RabbitMQ discarded handler failures (
@setu-ts/messaging-plugin, #421). Durable groups now confirm persistent retry/dead-letter copies before acknowledging the original, preserve message properties and prototype-safe transport headers, drop the broker-interpreteduserId,CCandBCC, classify deterministic failures immediately, and replay retry/dead declarations plus prefetch on reconnect. Retry delays, attempt budget, classifier, dead-letter cap and prefetch are configurable and validated at construction. Mandatory replacement publishes now reject correlated unroutable returns before acknowledging; reserved/oversized group names are refused before declarations can close the shared channel. A failed disposition now closes its channel and starts the reconnect-and-replay recovery, so unacked originals return toQinstead of fillingprefetchon a live channel until the consumer stalls. Dead-letter diagnostics normalize application-supplied queue/topic names and bound the complete log line, preventing control characters from forging log records while preserving the original routing names. -
Redis Streams stranded messages (
@setu-ts/messaging-plugin, #419). Failed entries are reclaimed with tiered idle backoff through one delivery path, instead of staying in the pending list forever. Terminal failures enter a bounded per-group dead-letter stream before acknowledgement. Clean consumers are deleted only with no pending entries, and old foreign empty consumers are swept. Real Redis tests cover restart recovery, competing replicas, dead-letter fields, and clean stop. -
Generated WebSocket and SSE routes fail the project’s own
deno fmt --check(M101g).setu generate ws-routeemitted abroadcastline wider than the generated project’s formatter allows for every name, andsetu generate sseemitted a one-line registrar signature that overflowed for longer names. Both now emit whatdeno fmtproduces, and the every-family formatting sweep generates these families too. -
setu generateno longer reports adopting a file into a barrel it does not write (#416). On a fresh--template restproject everysetu generateprinted “Adopted src/services/greeting.service.ts into src/services/index.ts … Remove any manual registration of it”, although nosrc/services/index.tswas written and that barrel only re-exports, registering nothing. Adoption is now reported only for a barrel the command writes, only for a barrel that registers something, and only after the write succeeds;--dry-runpreviews it as “Would adopt”. A custom schematic’s output never reports an adoption, since the CLI cannot vouch that its barrel registers the file. -
CLI output cannot be forged by project-controlled text (M101f security audit). A member name, path, task value or parser message carrying a line feed, carriage return, U+2028/U+2029 or a bidi control printed raw, so a hostile workspace could make
setuprint a line that read as the CLI’s own. Every such value indevtool enable(including the task value a refused merge would write and the stale dev-runner refusal),workspace ports --reallocate,generate(includinggenerate app,generate libraryand the legacysrc/routes/notice),add, the member reconcile refusal, the duplicate-port refusals, the unreadable-runtime-marker error and the interrupted-run retry hint is now escaped to\uXXXXand stays on one line. The file and failure lines ofnew, every file, refusal and failure line ofadopt(which now also refuses by name, instead of crashing, when an adopted directory holds an entry it cannot stat, such as a committed dangling symlink), the interrupted-rollback message, and the plugin-command missing-config refusal and application-load errors are escaped the same way; an error raised by the project’s own plugin code is printed as written, since that code can already print anything. Messages keep their intended line breaks, and legitimate output is unchanged. -
The CLI no longer follows symbolic links inside a project (M101f security audit). A link is committed content, so a cloned project could make
setu adoptwalk a linked directory and move — deleting from where they lived — files outside the project, and could makegenerate,devtool enableandworkspace ports --reallocateoverwrite or merge into a file outside it by linking a barrel,deno.jsonormain.dev.tsthere. Every CLI write, and every fileadoptmoves, must now resolve to exactly its place under the project or workspace root; a path reached through a link inside the project, or a dangling link, is refused by name with nothing written. A path holding a backslash below the root is refused too (on POSIX it is an ordinary filename character, so it names a different file on each platform), and a workspace member name must be one path segment — no/, no\\, not.or..— or the manifest is refused as unreadable. A project reached through a linked PARENT directory is unaffected. If you deliberately link a file into a project (a shareddeno.json, for example), replace the link with a copy before running a writing command. -
Generated image pruning step pinned and scoped (M101f security audit). The step that removes the development-only diagnostics pin ran
deno evalwith every permission against a floatingjsr:@std/jsonc@^1.0.2, the one unlocked fetch in the image. It now runsdeno runagainstjsr:@std/jsonc@1.0.3, with read and write granted only to the member’s and the root’sdeno.json/deno.jsonc. Regenerate the managed Dockerfile withsetu generate app,setu devtool enableorsetu workspace ports --reallocate. -
RabbitMQ messages and jobs survive a broker restart (#418). Both RabbitMQ publishers —
messaging-plugin’s broker andqueue-plugin’s adapter — declared durable queues and then published every message TRANSIENT on a plain channel, so a RabbitMQ restart silently emptied every queue of the messages and background jobs waiting in it, whilepublish()/add()had already resolved. Measured against RabbitMQ 4.3.5: 5 messages and 5 jobs → 0 and 0 after a restart, the queues surviving empty. Every publish is now persistent (persistentMessages, defaulttrue;falserestores the old wire behaviour exactly) and goes through a publisher-confirm channel, sopublish()/add()resolve only once RabbitMQ has accepted the message and reject when it refuses it. Each publish also listens for its channel’scloseand rejects if the channel closes first: amqplib 0.10.x’s close-time drain can stop at a slot an out-of-order confirm already settled and never call a later publish’s callback, which withpublishTimeoutMs: 0would leave the publish — and a retry’s ack of the reserved job — pending forever. A queue retry or dead-letter now acknowledges the reserved job only after its replacement is accepted — previously it acknowledged after an unconfirmed publish, so a broker failure between the two lost the job. The drive-mode reconnect reopens a confirm channel too.IAmqpConnectionandIAmqpQueueConnectiongain an optionalcreateConfirmChannel(); an injected facade without it keeps a plain channel and logs one warning. Proven by restarting a real broker in CI, with the transient control losing its messages in the same restart. -
A paused RabbitMQ no longer hangs
publish()oradd()forever (#418). Each publish made an unbounded broker round trip (an exchange assert inmessaging-plugin, queue declarations inqueue-plugin) that a paused broker — socket open, nothing answering — never completes. The newpublishTimeoutMsoption (default15000,0unbounded, out-of-range values refused withRangeErrorwhen the plugin is constructed) bounds every round trip of one publish, confirm included; measured against a paused broker, both calls reject at the bound with a message naming the option. A rejection is not proof the message was dropped. -
SAML binding cookie cleared only on consumption (M101c). The ACS cleared the browser-binding cookie on every outcome, so a cross-site
POSTof an empty or junk body to the CSRF-exempt ACS, carrying the victim’sSameSite=Nonecookie, burned the victim’s in-flight login. It is now cleared exactly when a pending request is consumed. -
cloudflare-plugin’scacheApiMiddlewarekeys on the resolved tenant (#407). The default key carried the locale (M103) and not the tenant, so with multi-tenancy tenant A’s cached page was served to tenant B for the TTL — the exact gap the M103 audit recorded as pre-existing. Whenctx.request.tenantis set the key appendssetu-cache-tenant=<encoded id>before the locale parameter, under the same rules: the URL text is never re-serialized, a tenant-less request whose URL already carries the parameter is served uncached (its key would equal a tenant’s), as is an unencodable id, and an application without the plugin keeps byte-identical keys. A global registration must run after the tenant middleware (40). A customkeymust include the tenant itself. -
The documented Node.js floor is 22, not 18 (#406).
docs/getting-started.md,docs/runtime-deployment.mdand the runtime README claimed Node 18+; five packages usePromise.withResolvers, which Node 22 was the first to ship, andglobalThis.cryptoalone needs 19. CI verifies on Node 24. ThecacheApiMiddlewareJSDoc’s list of skip reasons also names the non-GETpass-through it had omitted since M52b. -
A Kafka
subscribe()racingdisconnect()no longer leaves a consumer running. Whendisconnect()landed whilesubscribe()was still connecting its consumer, or during a first attempt that then succeeded, the subscription completed anyway — a consumer ran after shutdown, holding the process open and consuming messages. It now rejects withKafkaBroker was disconnected while subscribingand releases the consumer. The same held for a consumer whose group JOIN was still in flight (about 3 s on a broker with Kafka’s defaultgroup.initial.rebalance.delay.ms): kafkajs’sstop()is a no-op until the join completes, so disconnecting closed the connections under it, which kafkajs treats as a retriable crash and restarts — the consumer rejoined afterapp.stop(). Releasing a consumer now waits up to 10 s for its join to settle and then disconnects it; a join still pending at 10 s is disconnected the moment it settles, afterapp.stop()has returned, because disconnecting under it neither stops a join that later succeeds nor returns before kafkajs’s pending JoinGroup is answered. A record delivered to a released consumer is left uncommitted for the group to redeliver. Found by the M101b security audit; both predate M101b. -
A Kafka consumer stopped while kafkajs was restarting it no longer rejoins after
app.stop(). After a broker restart or a connection reset, kafkajs restarts a crashed consumer behind its own timer. A stop that landed once that restart had begun found nothing to stop — kafkajs’sstop()drops a runner that is still joining — so the restarted consumer joined the group after shutdown, consumed and committed messages with the stopped application’s handler, and held the process open (measured on Kafka 4.0). The broker now declines kafkajs’s restart and restarts the consumer itself — after the crash’sretryTime, elseKafkaOptions.retry.initialRetryTime, else 300 ms, as kafkajs reads them — so a stop cancels a pending restart and waits for one in progress. Found by the M101b security audit; predates M101b. -
A Kafka application exits after
app.stop().KafkaBroker.disconnect()and a subscription’sunsubscribe()called the kafkajs consumer’sstop(), which halts fetching but leaves the consumer’s cluster connection open — so every Kafka application with a subscription hung after a cleanapp.stop()until it was killed, and each RPC request’s reply inbox leaked a connection when it closed. Both now calldisconnect(), which stops the consumer and closes its connection. Found while verifying M101b; it predates M101b. -
A
MessagingPluginwhose declared subscription rejects no longer leaks its broker (M101b).register()connected the broker, subscribed the declared entries, and only then registered the close hook — so when a declared subscription rejectedstart(), the kernel’s failed-start cleanup had no hook to run, the connected broker was never closed, and the process could not exit (measured against a real Kafka broker). M101b makes that rejection a documented outcome, so the hook is now registered directly afterconnect(). A Kafka subscribe retry waiting out its backoff is likewise cancelled bydisconnect()instead of holding a timer for up tomaxRetryTime. -
NATS request-reply works against a real server (M101b, V8-6). The reply inbox subscribes with the dotted queue
rr.inbox.<uuid>, which was used verbatim as the JetStream consumer name, and the nats client refuses.in one before anything reaches the server — so everyrequest()failed withinvalid durable name - durable name cannot contain '.', as did any dotted user queue. A queue is now encoded (.→_2e, and likewise*,>,/,\and whitespace); a queue with none of those characters is unchanged, so no existing consumer is renamed. A queue whose encoding collides with another’s, or that is reused across two topics, rejects withNatsConsumerNameCollisionErrorinstead of silently sharing one consumer’s deliveries. -
62
@sincetags named a release that does not ship their symbol. The MongoDB, DynamoDB and cursor-paging surfaces of@setu-ts/database-plugin(61 tags) carried@since 0.1.0while first shipping in0.2.0, and the kernel router’sROUTE_ENTRYfirst shipped in0.3.0. Each is corrected to the first published version whose file contains the symbol. They survived because the@sincegate skipped every0.1.0tag — that line shipped only as0.1.0-alpha.*— and now checks such a tag against the line’s last prerelease instead. -
The Cloudflare example cached a failed start for the isolate’s whole life (
apps/cloudflare, PR #426).createWorkerHandlermemoised the start promise even when it rejected, so one transient failure at cold start left that isolate answering errors to every later request. It now forgets a failed start and retries on the next request, the rule the generated Workers entry has followed since M70l. Concurrent requests still share one start, and none reachesfetchon an application whose start failed. -
Constructor injection in a full-stack app was undocumented (
@setu-ts/full-stack-starter,@setu-ts/rest-starter,@setu-ts/testingdocs, PR #426). Nothing said how a React Router loader reaches an injected service; thefull-stack-starterREADME now shows resolving it fromCAPABILITIES.DI_CONTAINERinpopulateLoadContextand carrying it on a context key, and says that a DI mistake starts cleanly and fails every server-rendered page. Both starter READMEs say what thediarm turns on, and the testing README points a container-provided service atoverrideProviderrather thanoverrideCapability. -
The root README’s quick examples did not run as written (README, PR #426). The first example registered
LoggerPluginwithout telling the reader to install it, so following the documented install failed to resolve the import; it now says to add the package. The second readJWT_SECRETbefore any plugin had registered; it now loads configuration withloadConfigand hands the same snapshot toConfigPlugin({ instance }). The feature table no longer claims event sourcing, which the framework does not ship, and now lists Cosmos DB and Bigtable. -
The CLI guide described a CLI several milestones old (
docs/cli.md, PR #426). It saidgenerate controllerandgenerate moduleneeddecorator-plugin(neither has since M70h), listed fourteen schematics where there are sixteen (ws-routeandssewere missing), quoted a refusal message the CLI no longer prints, and left the M83@Modulefile andMODULESbarrel out of the class-based module tree. It also miscounted the interactive questions, gave the wrongbunstart command, and showed the workspace discovery map without its<MEMBER>_HOSToverride. Each corrected claim was checked against a project the CLI scaffolded. -
The NestJS migration guide mapped several NestJS features to hand-written code the framework replaces (
docs/migration-nestjs.md, PR #426). Guards now map toAuthPluginwithrequireAuth()/requireRole(), in place of a hand-written global middleware that would also have refused/health. Exception filters map toerrorHandler({ respond }), WebSocket gateways to@Gateway, and the testing module tocreateTestApp({ app: createApp(), overrides }). The guide also says that a decorated@Injectablelives in the DI container, whichoverrideCapabilitydoes not reach. The decorator example now passesDiPlugin({ autoRegister: true }), without which injecting a framework capability fails at startup. The guide no longer says Setu-TS has no@Module. -
The examples guide described applications that are not in
apps/(docs/examples.md, PR #426). Its code blocks were written as illustrations rather than taken from the apps, and had drifted. They showed routes the apps do not serve (/items,/users,/my-route), a health endpointminimaldoes not have, D1 and Cache API use the Cloudflare example does not make, and WebSocket rooms in an SSE-onlyrealtimeexample. They also named abuildtask wherecompiled-binaryhascompile. The guide also did not say thatcqrsandmicroservicesprint a result and exit, or which port each app uses. Each deep dive now gives the commands it was checked with against the running app, and links to that app’s source. -
The Fastify migration guide’s testing example threw at startup, and several sections described behaviour the framework does not have (
docs/migration-fastify.md, PR #426).createTestApp()with no plugins throws, because the kernel requires a runtime plugin. The example now passesRuntimePlugin(), and the guide points atapp.inject(), which works as Fastify’s does. The shutdown hooks’ comments were reversed (onStoppingruns while the application still serves;onShutdownruns after the socket closes). The encapsulation section claimed capability tokens scope a service, but every registered service is application-wide. Validation and error handling now showvalidateBody(...)anderrorHandler()in place of hand-written equivalents, and query parameters come fromctx.query. -
The React Router development guide’s loop failed for the CLI’s full-stack skeleton on Deno (
docs/react-router-dev.md, PR #426). Re-verified on Deno 2.9.6, Vite 8.3.1 and react-router 8.4.0: the build thunk, the/__vite/proxy and editing a route without a restart all still work. A project fromsetu new --template full-stackimports@setu-ts/react-router-pluginin its route modules, though, and Vite’s development SSR runner cannot resolve that JSR import, so every page answered500. The guide’s loop now passesviteDevExternals(see Added), and points such a project at its owndeno task dev. It also pins Vite 8 as the CLI does, and saysisbotis needed only with React Router’s default server entry. -
The telemetry fan-out guide’s validation command failed as printed (
docs/telemetry-collector-fanout.md, PR #426). The collector config reads five credentials from the environment, andotelcol-contrib validaterejects it while they are empty, so the command exited1on a clean checkout. The guide now sets placeholders in the command and says validation contacts no vendor. It also says the config validates on 0.156.0 and on the 0.115.1 that the Compose stack pins. A scope note still sent readers to Milestone 39 for Compose. It now links thetelemetryprofile that shipped there. -
The MVC guide’s redirect example could never take its error branch (
docs/mvc.md, PR #426). It decided acceptance withctx.request.method === 'POST'on aPOSTroute, so it always redirected. It now validates the submitted title, re-renders the form with422when it is too short, and redirects with303otherwise; both branches were driven through a running app. The guide’s other claims were checked the same way and hold. -
The custom-plugin guide’s two test examples failed as printed (
docs/custom-plugins.md, PR #426). The integration test registered its plugin aftercreateTestApp()had started the application, which throws; the unit test asserted a service its plugin never registered. Both now pass when run, and every example was checked against the source or a running app. Step 1 usedCAPABILITIESwithout importing it, the service-registration block registered one token twice and pairedoverridewithmulti(which ignores it), thesubtleexample called it optional and imported an HMAC key without a hash, and the sample README’s outer fence closed early, so its Usage and Options sections rendered as part of the guide. -
The programmatic API reference resolved capabilities before
start()(docs/programmatic-api.md, PR #426). Both streaming examples and therouter.postexample read a service at module scope, where the plugin providing it has not registered yet, so each threwNo service registered. They now resolve inside the handler, and the disconnect example no longer closes a stream the client has already cancelled. Themiddleware.addsection showed only a plugin registration; it now adds a middleware.register()takes no options,start()’s hostname defaults to the runtime’s own, and the transcribedIResponseandIRequestContextgainhtml()andraw. -
The plugin catalog described packages that have since changed (
docs/plugins.md, PR #426). The kernel listed three exports it does not have (createApplicationis its only value), the DI token wasCAPABILITIES.CONTAINERrather thanDI_CONTAINER, the database entry listed neither the MongoDB, DynamoDB, Cosmos DB nor Bigtable arms nor their drivers, telemetry omitted@opentelemetry/context-async-hooks, audit claimed a Cloudflare KV store it does not ship, multi-tenancy claimed physical schema and database isolation, LaunchDarkly was called Node-only, and the CLI count was 14 schematics rather than 16. Every listed export and token is now checked against its package’s barrel andCAPABILITIES. -
The decorators guide’s validation examples answered
500on every request, and it said role decorators are not enforced (docs/decorators.md, PR #426).@ValidateBodyand@ValidateQuerywere given plain{ type, required }objects, which are not schemas the validation plugin can run, so every request to those routes failed. They now use Zod schemas.@Rolesand@Permissionshave been enforced since M89a (401,403, or501with no authorization service), while the guide described them as metadata only. TheDiPlugin()examples now passautoRegister: true; without it, injectingCAPABILITIES.CACHEfails at startup. Also corrected: the path@Versionproduces (/v1/api/users, not/v1/users), and theMetadataStoreexample, which iterated a new, empty store instead ofmetadataStore. -
The plugin architecture guide’s replacement and testing examples failed at startup (
docs/plugin-architecture.md, PR #426). “Plugin Replacement” registered a custom logger besideLoggerPlugin, which the kernel refuses because a capability has one provider. The testing example registered a plugin aftercreateTestApphad started the application, which throws. The middleware priority table now lists every first-party middleware with its real priority; it had omitted the error handler, locale resolution and request logging. The shutdown hook comments now say when each hook runs relative to serving, and the guide no longer says routing and middleware are plugins.
0.8.0 — 2026-10-03
Added
-
Diagnostics session renewal (M98o).
DiagnosticsPluginOptions.maxSessionLifetimeMsopts a launch into renewing its existing authenticated session for up to 12 hours of awake monotonic time. Protocol v1 gains signedGET /v1/renewand an optional statusrenewalmember; the native client gains requiredsession()andrenew()methods returning the newDiagnosticsSessionLifetime. Renewal keeps the same key, session ID, instance binding and sequence space, remains behind replay/rate/instance gates, and the expiry timer re-arms only for opted-in sessions. The CLI’s generated devtool entry selects an 8-hour cap. These required client methods widen the previously unpublishedIDiagnosticsClientinterface. The client also now cancels an unread non-200 response body, which it never parses, instead of leaving the connection’s body open until garbage collection. -
SAML 2.0 service provider (M100f). A
samlarm ofAuthPluginOptions.signIn.providers(the publishedSamlProvidertype) makes the application a SAML 2.0 SP for an enterprise IdP, landing in the same signed-in session as every other method withmethods: ['fed'], sosignIn.mfaandrequireMfa()apply unchanged. Each provider registersGET <basePath>/<name>/login(an AuthnRequest over the HTTP-Redirect binding),POST <basePath>/<name>/acs(the assertion consumer service, HTTP-POST binding) andGET <basePath>/<name>/metadata(the SP descriptor). XML signature verification is delegated to@node-saml/node-saml@^5, imported lazily and awaited inregister()— or injected throughmodule(SamlModule) — so a missing package fails at startup with the newSamlRuntimeLoadError, which names the specifier and Cloudflare Workers’nodejs_compatflag. Assertions must be signed (a response-only signature is not enough; unsigned, encrypted and signature-wrapped assertions are refused); every security option handed to the library is set explicitly, because node-saml 5.1.0 defaultsvalidateInResponseToto'never'. Two checks node-saml 5.1.0 does NOT make on an authentication response are made by the plugin from the verified assertion: itsIssuermust equalidp.entityId(the library readsidpIssueronly for logout messages), and everySubjectConfirmationDatamust nameacsUrlas itsRecipientand the consumed request as itsInResponseTo— the signed binding, since the response envelope’s ownInResponseTois unsigned when only the assertion is. The response’sInResponseTomust name a pending request this server issued; pending requests (SamlPendingRequest) live in anISamlRequestStore(MemorySamlRequestStoreby default, single replica only) that is also the library’scacheProvider, are consumed exactly once, and are bound to the browser that started the login by a__Host-setu-samlcookie (SameSite=None; Secure; HttpOnly; Path=/; Max-Age=600) — because the IdP’s cross-site POST does not carry theLaxsession cookie, and without the binding a posted foreign response would sign the victim in as the attacker. Assertion ids are claimed once (claimAssertionId), so two concurrent posts of one captured response cannot both sign in. The checkedIssuerand thenameIDhanded totoPrincipalare read from the assertion’s own elements, never from attribute-overlaid profile fields.MemorySamlRequestStorecaps its pending requests (MemorySamlRequestStoreOptions.maxPendingRequests, defaultDEFAULT_MAX_PENDING_SAML_REQUESTS= 10,000, oldest evicted first), so an unauthenticated login flood cannot grow memory without bound. IdP-initiated login is refused. Refusals answer401(orfailureRedirect?error=) with one of two fixed codes,assertion-invalidandstate-invalid; the library’s message reaches only thedebuglog.toPrincipalreceives a frozenSamlProfile. The ACS signs in on a NEW session, since the previous session’s cookie is not sent with the IdP’s POST. Encrypted assertions, single logout, the Artifact binding and signed AuthnRequests are not offered. Proven against the real node-saml on every run and against a real Keycloak 26.4 SAML client (test/e2e/keycloak-saml-real.test.ts). -
Passkeys / WebAuthn (M100e).
AuthPluginOptions.signIn.passkeys(the publishedPasskeyOptionstype:rpId/rpName/origins/store/resolvePrincipal/userVerification) registers the four ceremony routes —POST <basePath>/passkeys/{register,login}/{options,verify}— and lets a passkey assertion with user verification count as the second factor forsignIn.mfa’s step-up model. The ceremonies are WebAuthn Level 2 with attestation conveyancenone, accepting ES256, RS256 and EdDSA credentials. The verifier is zero-dependency: a bounded CBOR/COSE decoder overruntime.subtle(definite lengths only, depth 4, 4 KiB, every unsupported major type refused).@simplewebauthn/serverwas rejected as a runtime dependency because importing it installs a globalReflect.getMetadatapolyfill in every application that enables passkeys; it appears only in the package’s differential test as a test oracle. Challenges are held in the session with a 5-minute expiry AND claimed once in the credential store (IPasskeyStore.claimChallenge), so a replayed assertion against an older cookie copy is refused even though a synced passkey’s counter is always0.updateCounterMUST be implemented by custom stores as an atomic compare-and-advance, andsaveMUST be an atomic compare-and-set (it stores a NEW credential only when its id is absent AND the principal is belowmaxPerPrincipal, both checked in the same step as the insert, and answers'saved'/'duplicate'/'limit'— a blind write lets two concurrent registrations of the same credential id both succeed and lets the later record silently replace the earlier one,principalIdincluded, and a count read before a separate write lets a burst overshoot the cap); the shippedMemoryPasskeyStoredoes both and purges expired claims in an amortized sweep (when the claim map doubles) so its map stays bounded without a per-claim scan. Stored credentials (StoredPasskey) recordattestation: 'unverified'and abackedUpflag that is display only. Under the defaultuserVerification: 'required'a UV-unset assertion is refused everywhere; under'preferred'/'discouraged'it is refused for a username-less sign-in and accepted only as the second factor after a first one. A passkey-onlysignIn(providers: [], nomfa) is accepted, and thepasskeysoption is validated whenAuthPlugin(...)is called; the recorded method is alwayspop(RFC 8176), neverhwk/swk— with attestation unverified the plugin cannot know how the key is protected. Any attestationfmtis accepted with its statement unread. Attestation verification against trust roots remains out of scope. Registration is gated: a principal who already holds a passkey must have proved a second factor (otp/pop) in the session (403 second-factor-required). The first passkey is trusted on first use only withoutsignIn.mfa; with it, and noPasskeyOptions.mayRegister, the first passkey needs a proven second factor too, because the principal may hold a TOTP factor the plugin cannot see. A setmayRegister(context typePasskeyRegistrationContext) decides the first passkey and can refuse more (403 registration-refused). One principal holds at most 16 credentials (409 credential-limit), enforced atomically byIPasskeyStore.save(credential, { maxPerPrincipal }), which answers aPasskeySaveResult('saved'/'duplicate'/'limit') configured byPasskeySaveOptions. Only definedtransportsvalues are stored, and an EC2 key must be a point on P-256. -
Multi-factor authentication with TOTP (M100d).
AuthPluginOptions.signIn.mfaadds a TOTP second factor to any sign-in flow that records its principal throughIAuthSessionService.TotpService(app-instantiated, likePasswordHasher) computes RFC 6238 codes (HMAC-SHA1, 30-second step, 6 digits, ±1 step window) from a base32 secret and verifies them against anITotpStore; the shippedMemoryTotpStoreis single-process.signIn.mfa.required(principal, methods)returnstrueto require a second factor; when it does,signInstores aPendingSignInrecord (under a private session key, NOT the signed-in key) and returnssecond-factor-requiredinstead of signing the session in.TotpService.completeSignIn(ctx, code)andcompleteSignInWithRecoveryCode(ctx, code)complete a pending sign-in, each appendingotpso the principal ends up recorded withmethods: ['pwd', 'otp'](or['fed', 'otp']) and the session id rotated.signIn.mfa.pendingTtlMs(default 300 000 ms) is the single owner of how long a pending record survives;TotpServicehas no TTL option of its own, and it must be a positive integer (NaN,0and negatives are refused whenAuthPlugin(...)is called, sinceNaNwould never expire).pending(ctx)does not report an expired record, so completing one spends no code. A provider callback whose sign-in is held pending redirects tosignIn.mfa.challengePath(elsereturnTo) and keeps its RP-initiated-logout facts through the promotion. Per-account lockout: five failed attempts within 15 minutes lock the principal out of verification, recovery codes included; the attempt is reserved before the credential is checked, a refused attempt is not counted (so continued guessing cannot extend the lock), and a successful verification clears the count. Replay protection: the store’sclaimStepis monotonic, so a captured code cannot be replayed. An enrolment never confirmed is not a factor — no verification path accepts its code — and starting a new enrolment leaves the confirmed factor in place until the new secret is confirmed, keeping the step counter monotonic across the swap. Replacing a confirmed factor (confirmEnrolmentwith a re-enrolment pending), regenerating recovery codes, and disabling a confirmed factor each requireproofof the CURRENT factor — a code from it or an unused recovery code — and answerproof-requiredwithout it, so a caller able to name a principal cannot take over or remove its second factor; a first enrolment is trust-on-first-use and needs none. The routes calling these take the principal from the session, never the request, as the README example shows. Recovery codes: confirmation mints the first set (confirmEnrolmentreturns it, so codes never exist without a proven factor) andgenerateRecoveryCodes(principalId, proof)replaces it; each set is 10 codes of 16 base32 characters (80 bits), stored as SHA-256 digests, consumed atomically on use, and accepted only in the canonical shape (trimmed, upper-cased, exactly 16 characters ofA-Z2-7). The newrequireMfa()guard answers401for an anonymous request and403second-factor-requiredfor a principal whoseclaims.amrlacksotporpop; a non-arrayamrcounts as absent rather than being coerced, so a string merely containing a factor name cannot satisfy it.@setu-ts/commongainsPendingSignInand thesecond-factor-requiredarm ofAuthorizationFailure. New@setu-ts/auth-pluginexports:TotpService,MemoryTotpStore,requireMfa, and the typesMfaOptions,ITotpStore,TotpVerifyResult,RecoveryVerifyResult,TotpCompleteSignInResult,TotpServiceOptions,TotpEnrolment,ReserveAttemptResult,TotpProofResult,ConfirmEnrolmentResult,RecoveryCodesResult,DisableResult.ITotpStorewrites enrolment state only through two atomic operations —stageSecret(never touches the claimed step) andconfirmSecret(a compare-and-set on the secret awaiting confirmation) — so a concurrent enrolment can neither roll back a claimed step and reopen a code’s replay, nor have its secret confirmed by another user’s code.signIn.providersmay be empty whensignIn.mfais set, for a password-only sign-in.MemoryTotpStoresweeps lockout entries whose attempts have all left the window, so its size tracks the attempt rate rather than every principal id ever presented. The internalpromotePendingis not exported. WithsignIn.mfaunset nothing changes. -
Sign-in with an outside provider (M100c).
AuthPluginOptions.signInmakes the application an OAuth 2.0 / OpenID Connect relying party over the authorization-code flow: per provider,GET /auth/<name>/loginandGET /auth/<name>/callback, plus onePOST /auth/logout. Providers are the exportedOidcProvider(discovery; the ID token verified by the M100b verifier with the client id as audience,nonceandazp) orOAuth2Provider(a userinfo endpoint, for providers such as GitHub that issue no ID token). PKCE S256 is sent for every provider;state,nonceand the verifier are bound to the user’s own session; RFC 9207issis checked when sent;returnTois kept only as a same-origin path; and callback refusals answer with one of four fixed codes in the configured error format. Every configured or discovered URL must behttps(orhttpon a loopback host) written in printable ASCII, so a non-conforming discovered endpoint fails the login with503instead of a malformedLocation. Provider tokens reachonTokensand are stored nowhere else.@setu-ts/commongainsCAPABILITIES.AUTH_SESSIONandIAuthSessionService(signIn/current/signOut, withSignInOptions,SignInOutcomeandAuthMethod): the one owner of “this session is signed in as this principal”. A password login records its principal through it too, and the newauth-sessionstrategy authenticates later requests withclaims.amrfrom the recorded methods and no hand-written middleware.signIn.refreshPrincipalre-reads the principal per request.IAuthHttpgainspostfor the token exchange and aheadersoption ongetfor the userinfo read, and theissuersstrategy now refuses a Keycloak ID token (typ: "ID") presented as a bearer. RequiresSessionPlugin. New@setu-ts/auth-plugintypes:SignInConfig,SignInProvider(OidcProvider|OAuth2Provider, sharingSignInProviderBase),TokenEndpointAuth,ProviderTokensandRefreshPrincipal. Verified against a real Keycloak 26.4 realm. WithsignInunset nothing changes. -
Tokens from an outside issuer (M100b).
AuthPluginOptions.issuersaccepts access tokens an outside identity provider issued (Auth0, Entra ID, Google, Keycloak, Cognito), verified against its published key set throughruntime.subtlewith zero npm dependencies. Each exportedTrustedIssuernames an exactissuer, a requiredaudience, a key source (IssuerKeySource:{ jwksUri }or{ discovery: true }), an optional algorithm allowlist (IssuerAlgorithm: RS256, PS256, ES256, ES384, EdDSA) and a requiredtoPrincipal.noneandHS*are refused before any key lookup, and keys are filtered by type, curve, use, algorithm, operations andkid. Key sets are cached on the monotonic clock, refetched at most once per cooldown for an unknownkid, kept through fetch failures for a bounded time, and bounded per fetch by time, bytes and key count. A newauthhealth indicator reportsupordegraded— neverdown— from cached state only.http(the exportedIAuthHttp) replaces the defaultfetchseam. The chain is now jwt → issuers → api-key → session → caller-supplied; withissuersunset nothing changes. Verified against a real Keycloak 26.4 realm, including key rotation through the admin API. -
Authentication composition (M100a).
AuthPluginOptions.jwtis optional, so API-key, session, and caller-strategy applications no longer invent JWT key material.AuthPluginregisters passive authentication globally at priority 300 by default;middlewarecan change the priority, exclude paths, or disable registration for route-level composition through the exportedAuthMiddlewareOption. Empty passive chains now throw the exportedAuthPluginConfigurationError. Applications with a hand-added globalauthMiddleware()should remove it; duplicates remain correct but authenticate twice. -
Storage operation counters (M98m): opt-in, minimized storage observations through the diagnostics connector.
StoragePluginacceptsdiagnostics: { enabled: true, alias }(the new exportedStorageDiagnosticsOptions, validated whenStoragePlugin(...)is called, never echoing a value); the instance’s ownStorageServicethen counts each publicput/get/delete/exists/getSignedUrl/getStreamcall with explicitsucceeded/failedoutcomes.getSignedUrlmints a URL rather than transferring bytes, so its record carrieslastDurationMs: nullandlastBytes: null;lastBytesis the byte length of the last settled buffer for the buffered operationsputandgetandnullfor the non-buffered operations (agetthat finds nothing throws and settles as a failure).getStreamcounts the open, not the drain. Object paths, stored bytes, content types, signed URLs (the syntheticmemory://URL encodes the path) and error text are never captured. Every StoragePlugin instance registers anIStorageDiagnosticsSourceunder the new multi-providerCAPABILITIES.STORAGE_DIAGNOSTICS(disabledwithout the option, with nothing attached to the service). New public surface on@setu-ts/common:CAPABILITIES.STORAGE_DIAGNOSTICS,IStorageDiagnosticsSource,StorageDiagnosticsSnapshot,StorageDiagnosticsRecord,StorageDiagnosticsResponse,StorageDiagnosticsOperation. New connector surface:GET /v1/storage(authenticated like every operation; a snapshot operation — no query — exactly like/v1/cache; positionals<N>source ids; more than 16 sources refuses startup with a fixed configuration error; a throwing or invalid source answered by that source’s own value-freecollection-failedsnapshot; duplicate aliases or an over-budget body collapse to the fixed collection-failed response with no sources) and the REQUIREDIDiagnosticsClient.storage(); the status manifest now reportsstorage: true. Enabled calls read the monotonic clock twice (every call is timed, so age, duration and bytes describe one settlement) and settle through one derived promise that re-rejects with the original reason, so an unhandled provider rejection stays unhandled. Results, errors, rejection reasons, unhandled- rejection reporting and relative ordering are unchanged either way; with diagnostics on, the returned promise is a derived one rather than the provider’s own. Breaking for implementors:storage()is a REQUIRED member ofIDiagnosticsClient, so a hand-written client must add it (answeringunsupportedis a valid implementation). -
Outbound HTTP attempt observations (M98n): opt-in, minimized fetch-attempt counters through the diagnostics connector.
@setu-ts/sdkexportscreateObservedFetch({ alias, fetch?, timing? }), which returns{ fetch, plugin }: passfetchtoClientOptions.fetch,SseClientOptions.fetch, or call it directly, and registerpluginin exactly one application. The wrapper calls the wrapped fetch exactly once with the caller’s own arguments (never read) and receiver (except the helper object itself, which is forwarded asundefined— the platformfetchthrowsIllegal invocationon workerd otherwise), returns the identical value or rethrows the identical value — a synchronous throw stays synchronous — and records only that an attempt started, whether it resolved or failed, the response’s status class (2xx–5xx,other) and its time to headers. No URL, host, header, cookie, body, signal or error is ever read.pluginregisters a frozen snapshot-onlyIOutboundHttpDiagnosticsSourceunder the new multi-providerCAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICSand closes the helper on shutdown; it refuses a second application. (A re-registration by the SAME application reopens the source instead of refusing it, but a retriedstart()is not a working recovery today: the kernel refuses the retry itself onceRuntimePluginre-registersruntime.) A record never expires while an attempt is in flight, so a hung upstream stays visible asstarted - count. New public surface on@setu-ts/common:CAPABILITIES.OUTBOUND_HTTP_DIAGNOSTICS,IOutboundHttpDiagnosticsSource,OutboundHttpDiagnosticsSnapshot,OutboundHttpDiagnosticsRecord,OutboundHttpDiagnosticsResponseandOutboundHttpStatusClass; on@setu-ts/sdk:createObservedFetch,ObservedFetch,ObservedFetchOptions. New connector surface:GET /v1/outbound-http(authenticated like every operation; a snapshot operation with no query; more than 16 sources refuses startup; every counting invariant checked by the connector and the client), the status manifest’soutboundHttpkey nowtrue, andIDiagnosticsClient.outboundHttp(), which answers a frozen typedunsupportedresponse without a request when the negotiated manifest lacks the inspector. Breaking for implementors:outboundHttp()is a REQUIRED member ofIDiagnosticsClient. The SDK’scommonimports stay type-only: its collector is a deliberate local copy, and a test checks the module graph. The helper needscrypto.getRandomValues(Node ≥ 19). -
Realtime lifecycle observations (M98l): opt-in, minimized WebSocket, SSE and backplane observations through the diagnostics connector.
WebSocketPlugin,SsePluginandRealtimeBackplanePluginaccept adiagnostics: { enabled: true, alias }option (the newRealtimeDiagnosticsOptions, declared once in@setu-ts/commonand re-exported by all three; validated when the plugin factory is called, never echoing a value). A WebSocket source countsopen(a handshake that fails after the upgrade was accepted is a failed open),close(a close after a transport error or with code1006isfailed) and every framesendwrites or refuses — once, at the connection, so a room broadcast or heartbeat is not counted twice. An SSE source countsopen,closeand every enqueued frame, and reports closes caused by the 1 MiB backlog guard inbackpressureCloses(a number only on the SSEcloserecord,0when none,nulleverywhere else). A backplane source countsbackplane-publish(the transport’spublish()resolving — never peer delivery — with its duration) andbackplane-receive(after the transport’s own shape and origin filters), across the memory, redis and messaging transports; a'custom'transport is never observed. WebSocket and SSE sources also report currentopenConnectionsandgroupsgauges, read from the plugin’s OWN service on each authenticated read, which never expire with the records, so an idle connection stays visible. No frame, message, close reason, connection id, room or channel name, header, query, principal,Last-Event-IDor backplane origin is ever captured. Every instance registers a frozen, snapshot-onlyIRealtimeDiagnosticsSourceunder the new multi-providerCAPABILITIES.REALTIME_DIAGNOSTICS(disabledwithout the option). Observation never changes a result: a refused send throws the same error, a rejected publish rejects with the same reason, and a failing clock or gauge reader latches the sourcecollection-failed. New public surface on@setu-ts/common:CAPABILITIES.REALTIME_DIAGNOSTICS,IRealtimeDiagnosticsSource,RealtimeDiagnosticsSnapshot,RealtimeDiagnosticsRecord,RealtimeDiagnosticsGauges,RealtimeDiagnosticsResponse,RealtimeSourceKind,RealtimeObservationOperation,RealtimeGaugeState, and — because the three owning plugins need the identical collector and may not import one another — the one shared collector:compileRealtimeDiagnosticsAlias,createRealtimeObservationCollector,IRealtimeObservationCollector,RealtimeObservationCollectorInit,RealtimeGaugeReadingandRealtimeDiagnosticsOptions. New connector surface:GET /v1/realtime(authenticated like every operation; a snapshot operation with no query; positionals<N>source ids; more than 16 sources refuses startup; a throwing or invalid source answered by a value-free snapshot of kindunknown; duplicate aliases or an over-budget body collapse to a fixed collection-failed response with no sources; any kind/operation, kind/gauge or backpressure combination outside the contract refused by both the connector and the client), the status manifest’srealtimekey nowtrue, andIDiagnosticsClient.realtime()that answers a frozen typedunsupportedresponse without a request when the negotiated manifest lacks the inspector. Breaking for implementors:realtime()is a REQUIRED member ofIDiagnosticsClient, so a hand-written client must add it (answeringunsupportedis a valid implementation). -
Scheduler execution observations (M98k): opt-in, minimized fire and attempt observations through the diagnostics connector.
SchedulerPluginacceptsdiagnostics: { enabled: true, alias, jobs }(the new exportedSchedulerDiagnosticsOptions, validated whenSchedulerPlugin(...)is called;jobsis REQUIRED and maps exact job names to approved aliases, so an empty map approves nothing); the instance’s ownSchedulerServiceand executor then record each approved job’s local timer fires — contended (slot or overlap lock held elsewhere), lock-failed (a lock operation rejected), dispatched — and each handler attempt (started, succeeded, failed, retryAttempts; abehaviorsentry that declines a dispatch invokes no handler and records no attempt) with monotoniclastDurationMsand the wall-clocklastLatenessMsa fire started late (max(0, actualStart - intendedFire), never an absolute schedule). A skipped local fire is never reported as a globally missed execution: no missed counter exists. Job names, cron expressions, payloads, job ids, lock keys and tokens, and thrown values never reach the collector. Every SchedulerPlugin instance registers anISchedulerDiagnosticsSourceunder the new multi-providerCAPABILITIES.SCHEDULER_DIAGNOSTICS(disabledwithout the option, with nothing attached to the service and no clock read on any fire). New public surface on@setu-ts/common:CAPABILITIES.SCHEDULER_DIAGNOSTICS,ISchedulerDiagnosticsSource,SchedulerDiagnosticsSnapshot,SchedulerDiagnosticsRecord,SchedulerDiagnosticsResponse,SchedulerDiagnosticsOperation. New connector surface:GET /v1/scheduler(at most 16 sources, more refuses startup) and the REQUIREDIDiagnosticsClient.scheduler(); the status manifest now reportsscheduler: true. Unobserved services run the pre-M98k path — one field read, no clock. -
Event dispatch observations (M98j): opt-in, minimized event-dispatch observations through the diagnostics connector.
EventsPluginaccepts adiagnosticsoption (EventsDiagnosticsOptions, exported from@setu-ts/events-plugin) that attaches an internal observer to the EXISTING dispatch path without changing it: a handler rejection still reacheserrorHandlerand never rejectspublish, a thrownerrorHandlerpropagates exactly as before (sync mode:publishrejects; async mode: the dispatch promise rejects unhandled andwhenIdle()rejects), async mode still resolves before handlers settle, and a failing clock latches the sourcecollection-failedwithout ever rejecting a publish or reachingerrorHandler. Only event types whose EXACT raw name appears in the configuredeventsmap are observed, each replaced by its approved alias before anything is retained; event payloads, event ids, aggregate ids, and unapproved type names never reach collector state. A record aggregates per (alias, operation) over a 64-slot bounded table with 60-second retention (re-checked on every update and read, never a timer), a 30-second stale threshold, saturating counters, clamped integer durations, and a saturatingdroppedoverflow counter (each alias can occupy two slots, so more than 32 active aliases can overflow).startedis counted when a boundary begins andcountwhen it settles, sostarted - countshows work still in flight. Both thepublishentry (including no-subscriber publications) and each existing handler’s await are instrumented; a handler rejection countshandler: failedand a thrownerrorHandlerfails the publish without changing propagation. The plugin ALWAYS registers oneIEventDiagnosticsSourceunder the newCAPABILITIES.EVENTS_DIAGNOSTICStoken WITHOUT claiming it inprovides(it is an optional dependency, not a contract):disabledwithout the option,no-databefore the first approved publication,stalewhen every record has aged past the threshold,readyonce a fresh record exists, andcollection-failed— latched by the source until it is recreated when its clock fails, or substituted by the connector for a source whose DTO fails the exact validator;closedetaches the observer FIRST, then clears the bus, and discards any observation arriving after the collector closed. New public surface on@setu-ts/common:CAPABILITIES.EVENTS_DIAGNOSTICS,IEventDiagnosticsSource,EventDiagnosticsSnapshot,EventDiagnosticsRecord,EventDiagnosticsResponse,EventDiagnosticsState,EventObservationOperation,EventDiagnosticsCoverage. New connector surface:GET /v1/event(authenticated like every operation; a snapshot operation — no query — distinct from the paged kernel-event/v1/events; positionals<N>source ids; more than 16 sources refuses startup with a fixed configuration error; an over-budget body collapses to the fixed collection-failed response with no sources; a hostile/throwing source answered by that source’s own value-freecollection-failedsnapshot, duplicate aliases answered by a fixed collection-failed response with NO sources,collection-failed/unsupported/no-data/stale/disabledanswered as typed 200 bodies, never error text), the status manifest’seventskey nowtrue, andIDiagnosticsClient.events()that answers a frozen typedunsupportedresponse without a request when the negotiated manifest lacks the inspector. Breaking for implementors:events()is a REQUIRED member ofIDiagnosticsClient, so a hand-written client must add it (answeringunsupportedis a valid implementation). -
Cache operation counters (M98i): opt-in, minimized cache observations through the diagnostics connector.
CachePluginacceptsdiagnostics: { enabled: true, alias }(the new exportedCacheDiagnosticsOptions, validated whenCachePlugin(...)is called); the instance’s ownCacheServicethen counts each backendget/set/delete/has/clearcall —getOrSetcounted as its internal calls — with explicitsucceeded/failedoutcomes andhits/misses,present/absent,removed/notRemoveddetail counters. No eviction is ever inferred; keys, prefixes, values, URLs, factory results and errors are never captured. Every CachePlugin instance registers anICacheDiagnosticsSourceunder the new multi-providerCAPABILITIES.CACHE_DIAGNOSTICS(disabledwithout the option, with nothing attached to the service). New public surface on@setu-ts/common:CAPABILITIES.CACHE_DIAGNOSTICS,ICacheDiagnosticsSource,CacheDiagnosticsSnapshot,CacheDiagnosticsRecord,CacheDiagnosticsResponse,CacheDiagnosticsOperation. New connector surface:GET /v1/cacheand the REQUIREDIDiagnosticsClient.cache(); the status manifest now reportscache: true, and the connector refuses to start with more than 16 cache sources. Enabled calls read the monotonic clock once (twice on a timed one-in-eight sample, whichlastDurationMsreports) and settle through one derived promise that re-rejects with the original reason, so an unhandled backend rejection stays unhandled. Measured against a real Redis 7: within run-to-run noise with one call in flight, and about 5% of throughput at 50 concurrent calls; against an in-memoryMemoryStore, whose own call is about 100 ns, enabled calls take about twice as long. Disabled calls are unchanged. Results, errors, rejection reasons, unhandled-rejection reporting and relative ordering are unchanged either way; with diagnostics on, the returned promise is a derived one rather than the backend’s own. -
Distributed tracing observations (M98g): opt-in, minimized completed-span observations through the diagnostics connector.
TelemetryPluginaccepts adiagnosticsoption (TraceDiagnosticsOptions) that appends an internal span processor AFTER the exporter processor in the same provider constructor — the exporter path is unchanged (measured: the exporter still receives every span), the processor never exports, never throws into OTel, and observes only finished SAMPLED spans whose exact raw name appears in the configuredoperationsmap, each replaced by its approved alias before anything is retained. A record carries the service and operation aliases, W3C-validated trace/span/parent identifiers (all-zero rejected;parentSpanIdpresent exactly when the parent is locally meaningful), at most eight validated link identifier pairs, kind, outcome (ok/error/unset), monotonicdurationMs/ageMsandparentVisibility(observed/remote-or-unobserved/root/unknown— an identifier relationship only, never a fabricated edge). Span names, attributes, events, resource labels, tracestate, baggage, exceptions and status messages never reach collector state, and kind/status values outside the fixed mapping drop the record — kinds follow the@opentelemetry/apiSpanKindenum (INTERNAL = 0…CONSUMER = 4), and a span created with no kind arrives as0. The plugin ALWAYS registers oneITraceDiagnosticsSourceunder the newCAPABILITIES.TRACE_DIAGNOSTICStoken (claimed inprovides):disabledwithout the option,unsupportedwith the coverage reason (custom-provider/noop-no-provider) when the stack cannot supply completed spans,no-data/readywhen it can, and acollection-failedanswer when the connector’s exact validator refuses a source DTO. The batch reports coverage, instrumentation coverage (only families whose registry outcome said enabled), the configured sampler description, M98a’s cursor contract over a 1,024-record ring with exact per-batchlost, and a saturatingdroppedSpanscounter. New public surface on@setu-ts/common:CAPABILITIES.TRACE_DIAGNOSTICS,ITraceDiagnosticsSource,TraceDiagnosticsBatch,TraceObservation,TraceLinkRelationship,TraceSamplerDescription,TraceSourceState,TraceCoverage,TraceInstrumentationKind,TraceOutcome,TraceParentVisibility. New connector surface:GET /v1/traces?after=N&limit=N(authenticated like every operation; a cursor beyond the source’s sequence refusedinvalid-request, as/v1/queuesrefuses one beyond its merge sequence;collection-failed/unsupportedanswered as typed 200 batches, never error text), the status manifest’straceskey nowtrue, andIDiagnosticsClient.traces(after, limit?)as a required member that answers a frozen typedunsupportedbatch without a request when the negotiated manifest lacks the inspector. Cross-application correlation joins EQUAL trace ids across independently authenticated sessions only; identifiers grant no discovery or connection authority, and no global timeline is implied —ageMsis arrival age at one process. -
Authorization decision explanations (M98h): opt-in, minimized per-decision explanations through the diagnostics connector.
AuthPluginaccepts anauthorizationDiagnosticsoption (AuthorizationDiagnosticsOptions, exported from@setu-ts/auth-plugin) that attaches an internal observer to the first-partyRbacService— the booleanIAuthorizationServicesurface and every guard’s status, body and short-circuit behaviour are unchanged. The observer records, for eachhasAnyRole/hasAllPermissions/single check, only the approved role and permission aliases (raw names never retained), the per-step outcome and the fixed reason vocabulary, and the evaluation count actually performed — a compound that short-circuits reports the steps it really took, never a fabricated full sweep. The plugin always registers oneIAuthorizationDiagnosticsSourceunder the newCAPABILITIES.AUTHORIZATION_DIAGNOSTICStoken:disabledwithout the option,no-data/readywhile it observes the first-party RBAC service,unsupportedonce latched — coveragerbac-not-configuredwhen the option is passed withoutrbac,provider-identity-unavailablewhen the registry lacks the identity predicate,custom-providerwhen the capability resolves to a replacement the observer cannot see — and acollection-failedanswer when the connector’s exact validator refuses a source DTO. The source re-verifies its provider at read time through the new optionalIServiceRegistry.isCurrent?(token, instance)identity predicate — non-resolving, never returning a service — so a replacement registered after startup latches the source tocustom-providerrather than guessing at decisions it did not observe; a registry withoutisCurrentconservatively disables explanations. New public surface on@setu-ts/common:CAPABILITIES.AUTHORIZATION_DIAGNOSTICS,IAuthorizationDiagnosticsSource,AuthorizationDiagnosticsBatch,AuthorizationDecisionObservation,AuthorizationDecisionStep,AuthorizationDecisionOperation,AuthorizationDecisionReason,AuthorizationSourceState, andAuthorizationCoverage. New connector surface:GET /v1/authorization?after=N&limit=N(authenticated like every operation; a cursor beyond the source’s sequence refusedinvalid-request;collection-failed/unsupportedanswered as typed 200 batches, never error text), the status manifest’sauthorizationkey nowtrue, andIDiagnosticsClient.authorization(after, limit?)as a required member that answers a frozen typedunsupportedbatch without a request when the negotiated manifest lacks the inspector. Principal identifiers, role and permission values, request paths and error text never reach collector state; a decision whose requested rule lacks an approved alias is dropped (droppedUnapproved), while an unapproved granting role is only omitted fromviaRoleAlias; a compound’s alias list is de-duplicated, so a request repeating a rule never exceeds 128 aliases; the option is validated whenAuthPlugin(...)is called, with or withoutrbac; explanations are session-local and grant no discovery or connection authority. -
Configuration provenance (M98e): value-free provenance for approved keys, served through the diagnostics connector.
ConfigPluginandloadConfigaccept adiagnosticsoption (ConfigDiagnosticsOptions, exported from@setu-ts/config-plugin) that records — during the one load already performed, never a second pass — where each approved key’s final value was observed to come from: the source category and approved source aliases, evidenced precedence displacement,${NAME}expansion references, and a schema effect derived only from input/output property presence. No configuration value, value hash, value length, raw key name, or file path is ever retained or served; unapproved keys are never observed at all, and an unapproved file path contributes only its category.loadConfig(runtime, { diagnostics })builds the record with the snapshot, andConfigPlugin({ instance, diagnostics })adopts that exact instance’s record — keeping each entry’s realenvironment/fileorigin — or reports an opaque injected instance asunknownwith no presence flag and without a single read of it. Provenance adds no read to any configuration object: configured sections perform exactly the sameIConfig.getcalls with diagnostics disabled and enabled. The plugin always registers the source under the new eagerCAPABILITIES.CONFIG_DIAGNOSTICStoken (common0.8.0:IConfigDiagnosticsSource,ConfigProvenanceEntry,ConfigProvenanceOrigin,ConfigSchemaEffect,ConfigDiagnosticsSnapshot), answeringdisabledwithout the option; the Diagnostics Connector serves it atGET /v1/configand its status manifest now reportsconfiguration: true; the native client reads it throughclient.configuration(): Promise<ConfigDiagnosticsSnapshot>, which — likehealth()— answers a typedunsupportedwithout sending the request when the negotiated manifest (or a legacy M98b status body) reports the inspector unsupported. The configuration and health wire validators — shared by the connector and the native client — now refuse a C0/C1 control character in any alias, as the queue validator already did, so a replacement in-process source cannot forge a consumer’s terminal output; and the connector re-checks the instance binding on the projected copy it signs, so a source whoseinstanceIdreads differently twice is refused rather than signed. The projection copies every source-supplied list itself — reading each item once, never through a source’s ownmaportoJSON, and never past its budget — so the validator checks exactly the bytes that are signed. -
Queue observations (M98f): opt-in, minimized attempt, outcome and depth observations through the diagnostics connector.
QueuePluginaccepts adiagnosticsoption (QueueDiagnosticsOptions/QueueDepthDiagnosticsOptions, exported from@setu-ts/queue-plugin) that observes each dispatched attempt of an exactly-allowlisted job name and retains only the approved queue alias, a session-localj<N>job alias, the attempt number, monotonic duration and age, the processor outcome (completed/retryable-error/terminal-error) and the settlement — recorded only AFTER the adapter’s settlement call returned:acknowledged/requeued/dead-letteredon an adapter that confirms it,failedwhen the call rejected,unknownon RabbitMQ and SQS, whose settlement calls cannot be confirmed. The queue metrics keep their existing timing — recorded BEFORE the settlement call is awaited — so aqueue_jobs_totaloutcome names the settlement requested, never one the backend confirmed. No payload, header, raw job id, claim token, credential or error text is captured. Optional depth counting (depths) runs its own bounded, non-overlapping cycles — never on a diagnostic read — and reportsunavailablerather than zero on adapters that cannot count; each depth carries itsscope(process-local/shared-backend). Every QueuePlugin instance registers one source under the newCAPABILITIES.QUEUE_DIAGNOSTICStoken as a MULTI provider (never inprovides, so named instances cannot collide); an unconfigured instance registers an inertdisabledsource and dispatch is unchanged. New public surface on@setu-ts/common:CAPABILITIES.QUEUE_DIAGNOSTICS,IQueueDiagnosticsSource,QueueDiagnosticsSourceBatch,QueueSourceAttemptObservation,QueueSourceDepthObservation,QueueDiagnosticsBatch,QueueDiagnosticsSourceStatus,QueueAttemptObservation,QueueDepthObservation, and the vocabulariesQueueProcessorOutcome,QueueSettlementState,QueueDepthScope,QueueDepthCycleCoverage,QueueDepthCoverage,QueueSourceFailure,QueueDiagnosticsFailureandQueueSourceState. -
Diagnostics connector —
GET /v1/queuesinspector (M98f). The connector servesGET /v1/queues?after=<N>&limit=<N>and its status manifest now reportsqueues: true; the client reads it through the newIDiagnosticsClient.queues(after, limit?), which answers a frozen typedunsupportedbatch — without sending the request — when the negotiated manifest hasqueues: false. The connector reads every queue source registered at bootstrap (at most 16), keeps one cursor per source, drains new attempts into a bounded 1,024-event merge ring on each authenticated read, and pages that ring under the M98a cursor contract. A source ring that wrapped between two reads is reported on that source’s statuslost, never folded into the batchlost. Each source’s batch is validated key-by-key before merging (a failing source answerscollection-failed), the merged frame is validated before signing, and depths are trimmed to keep it within 256 KiB (truncatedDepths).IDiagnosticsClient.queuesis a new required member of an interface in a package that has not yet been published. -
cli—--styleis its own axis onsetu newandsetu generate app(M99e).--style functional|class-basedselects the decorator-and-DI composition independently of--template:functional(the default) installs neitherDecoratorPluginnorDiPlugin, andclass-basedinstalls both together. It is accepted on the styleable templatesrestandmicroserviceand refused with no template to apply to, with an unknown value, and onfull-stack, which composes through a starter and has no controller or ingress seam to register decorated classes through.--template class-basedbecomes a byte-identical alias of--template rest --style class-based: scaffolding it logs one line naming the canonical spelling, the interactive template prompt omits the duplicate, and--helpshows(alias of …). The class-based microservice host composes the microservice recipe with the decorator and DI pair, and its generated CQRS and event ingress classes register throughDecoratorPlugin({ ingress })— the functionalsrc/cqrsandsrc/eventsbarrels are not emitted, so nothing is registered twice.docs/migration-nestjs.mdgainsScaffoldingandMicroservicessections naming the decorator route for@MessagePattern,@EventPattern,@nestjs/cqrs,@nestjs/bulland@nestjs/schedule. -
Health observations (M98d): an opt-in, minimized view of health-check outcomes through the M98b diagnostics connector.
HealthPluginaccepts adiagnosticsoption (HealthDiagnosticsOptions/HealthDiagnosticsScheduledOptions, exported from@setu-ts/health-plugin) that retains only the LATEST minimized outcome per explicitly allowlisted indicator alias — never a history, and never an indicator’sdata, error text, or absolute time.diagnostics.enabledis the LITERALtrue(an absent option is the disabled path;enabled: false, or any other value, is refused whenHealthPlugin(...)is called — checked at runtime, not only by the type);diagnostics.indicatorsis the exact registered-name to display-alias allowlist (at most 64 entries, unique aliases, 1–64 UTF-8 bytes, no control characters);diagnostics.staleAfterMs(default 30,000) turns the snapshot’sstatestaleonce any retained observation is older than that monotonic age — an observation itself carries only itsageMs; and the optionaldiagnostics.scheduledblock performs bounded scheduled checks (a subset of the approved names, at most 16;intervalMs1,000–300,000;timeoutMs1–30,000 as a reporting bound, not a cancellation;concurrency1–4). Each cycle covers every scheduled indicator not still in flight, from a rotating start; a timed-out callback that has not settled keeps its one concurrency slot and is never replaced early, so a hung indicator cannot accumulate work; while fewer thanconcurrencycallbacks are hung the others keep refreshing, and once every slot is held by a hung callback no scheduled check starts until one settles (each stalled alias keeps its last observation with a growingageMs, or staysnever-observed, and the snapshot turnsstale). Every option is validated whenHealthPlugin(...)is called, with fixed messages that never echo a value. An indicator result whosestatusis notup/degraded/downis observed asfailedand the value is never retained; an approved name no indicator carries staysnever-observed, with a count-only warning at bootstrap. The plugin registers a read-only source under the newCAPABILITIES.HEALTH_DIAGNOSTICStoken and, when scheduled, starts the bounded scheduler fromonBootstrapand tears it down fromonClose. The/health,/live, and/readyendpoints are unchanged: observation is a side channel over the already-produced result, with one callback per normal check. New public surface on@setu-ts/common:CAPABILITIES.HEALTH_DIAGNOSTICS,IHealthDiagnosticsSource,HealthDiagnosticsSnapshot,HealthDiagnosticsObservation,HealthObservationState, andDiagnosticsInspectorState. -
Diagnostics connector —
GET /v1/healthinspector and status-body inspector manifest (M98d). The M98b connector now serves a first inspector operation,GET /v1/health, and the status body gains aninspectorsmanifest (health: true;configuration,queues,traces,authorization,cache,events,scheduler,realtime,storage, andoutboundHttpreserved andfalseuntil their own operations ship). The client reads the snapshot throughIDiagnosticsClient.health(): Promise<HealthDiagnosticsSnapshot>; a client paired against a legacy M98b three-field status body resolves the manifest to all-falseand answers a typedunsupportedwithout sending the request. The connector resolves the optional health source underCAPABILITIES.HEALTH_DIAGNOSTICSonce, at registration: an absent source answersunsupported, a registered-but-disabled source answersdisabled, and a source that throws answers a value-freecollection-failedsnapshot — as does a source whose DTO fails the exact validator (unknown keys are dropped by the field-by-field copy; an unknown enum, a non-finite or negative measurement, an oversized alias or a malformed shape is never signed) — none of which runs an indicator or changes the application’s readiness. The answer is projected field-by-field, signed over the exact body bytes, and bounded by the same 256 KiB response ceiling.docs/diagnostics-protocol.mddocuments the operation and the manifest. -
secrets-plugin—endpointon the AWS and GCP providers (M99d).AwsKmsProviderOptions.endpointandGcpSecretManagerProviderOptions.endpointpoint the lazily loaded client at a non-default endpoint — LocalStack or a private endpoint for AWS, a private or regional host for GCP; both are ignored when aclientis injected. The option is also available through the plugin —SecretsPlugin({ provider: 'aws-kms' | 'gcp', options: { endpoint } })— withcreateProviderforwarding it to the provider. The option is TRANSLATED per SDK: AWS’s config object takesendpoint, while google-gax’sClientOptionsdeclaresapiEndpoint(and has noendpointmember — itsClientStubOptionsindex signature would let a verbatimendpointtype-check and be silently dropped, so the adapter passesapiEndpointand the unit test asserts the exact key per provider). This makes X28-1’s question — doesgeton an absent secret returnnull? — answerable against a real LocalStack for the first time. The GCP value ishostorhost:portwith no URL scheme, split into the SDK’sapiEndpointandportbecause google-gax appends:<port>(default 443) itself — a forwardedlocalhost:8085would resolve tolocalhost:8085:443— and a scheme, path or out-of-range port is refused atconnect(). It is TLS-only: a plaintext emulator is not reachable through it, so inject aclientfor that. Azure (vaultUrl) and Vault (address) already took an endpoint and are unchanged. -
decorator-plugin— cross-family misregistration warning (M99d). A class registered as a controller — throughDecoratorPlugin({ controllers }), a@Module’scontrollers, orautoDiscover— that carries non-HTTP ingress decorators (or one iningressthat carries HTTP route decorators and is registered as a controller by none of those) now gets aregister()warning naming the ignored family and the option that would register it. Previously the ignored half was silent:controllers-only served the routes200and never fired the processor;ingress-only fired the processor and answered the routes404. A class in both lists is the correct composition and warns about neither; nothing is auto-registered. -
cli— devtool scaffolding (M98c).setu new --devtool,setu generate app <name> --devtool, andsetu devtool enable [member]opt a project into the M98b local diagnostics connector through one shared planner: they emit amain.dev.tsthe production graph never imports, adddevandchecktasks, and record aWorkspaceMember.devtoolPortthatallocatePortwalks besideport, so no generated port collides andports --reallocatemoves both addresses together. The emitted config factory takes the devtool composition as its SECOND parameter on every target —setu commandspasses its inert discovery env first, so a first-parameter composition would throw. The entry readsSETU_DEVTOOL_SESSION_ID/SETU_DEVTOOL_SESSION_KEYfrom the environment, refuses to start without a valid pair, and never mints one; in a workspace the launcher also setsSETU_DEVTOOL_MEMBER, and the generatedscripts/dev.tsforwards all three to that member’s child alone while blanking them for every sibling, under a rootdevtask whose--allow-envgrant is scoped to exactly those three names. All three are published CLI surface: renaming one is a breaking change to every scaffolded project.setu devtool enableMERGES into an existingdeno.json(your tasks survive, a differing task refuses by name, a second run is a no-op) and refuses by name — with the exact signature to write — on a factory scaffolded before this letter, whose zero-parameter shape would otherwise discard the composition in silence. It refuses a workspace whosescripts/dev.tspredates the devtool for the same reason: nothing regenerates that file, so it would still spawn every member’sstarttask and pass no per-child environment — the refusal names the remedy, and a workspace missing the runner entirely gets it written. A project type-checks, boots, and answers the reviewedcreateDiagnosticsClientthrough the new end-to-end gate. -
Local diagnostics connector (M98b): an authenticated loopback connection between a native devtool client and M98a’s kernel diagnostics. New package
@setu-ts/diagnostics-pluginwithDiagnosticsPlugin(options)/IDiagnosticsPlugin.revoke()/DiagnosticsPluginOptionsand the native helpercreateDiagnosticsClient(options)/IDiagnosticsClient/DiagnosticsClientOptions. New public surface on@setu-ts/common:CAPABILITIES.LOCAL_DIAGNOSTICS_LISTENER,ILocalDiagnosticsListenerFactory,ILocalDiagnosticsListener, andLocalDiagnosticsListenerOptions. The RuntimePlugin provides the factory: it binds exactly one127.0.0.1listener (port 1024–65535, zero-body policy, duplicate-singleton-header refusal before framework mapping) on Deno and rejects everylistenon other platforms before any bind; its close hook releases an open listener on shutdown AND failed-startup paths. Activation is explicit-only (enabled: true, port, per-launch session ID/key; no environment fallback) and refuses startup without M98a’s diagnostics.enabledis the LITERALtruerather than aboolean, because there is no disabled mode:enabled: falsehas never activated anything, it is refused, soenabled: !isProductionwould not yield an inert connector but an application that throws at composition and never boots. The literal makes that a compile error; keep the connector out of production by deciding whether to construct it. The wire protocol is signed with HMAC-SHA-256 over canonical bytes on both ends (docs/diagnostics-protocol.md), with strict monotonic sequences, instance binding, monotonic expiry, bounded polling (3 GET operations, 256 KiB bodies, 128 events per read), value-free fixed errors, and flood isolation (anonymous refusal budget 5/s burst 10; per-session budget 20/s burst 40; 8 handler slots with 7 for unpaired lanes). Every refusal drops supplied input; the connector’s projection copies M98a’s exact field allowlist, so a hostile provider result cannot leak a canary. The connector announces its own bind through the application’s logger —Setu devtool: local diagnostics connector listening on http://127.0.0.1:<port>— replacing the runtime’s bareListening on …banner, which in an application that also binds a port reads as a second, unexplained application listener.LocalDiagnosticsListenerOptions.onListencarries it; omitted, the runtime’s own banner stands, so a bind is never silent. -
Kernel diagnostics (M98a): an optional, read-only view of application composition and kernel execution.
createApplication({ diagnostics: {} })enables collection and exposesIApplication.diagnostics— a pull-onlyIDiagnosticsSourcewithsnapshot()andread(after, limit?). New public surface on@setu-ts/common:IDiagnosticsSource,DiagnosticsSnapshot,DiagnosticsNode,DiagnosticsEdge,DiagnosticsEvent,DiagnosticsBatch, and their vocabulary typesDiagnosticsNodeKind,DiagnosticsEdgeKind,DiagnosticsSnapshotState,DiagnosticsFailureCode,DiagnosticsEventKind,DiagnosticsEventOutcome, andDiagnosticsEventStage. New public surface on@setu-ts/kernel:KernelDiagnosticsOptions,KernelDiagnosticsLabelOptions, and theApplicationOptions.diagnosticsoption. An omitted option allocates nothing: no collector, mirror, ring, or timer, and the property is absent. Labels are OFF by default and gated by exact allowlists; route patterns, plugin names, capability tokens, and middleware names leave the process only when explicitly approved. A plugin’sversionis the one projected field that is NOT allowlist-gated — it is emitted whenever it passes a boundedmajor.minor.patchsemver grammar, so enabling diagnostics discloses the version of every registered plugin, including the framework’s own. Snapshots are bounded (1,024 nodes / 4,096 edges / 256 KiB of compact JSON) and event batches are bounded (1,024 events, 1,024 bytes per event) with drop and loss counters. Startup failure and final shutdown clear retained metadata while preserving the original error. The topology sinks are installed at CONSTRUCTION, so a route or capability registered betweencreateApplication()andstart()is projected: a CLI-scaffolded project registers its routes exactly there — through the generatedregisterGeneratedRoutes(app.router, …)setup call and the hello-world route — and installing the sinks at startup instead would show no routes at all for the default template while the application served them. A composition-time registration carries noownsedge, because no plugin’sregister()was running: the application registered it. -
deno task check:docsnow refuses an export added to a published barrel that noUnreleasedchangelog entry names. Four releases in a row shipped, or nearly shipped, public surface that no entry announced —alpha.10lost PR #195,v0.4.0lostResponseSnapshotInit,v0.5.0lostrespondWithAuthorizationFailure, andv0.7.0lost thirteen ingress decorators on@setu-ts/decorator-plugin. Every one was caught by a human reading the merged PR list at cut time, whichdocs/releasing.mdcarries as a manual step; this is that step, run on every pull request instead of once per release.It compares the README
## Exportstables between the last release tag and the working tree, which is what makes it cheap: those tables are derived from the barrel bydeno task docs:exportsand already gated for drift, so the committed table is the barrel’s contents in a form two git revisions can be diffed in — no TypeScript parser, nodeno docrun per package, no network. It reusesparseExportsTable, so this gate and the drift check cannot disagree about what an export is.It deliberately does NOT require an entry to name its PR, which was the blocker recorded when the gate was first proposed: entries are prose and carry no stable identifier. Naming the SYMBOL is cheaper for the author and a better check. The mention must sit in a code span — a first cut matched anywhere in the section and was satisfied by the section’s own
### AddedHEADING, which is a false PASS, and every Keep-a-Changelog heading is a plausible identifier. An unresolvable base tag fails closed with the remedy in the message, because a shallow checkout fetches no tags and would otherwise compare nothing silently; both workflows now check out full history.Two review findings, both the gate’s own failure mode pointed at itself:
git ls-fileshad its exit status discarded, so a missing binary or a cwd outside the repository returned an empty list that read downstream as “no packages to check” — demonstrated by running it from/tmp, where it answered0 READMEsand passed. It throws now, and a run that compared NOTHING is reported as a failure rather than a pass, in the shapescript-coveragealready uses for its target set.Validated against the three releases that actually lost an export, before it was written: replaying each reports exactly the symbols their manual checks found, and replaying the current tree reports none. What it does not catch is a release-worthy change that adds no export — PR #195 was dependency ranges, a workflow and a release artifact — so reading the merged PR list stays in the runbook.
Changed
-
AuthPlugin’sregister()returns a promise when asamlprovider is configured (M100f), because the SAML library load is awaited there. Every other configuration still registers synchronously.SignInProvidergains theSamlProviderarm, so code switching exhaustively overprovider.kindmust handle'saml'; an unknownkindis now refused naming'oidc', 'oauth2' or 'saml'. -
common—IAuthSessionServicegains a requiredpending()member andSignInOutcomewidens (M100d, breaking).IAuthSessionServicenow requirespending(ctx): PendingSignIn | null, which reads the pending second-factor record (if any) for the session.SignInOutcomewidens from{ status: 'signed-in' }to{ status: 'signed-in' } | { status: 'second-factor-required' }, so a sign-in that requires a second factor no longer returnssigned-in. Applications that implementIAuthSessionServiceby hand must add thepending()member; applications that consume the outcome must handle the newsecond-factor-requiredarm.@setu-ts/commonexports the newPendingSignIninterface. WithsignIn.mfaunset theauth-plugin’s ownAuthSessionServicenever returnssecond-factor-required, so existing consumers see no behavioral change. -
auth-plugin—AuthPluginnow registersauthMiddleware()globally by default (M100a). Every request runs the passive strategy chain at priority 300, soctx.request.useris populated on routes that previously saw no principal, and a backend-backed strategy (apiKey.validate, a store-strategy session) now performs its lookup for every request that carries its credential, including health probes and metrics scrapes that do; a request without one costs no lookup.middleware: falserestores the previous composition exactly: nothing is registered globally and route-levelauthMiddleware()copies keep working.middleware: { exclude }skips selected paths instead (no path is excluded by default). A hand-added globalauthMiddleware()should be removed; left in place it stays correct but runs every strategy twice. Seedocs/upgrading.md. -
kernel— retrying astart()that failed after plugin registration began now throws by name.start()rolls its started state back on failure, and its comment promised the failed start could be “corrected and retried”. Once a plugin had runregister(), that retry re-ran every plugin against state that survived the rollback — the registry (sealed, too, if the failure came after bootstrap), lifecycle hooks, routes and middleware — so in the ordinary case it failed with a misleadingCapability 'runtime' is already registered(orCannot register capability 'runtime' after runBootstrap() has completed). It now fails withCannot retry start() after plugins have registered … Create a new application instead., before anything runs again. This also refuses a retry that used to succeed: when the first plugin to run threw before writing any state — for example a runtime provider rejecting its options — nothing was left behind and the retry worked. It is refused now because the kernel cannot tell a plugin that wrote nothing from one that opened a connection itsonClosedid not release. Failures during plugin resolution (no runtime provider, an unsatisfied dependency, a cycle) stay retryable, as before. Migration: code that catches a failedstart()and callsstart()again on the same application must create a new application instead (call yourcreateApp()again). -
realtime-backplane-plugin— a Redis publish on a failed connection is reported, not lost. A connection that stays open while the server answers nothing — a paused or partitioned host that sends no reset — triggers no ioredis reconnect, so no retry budget applied andpublish()never settled: the WebSocket and SSE consumers never logged the dropped frame, the promises accumulated, and an observed publish (M98l) was never recorded. Measured against a paused real Redis 7: still pending after 20 s. The two connections the lazy path builds now carry an iorediscommandTimeout, from the newRedisBackplaneOptions.commandTimeoutMs(defaultDEFAULT_REDIS_COMMAND_TIMEOUT_MS, 15 s;0disables; a value outside0–2147483647throwsRangeErrorat construction, since a longer delay overflows the runtime timer to 1 ms and every command then times out at once). The default sits above ioredis’s ~11 s retry budget, so a dropped connection still buffers and rejects as documented. An injectedclient/subscriberpair is unaffected and keeps its own timeout.IRedisModule.creategains an optional secondoptionsargument carrying it; existing callers and modules still type-check. Behaviour change:RedisBackplane.publish()beforeconnect()or afterclose()now REJECTS (“the redis transport is not connected”) instead of resolving without sending, so a frame sent nowhere reads as a failure — it previously counted as a successful publication when observed.close()during an outage now releases both connections. It stopped at the first rejected step — an UNSUBSCRIBE or QUIT that timed out — so both connections kept reconnecting after the application stopped; measured against a real Redis, both reconnected (ready) once the server returned. Every step now runs, and a connection whose QUIT fails is force-closed through the new optionalIRedisBackplaneClient.disconnect?()— which is what stops reconnection while the server is unreachable, since ioredis discards a QUIT that never reached it and would otherwise reconnect and re-subscribe once the server returns — and the first failure is still rethrown after everything is released. Against a silent serverclose()can take up to about three command timeouts (≈45 s at the default) — lowercommandTimeoutMsif that exceeds a shutdown grace period. -
cli—setu new --template full-stacknow demonstrates React Router route middleware, and explains both middleware layers. A generated full-stack project has two: kernel middleware (setu generate middleware), which runs for every request, and React Router’s routemiddlewareexport, which runs only for the SSR routes it is attached to. The scaffold carried no example of the second, so where a route middleware goes and how it hands a loader a value had to be learned from React Router’s documentation. It now emitsapp/middleware/require-user.server.tsandapp/models/user.ts, acurrentUserContextkey andgetCurrentUseraccessor, and a README section comparing the two layers. Generated behaviour changes:/productsin a newly scaffolded project now redirects a visitor who is not signed in to/login(existing projects are unaffected). A kernel middleware generated into a project that installsreact-router-pluginnames the route-level alternative in its JSDoc, and every generated middleware’s priority comment now lists tenant resolution (40) and request logging (100), which it omitted, and explains short-circuiting beforeawait next()rather than after it, where returning early no longer stops anything. -
cli— the--direfusal names the--styleaxis (M99e). The retired--diflag’s message now directs the caller to--style class-based(with--template restormicroservice) rather than only to--template class-based, and the broker refusal’s “use--template microservice” advice gains “(add--style class-basedfor decorators)”. -
cli— every newly scaffolded project declares@setu-ts/kerneland a two-parametercreateApp(M98c). The config factory’s devtool parameter namesKernelDiagnosticsOptions, so the kernel is now pinned on every target — including starter-composed templates, which referenced nothing from it before. A package import naming only types renders in theimport type { … }form. Every generated Deno workspace’s rootdevtask also carries the scoped--allow-env=SETU_DEVTOOL_SESSION_ID,SETU_DEVTOOL_SESSION_KEY,SETU_DEVTOOL_MEMBERgrant its runner reads, whether or not the devtool is enabled. Existing projects are unaffected: nothing regenerates a scaffolded file, and--allow-netis unchanged and still unscoped. -
storage-plugin— eleven provider methods now REJECT instead of throwing synchronously (BREAKING for callers that caught the throw). Ten guard with a private#assertConnected()that throws —S3Providerget/delete/exists/getSignedUrl/getStream,GcsProviderput/getStream, andAzureBlobProviderdelete/exists/getSignedUrl. Each was typedPromise<...>but notasync, so the throw escaped synchronously —provider.get('k').catch(handleIt)never ran and the error was uncaught, whileput()on the same class rejected. This is the recurring defect class fixed in M52b, M52c, M70j, M79 and PR #355; all are nowasync, so the same precondition arrives as a rejection, matching the committedIStoragecontract and each provider’s ownasyncsiblings.AzureBlobProvider.getSignedUrl’s second refusal (no account key to sign with) moves with it. The eleventh isLocalStorageProvider.getSignedUrl, whose refusal is permanent (local storage cannot presign) rather than a lifecycle precondition — which is why it was nearly missed. It is the same defect in the same shape, and it was reachable through the PUBLISHED contract rather than only the provider class:StorageService.getSignedUrlis a bare passthrough, so the throw escaped out of theIStoragean application resolves fromCAPABILITIES.STORAGE. Leaving it would also have madelocalthe only one of sixgetSignedUrlimplementations that throws —cloudflare-plugin’sR2Storagealready answers this exact cannot-presign case withPromise.reject. Migration: no source change for callers usingawaitor.catch(), but only theawaitones see no difference — a.catch()handler was previously BYPASSED by the synchronous throw and now actually runs, which is the point of the fix. A caller that wrapped one of the eleven calls in a synchronoustry/catchwithout awaiting stops catching; move the catch onto the promise (await …in anasyncfunction, or.catch(...)). Seedocs/upgrading.md.StorageService.delete/exists/getSignedUrlwere barereturn this.#provider.x()passthroughs and are nowasync, so a THIRD-PARTY provider’s synchronous throw can no longer escapeIStorageeither. That path is reachable from the published surface:StorageServiceis barrel-exported, and structural typing means an application needs noStorageProviderimport to pass its own provider. Nothing changes for the five built-in providers, which all return promises.
Fixed
-
setu addprinteddeno installfor every project. A Node or Bun project was told to run a command its toolchain does not use. It now prints the project’s own install command —npm installfor Node and Cloudflare Workers,bun installfor Bun, anddeno install --min-dep-age 0for Deno — and, when it writes an npm-compatpackage.jsonentry into a Node, Bun or Workers project whose.npmrcdoes not route the@jsrscope, prints the registry line the install needs.setu addstill never installs for you. Apackage.jsonwith nostartscript (one the CLI did not write) is now read as Node, or Bun beside abun.lock/bun.lockb, unless adeno.jsonsits beside it — it was read as Deno. This also changessetu generatein such a project: it emits the Node or Bun test harness rather than a Deno one whose@std/*imports the project could not resolve. -
setu addat a workspace root wrote the pin into the root and exited0. Framework packages are pinned per member, andsetu generatereads the member’s manifest for plugin gating, so the package looked installed while no member could use it. A workspace root — recognised bysetu.workspace.json, adeno.jsonworkspacekey, or apackage.jsonworkspaceskey — is now refused with exit2, writing nothing and naming--dir <member directory>. A script that relied on the old exit0at a root will now fail. -
mail-pluginnow loadsnodemailer10, closing a high-severity advisory.SmtpProviderlazily importednpm:nodemailer@^9, and every 9.x release is inside GHSA-v53p-9fqp-m79j (quadratic backtracking in the address parser’s free-text fallback, a remote denial of service, fixed in 10.0.6). The lazy import is nownpm:nodemailer@^10. nodemailer 10’s only breaking change is requiring Node.js 20 or newer; the transport API the plugin uses (createTransport,sendMail,verify,close) is unchanged, which was verified against the real 10.0.12 module. An application that injects its own transport is unaffected. -
cache-plugin,messaging-plugin,queue-plugin,realtime-backplane-plugin,scheduler-plugin,auth-plugin— a Redis outage no longer prints to the console. Everyioredisclient the framework builds had no'error'listener, soioredisfell back toconsole.errorand printed[ioredis] Unhandled error event: … ECONNREFUSEDon every reconnect attempt — dozens of lines per outage, outside the logger, its structure, its redaction and its sink.@setu-ts/commonaddscreateConnectionErrorReporterandattachConnectionErrorReporter: the first error of an outage is logged atwarn, identical repeats atdebugwith a count, the recovery atinfo, and a failing logger never throws into the driver (typesConnectionErrorReporter,ConnectionErrorReporterOptionsandConnectionErrorLogger). The five plugins attach a reporter over the application logger (read at call time) to the clients they BUILD, through a new optionalconnectionErrorReporteroption on the Redis store, broker, adapter, lock and backplane options; an injected client gets no listener, since it belongs to the caller.RedisRateLimitStoreis built by the application, so it takes the reporter as an option and keeps the console fallback without one. With no logger registered the events are dropped; health reporting is unchanged. All additions are optional, so no caller changes. -
diagnostics-plugin— the connector refuses URL aliases of a target and answers an out-of-range events cursor as a caller error. Found by the M98b committed-tree security audit. The handler parsed the target from the NORMALIZED URL, so/v1/./snapshot,/v1/x/../snapshot,/v1/snapshot?and a target carrying a fragment were each served as the canonical operation, contradicting the protocol specification’s refusal of non-canonical forms; the raw request target must now equal the canonical one. Integrity was never affected — the MAC covers the canonical target, so no request could be retargeted without the key. Separately,GET /v1/eventswith a cursor beyond the reader’s sequence let the reader’sRangeErrorescape and answered503 unavailable; it now answers400 invalid-request, as/v1/queuesand/v1/tracesalready did. No released version carries either defect. -
kernel,queue-plugin,telemetry-plugin— a stopped diagnostics reader no longer echoes an invented cursor. After shutdown,IApplication.diagnostics.read(), the queue diagnostics source and the trace diagnostics source accepted ANY non-negative safe integer asafterand returned it verbatim asnext—read(500)on a reader that had issued one event answerednext: 500— contradicting their own contracts, which refuse a cursor beyond the current sequence. Shutdown never rewinds a sequence counter, so every cursor a reader could have been issued is still at or below it: a closed reader now applies the same refusal it applies while running, and still answers an emptyclosed: truebatch for every cursor it did issue. No released version carries the defect; all three readers are new in this release. -
diagnostics-plugin— the native client binds every post-pairing response to the paired instance. Pairing compared the status body’sinstanceIdwith its authenticated header, but later exchanges only verified the MAC — and thex-setu-instanceheader is an INPUT to that MAC, so a peer holding the session key could sign a snapshot under a different identity (header B / body A, header A / body B, or both) andsnapshot()andread()accepted all three. The shared exchange now refuses any signed response whose header differs from the instance the request presented, which covers every operation including the four inspector reads, andsnapshot()/read()require the bodyinstanceIdto equal the paired instance (anullbody identity, valid for the in-process reader before the runtime assigns one, is refused on a paired network session). A refusal is the existing fixed connection error and, like any post-pairing verification failure, not terminal. Legacy/current status negotiation and terminal initial pairing are unchanged. The client has not yet been published, so no released version carries the defect. -
diagnostics-plugin— the native client validates the full core DTO and freezes what it returns. The core snapshot and batch validators checked little more than primitive types: any string passed asstate, a node or edge record could carry any fields, and an event needed only a numericsequenceand stringoperationId/stage. They now enforce the M98a contract — only defined keys, fixed vocabularies, per-kind node fields, prefixed unique node ids, bounded control-free labels, edges between present nodes, canonical operation ids, non-negative (ornull) timings, validated trace identifiers, consecutive sequences, and the 1,024/4,096/128 bounds — andread()now checks the cursor contract it sent, asqueues()andtraces()already did.snapshot()andread()also returned the parsed JSON unfrozen while their documentation promised frozen data; both now deep-freeze it. An in-processinstanceId: nullremains valid; a middlewarepriorityand an eventstatusCodestay unranged finite numbers because the application sets both; a refusal is the fixed connection error. -
kernel— diagnostics omit a non-finite middleware priority or response status. Both were recorded verbatim, sopriority: NaN(whatNumber(env.X)yields for an unset variable) orstatus(NaN)putNaNin a fieldcommontypes asnumber, and the connector serialized it asnull. A non-finite value is now omitted from the node or event; every finite value is still recorded as the application set it. -
kernel— a retried start no longer reuses diagnostics event sequence numbers. A failed start cleared the event ring and restarted numbering at 1, so after the kernel-supported correction (unregister+start()) a reader’s cursor from the failed attempt was refused as “beyond the current sequence” or silently skipped the retry’s first events. The discarded records are now treated like an eviction: numbering continues, and a reader’slostcounts them; theDiagnosticsBatch.lostJSDoc now says so. -
telemetry-plugin— exported spans now carry the span kind and status OpenTelemetry defines.TelemetryServicemapped the framework’sSpanKindonto the OTLP WIRE numbering rather than the@opentelemetry/apienum a span holds in memory, so everyserverspan was exported asCLIENT,clientasPRODUCER,producerasCONSUMER, andconsumeras an out-of-range kind. AndISpan.setStatus('ok' | 'error')handed OTel the bare string, while OTel’sSpan.setStatusreadsstatus.code— so every span’s status was exported as{}: an errored request or a thrown handler never reached Datadog, New Relic or Application Insights as an error. Both are corrected (kinds0–4, status{ code: 0 | 1 | 2 }), verified against the real locked SDK. Behavior change for dashboards: server spans that were filed under CLIENT now appear as SERVER, and error rates computed from span status become non-zero where requests actually failed. The same defect made the M98g diagnostic processor drop every span that set a status — every HTTP server span. -
queue-plugin— a job whose thrown value cannot be described is no longer left stuck. With a logger registered,QueueServiceconverted a non-Errorthrown by a processor withnew Error(String(error))outside its reporting guard, so a value whose string conversion throws — a payload-derived{ toString: 1 }, a revokedProxy— made the failure report itself throw. That escaped the job runner before the requeue or dead-letter call, and the job stayed in its processing state for the life of the process. Describing the value now happens inside the guard, falling back to a fixed message, and the queue metrics collector’s report path is guarded the same way. Found by the M98f committed-tree security audit; no configuration change is needed. -
cli— the--runtimedocumentation read as a lock-in, and one of its claims was stale. The scaffolding examplesetu new my-app --runtime node # deno | node | bun | cloudflare-workerssuggested a project is tied to the runtime it was created for. It is not: ondeno,nodeandbunevery template emits byte-identicalmain.ts,setu.config.tsandsrc/, andRuntimePlugindetects the platform at startup, so only the manifest and start command differ.docs/cli.mdnow says so and gives the verified steps for moving between runtimes — including that a move to Deno must replacepackage.jsonrather than sit beside it (setu generatereadspackage.jsonfirst), withfull-stackas the one template that keeps it for the Vite build — and the Workers exception. Separately, the CLI README’s option table said--runtimedefaults todenoonsetu generate; it is detected from the project’s manifests. Wording only; no behaviour, API or export changed. -
cli—--transport-urlcould inject code into a generated workspace member. The broker URL fallback was wrapped in quotes without escaping when rendered into each member’ssetu.config.ts, so a value carrying a quote closed the string literal and the rest ran as code whenever the member started —redis://h:1'+(globalThis.PWNED='yes')+'did exactly that. The value is also persisted insetu.workspace.json, so every latersetu generate apprepeated it. It is now rendered as an escaped string literal that evaluates to exactly the value given, for both the flag and a hand-edited manifest; an ordinary URL renders byte-identically, so no existing generated file changes. A member name read back from that manifest reached the generated discovery module the same way, as a raw object key, and is rendered the same way now. Present since the workspace transport arrived; found by the M99e security audit and fixed there at the maintainer’s direction. -
cli— a name that is not safe to generate from is refused before anything is written (M99e). Every name-taking verb (new,generate app,generate <schematic>,generate library,adopt) joins the derived kebab into a path, andsetu new ../sibling,setu new ..andsetu generate app ../siblingwrote the scaffold into an ancestor directory. Punctuation also passed through into generated source:setu g service a:bemittedclass A:bService, and a quote (x'y) closed the@Injectabletoken literal early — source injection from the command line — whilesetu new 'x"y' --runtime cloudflare-workersbroke the emittedwrangler.toml. A verb that generates source now requires every derived form (Pascal, camel, SCREAMING) to be a TypeScript identifier: letters (Unicode included, socaféstill works), digits after the first character, and the separators-,_and space.setu newtakes a portable project name — it starts with a letter or digit and holds only letters, digits,.and-— so3d-shop,2048andmy.appkeep working. A Windows device name (con,nul,com1, … with or without an extension) and a name ending in.are refused everywhere, since Windows cannot create either. Any name over 255 bytes, and a name whose planned file name (<name>.controller.ts, a library’stest/<name>.test.ts) exceeds 255 bytes, is refused too, where it used to fail mid-write with an uncaughtFile name too long. Every refusal is a usage error (exit2) with no writes, and echoes the name with its control characters escaped, so the message stays one line. No argument can forge an output line any more: an option value carrying a control character is refused before any command runs, every line the CLI writes has its control characters other than the line feed and tab escaped (bidirectional format characters included, so a quoted value cannot be reordered as displayed), and a positional quoted back (a name, a schematic, an unknown command, anaddpackage, adevtool enablemember) is escaped where it is quoted. A suggested command copies a value only when it names a real template, style or runtime, so an unknown value is never planted in a command you are told to run.--transport constructoris now an unknown transport rather than an alias of a built-in. -
health-plugin— an unrecognized indicator status could hide another indicator’sdown, so/healthand/readyanswered200over a failing dependency. The aggregate took the worst status through a rank table, and a status outsideup/degraded/downhas no rank, so every comparison against it was false and the fold carried it forward: an indicator returning such a value after adownone replaced thedown, and a laterdegradedthen won — measured,/healthanswered200 degradedwith a checkdown, so a readiness probe passed over a dead dependency. The value was also published verbatim onto/health, and anullresult made the endpoint reject. An indicator result is now read through one rule, shared with the M98d observation collector: a result that is not an object, or whosestatusis outside the three, is reported as{ status: 'down', data: { reason: 'invalid-result' } }with its value dropped, and a throwing getter is a failed check (reason: 'error'). Adatavalue that is not an object is omitted. Behaviour change: an indicator that returned an unrecognized status now fails/healthand/readywith503; return'up','degraded'or'down'. The defect predates M98d — it was onmainsince M20 — and is fixed on the M98d branch at the maintainer’s direction, where the security audit found it. -
cloudflare-plugin— the R2 documentation saidgetSignedUrl“throws” where it rejects. The behaviour was always correct (R2Storage.getSignedUrlreturnsPromise.reject), but four published sites — the class JSDoc, the method JSDoc, the README caveat andPUBLIC_API.md— read as an instruction to catch it synchronously, which never works. Wording only; no behaviour, API or export changed. -
kernel—inject()no longer MUTATES a caller-suppliedHeadersinstance (M99c). The per-shape content-type default wrote onto the caller’s own object whenInjectRequest.headerswas aHeadersrather than a record, so one instance reused across two injected requests — an ordinary way to hoist a shared auth header in a test — carried the first request’scontent-typeinto the second:!headers.has('content-type')then declined to set the right default, and aURLSearchParamsbody following a JSON body arrived as JSON.inject()now defaults onto a copy, which also contains a handler that mutatesctx.request.headers. This is the aliasing hazard the byte copy in the same release guards against, one field over; it predates theBlobdefault below, which only widened its reach, and is fixed here at the maintainer’s direction rather than on a separate branch. Nothing restores the old behaviour, because nothing could depend on it: a caller wanting the header set passes it explicitly, which still wins. -
kernel—inject()defaults aBlobbody’s content type fromblob.type, so an injected multipart Blob no longer 500s where the same Blob throughfetch()answers 200 (M99c). ABlobCARRIES its type — the caller has said what the bytes are — and the platform’snew Request(url, { body: blob })sets the header from it, so the injected entry point now says the same thing: a non-emptyblob.typebecomes the content-type default, a typeless Blob contributes nothing, and an explicitheaders['content-type']still wins.Uint8ArrayandArrayBufferkeep NO default: bytes alone genuinely say nothing about their encoding, so the0.7.0entry’s stated reasoning now names only those two shapes, which is the correction to it this fix ships alongside. -
sdk— two correct naming rules shipped together no longer collide with each other (M99c). X11-6 made@setu-ts/openapi-pluginname a reused schema after its first use (getOrdersResponse200→ componentGetOrdersResponse200); X11-9 made the generated client hoist an inline multi-line response body under the SAME derivation. One schema that is both reused (so it becomes that component) and rendered inline (so its body is hoisted) claimed the identical name twice, and generation aborted withDuplicate generated name 'GetOrdersResponse200'— the0.6.0SDK throws identically, so this is not a regression, only invisible until a document carried the trigger. Neither entry above is rewritten: each rule is individually correct, and the interaction is what changed. The component name is published surface of the document, so the HOISTED ALIAS yields — it asks for its preferred name first (byte-identical output for every document that generates today), takes…Response<status>Bodywhen that is claimed, then an allocated numeric suffix; and because an alias names an anonymous inline schema the document never named, the same preference-and-fallback now resolves all four hoist sites (request body, parameter, success response, error body) instead of moving the abort. -
storage-plugin— cancelling a download no longer crashes the process (S3, GCS, Azure).S3Provider.getStreamreturned the SDK’s node stream through Deno’snode:stream-to-web adapter, which enqueues on'data'without checking whether the web controller was already closed: areader.cancel()that raced origin teardown (a connection dropped while bytes were still flowing) enqueued into the closed controller and threw an uncaughtTypeError: The stream controller cannot close or enqueueout of the tick queue, killing the process. Node-shaped bodies are now wrapped in a pull-driven adapter (src/providers/provider-stream.ts) that guards every controller touch and destroys the node stream on cancel; the measured read-ahead shape is unchanged.GcsProviderandAzureBlobProvidercarried the same hazard in a deterministic form — no cancel hook at all, so a cancelled download kept flowing and the next chunk hit the closed controller — and now route through that module’s EAGER adapters, a different one from the pull-driven S3 wrapper above, which release the upstream stream on cancel (they remain eager, never demand-driven).GcsProviderpicks its adapter by probing the stream’s shape rather than assuming one, so an injected client whosecreateReadStream()only emits'data'/'end'/'error'— allIGcsClienthas ever promised, sincebucket()returnsunknown— keeps streaming, now with the cancel hook it never had; a value carrying neither shape is refused by name instead of failing with a bareTypeError. The S3 wrapper also keeps a lifetime'error'listener on the node stream: its per-read listener is detached between pulls, which is exactly where a slow consumer sits once the web queue is full, so an origin failure in that window would otherwise emit an unhandled'error'and escape as the same uncaught, process-killing throw — the error is now recorded and surfaced to the consumer on the next read. A real-backend regression test drives the firing shape — three back-to-back reads, then concurrent origin teardown and cancel — against MinIO. -
logger-plugin— default redaction now covers normalizedauthorizationheaders. The shipped default is lowercase and default matching is case-insensitive across both console and Pino transports; a caller-suppliedredactlist remains case-sensitive. This prevents Fetch’s normalized bearer-token header from reaching structured logs (M99a). -
messaging-plugin— a known Service Bus data-plane outage no longer ages back to healthy. A network publish failure remains reachability evidence until a successful publish or a positive management probe contradicts it;dataPlaneEvidenceMsnow bounds positive evidence only (M99a). -
cli— a generated workspace image now verifies its lockfile at build time. The build step isdeno cache main.ts && deno install && deno install --frozen, notdeno cache main.tsalone. Deno records a jsr package’s npm edge list nondeterministically on a cold cache: four--no-cachebuilds of one unchanged workspace left@setu-ts/messaging-pluginmissing itsnpm:amqplibandnpm:ioredisedges twice and complete twice, while both packages were recorded in the lockfile’s package section every time. Runtime--frozendoes not write the lockfile, so against an incomplete one it refuses, and every container died at registration reporting a stale lockfile — an image that built green and never served.deno installcompletes the edge lists the frozen check compares against — the same resolver writes and verifies them — anddeno install --frozenproves it, so a still-missing edge fails the build once instead of every container at startup. Regenerate an existing workspace’s managed Dockerfile withsetu generate app <member>to pick it up. -
check:deploy --generatednow sees this defect on every run rather than intermittently. It built from whatever lockfile the host’s own install happened to write, so a Dockerfile regressed todeno cache main.tsalone — the exact pre-fix form — passed it. The gate now strips every frameworknpm:edge from the scaffolded workspace’s lockfile before building the image, leaving every resolved version pin intact, so the build has to establish lock completeness itself; that same regressed Dockerfile now fails. A strip that removes nothing fails the gate rather than passing quietly. -
cli— generated workspace Dockerfiles now run withdeno run --frozen. The runtime uses the lockfile that its build cached and still cannot modify it on a generated read-only root, so a lazy broker driver cannot re-resolve an uncached npm transitive dependency at startup (M99b). -
cli— an interrupted scaffold compensates for caught write failures. It restores files it overwrote, removes files and empty directories it created, and reports any incomplete recovery;adoptnow reports an entry rewrite failure instead of presenting it as a port-literal mismatch. Reported by u/kantorcodes1 (M99b).
0.7.0 — 2026-09-18
Added
-
decorator-plugin,cli— decorators for the six non-HTTP ingress categories (M97a). Queue, scheduler, domain events, messaging, WebSocket and CQRS get the class-based registration surface HTTP has had since M9:@Processor,@Cron/@Every,@OnEvent,@Subscribe,@Gateway(with@OnOpen/@OnMessage/@OnClose) and@CommandHandler/@QueryHandler, plus@UseIngressBehaviorsand@UsePipelineBehaviorsfor the per-target chains. Six categories, seven provider tokens, becauseCqrsPluginprovidesCOMMAND_BUSandQUERY_BUSseparately and the registration pass resolves both buses directly.A decorated target registers imperatively against the capability RESOLVED from its token, which is why this is an ergonomic surface rather than a second pipeline: the behaviour-wrapped service is what each plugin already registers under its token, so a decorated processor, job or subscription inherits the M86 ingress-behaviour and M90i tracing stack unchanged. No
commonchange, no new capability token, and no plugin imports another — the ingress map lives on the concreteMetadataStore, and no ingress plugin depends onMETADATA_STORE, so no cycle is introduced.Four things are refused at
register()rather than at the first message, each naming the class, the method and the alternative: a decorated target whose backing plugin is not registered (naming both the plugin and the token), two primary ingress decorators on one method,@UsePipelineBehaviorsanywhere but a command or query handler, and@UseGuardson a non-HTTP ingress target — guards are an HTTP-pipeline concept and do not carry over, so the refusal points at@UseIngressBehaviors/@UsePipelineBehaviorsinstead.setu generateemits the decorated form from the existingcommand-handler,query-handler,event-handler,jobandws-routeschematics when the project hasdecorator-plugin, andsetu addknows the backing plugins by short name. -
config-plugin— typed configuration sections (M97c).defineConfigSection({ prefix, keys, schema })declares related flat keys, andgetConfigSection(config, section)returns the exact schema output validated and cached at startup. Sections run after whole-snapshotvalidationSchemacoercion and are validated for both ordinary loading and an injectedinstance. The explicit prefix-strippedkeyslist keepsIConfigunchanged while allowing an arbitrary injected implementation to be validated through its existing named-read contract. A section validation error identifies only its declared prefix; it never carries the schema diagnostic or a configuration value. -
decorator-plugin,common,openapi-plugin— response shaping for decorated handlers (M97b).@HttpCode(status),@ResponseHeader(name, value)(repeatable, distinct names) and@Redirect(url, status?)let a decorated handler state its success status and its response headers in its declaration instead of accepting@Params(Ctx())purely to say one fixed thing.@Params(Ctx())is neither replaced nor deprecated: it remains the way to compute a status or header per request, and the only way to write a multi-valued header. The declared shaping is written to the response builder BEFORE the handler runs, so a returnedHandlerResult(ctx.response.status(202).json(...)) still wins and@Rendercomposes with a declared status unchanged.@Redirectdoes NOT short-circuit — a decorator cannot decline to call the method, so the handler still runs and a plain return is still serialised alongsideLocation.Every argument is refused at
register()rather than per request, naming the controller, the method and the value: a status outside[200, 599](or[300, 399]for@Redirect), a header pair the runtime rejects, the same header name twice,Locationalongside@Redirect, and one handler carrying both@HttpCodeand@Redirect. The alternative in each case is a failure nothing can answer — aRangeErrorthrown inside the HTTP adapter after the middleware pipeline has finished, or aTypeErrorwhile response headers are written, answering500on every request to that route.@setu-ts/commongainsRESPONSE_METADATA,RouteResponseMetadata,withResponseMetadataandresponseMetadataOf— aSymbol.for-keyed brand on the ROUTE HANDLER, theSECURITY_METADATA/VALIDATION_METADATAprecedent, carried on the handler rather than a middleware because a success status is a property of the handler and a decorated route may carry no middleware at all.@setu-ts/openapi-plugingainsderiveResponseStatus(defaulttrue), which reads that brand and documents the operation under the declared status instead of the assumed200; a declaredschema.responsestill wins. Defaulting it on is safe because the brand is new surface: an application that changes nothing gets a byte-identical document, asserted rather than assumed.@ResponseHeaderis deliberately NOT derived — an OpenAPI response-header entry needs a schema and a description the declaration does not carry. Adopting@HttpCodemoves a regenerated client’s success type for that operation from200to the declared status; seedocs/upgrading.md.Also: the OpenAPI status-description table gains the five redirect statuses (
301,302,303,307,308), which previously fell through to the bare fallback'Response'. -
deno task check:docsnow refuses a markdown table row whose code spans will not render as written. Two rules, the same defect at two stages of its life, both caused by a|the author did not escape — a pipe ends a table cell even inside backticks, and GFM then DROPS the cells past the header’s width.unescaped-pipecatches a span still holding a raw pipe, i.e. at the moment it is written;unterminated-spancatches a span left open, which is what the damage looks like afterwards, once the dropped cells have taken the closing backtick with them. Both real instances were found by a reviewer and a hand-written sweep rather than by any gate:PUBLIC_API.md’sRenderDecoratorrow had been rendering with its entire return union missing since M92, andpackages/static-plugin/README.mdwrote a default value with its backticks transposed. The checker walks spans by matching the opening backtick run’s length rather than counting backticks, because counting is unsound — a double-backtick span wrapping a literal backtick holds an odd number of them and would be misreported. A row counts only when its block carries a delimiter row, without which theSseMessage.dataentry further down this file is a day-one false positive:deno fmtwrapped its inline union type mid-line, leaving a continuation line that begins with a pipe and is not a table. -
common, logger, telemetry, audit — one synchronous redaction seam for framework egress (M96).@setu-ts/commonnow exportscreateRedactionService,IRedactionService, policy and redactor vocabulary, an erase redactor, and a suffix-mask factory. The logger, telemetry, and audit plugins accept the same policy or service throughredaction; policies are compiled once, never mutate caller-owned records, and apply before records leave those components. Telemetry now omits query strings and fragments fromhttp.urlby default;queryParameters: 'redact'retains only policy-transformed query values, and fails closed to omission with a warning when no policy is supplied. Audit redactsbefore,after, andmetadatabefore deep-freezing stored entries. -
Public contribution intake is now maintainer-triaged and protected from untrusted automation. GitHub Discussions, focused issue forms, a contribution guide, code of conduct, security policy, and pull-request template direct beta feedback, questions, compatibility reports, and proposed work into an explicit maintainership flow. Fork pull requests are proposals only: GitHub requires maintainer approval before any outside contributor workflow begins, and an approved fork does not run the repository’s validation jobs, services, website build, or automatic CodeRabbit review; a minimal no-checkout policy gate prevents skipped jobs from satisfying merge protection, and an accepted contribution is reproduced and validated in a trusted repository branch.
-
A documented example’s BEHAVIOUR is now checked, not only its types. The fence compilers prove an example type-checks; the
v0.6.0view defect type-checked perfectly and escaped nothing, so every gate was green while the README taught an XSS hole.scripts/check-example-behaviour.tscloses that class by RUNNING the documented components: every component a fence defines in a view-rendering document is rendered through the framework’s ownrenderComponentwith<script>alert(1)</script>substituted into its props, and the gate fails if the payload survives unescaped. It derives its work from the documents rather than from an author remembering to write an assertion, so a component documented tomorrow is covered the day it is written; it runs insidecheck:docs. A component whose own comment labels itUNSAFEorDO NOT USEis checked in the OTHER direction — it must still be unsafe — so a warning that stopped being true fails rather than quietly misinforming.raw()is honoured as the documented opt-out. The first cut of this gate passed against a reintroduction of the exactv0.6.0defect: it deduplicated component names per document, and a README routinely defines the same component twice — once functional, once class-based — so the class-based one, which is where the defect lived, was skipped. Scope is per fence, and the case is pinned. -
The behaviour gate substitutes a second payload —
javascript:alert(1)— into every prop and asserts on the RENDERED OUTPUT (M95d, X46-1). Escaping neutralises<script>alert(1)</script>because that payload is made of HTML metacharacters; a URL scheme needs none, so it survived both authoring arms verbatim and executed on click in real Chrome. The gate now fails when the scheme reaches a renderedhref,srcoraction— decided on the output, not the prop name, so it cannot drift as components change. A component whose own source routes a value throughraw()— or spreads{...props}into an element — is reporteduncheckedand FAILS: the probe stubs that path out, so it could not have delivered either payload, and a clean render there would mean nothing. The only way past is an explicit// UNCHECKED-EXEMPT: <reason>comment on the component; the three legitimate sites in the corpus are labelled in their documents, so a fourth arrival is a failing gate rather than a silent gap. -
@sincetags are now gated against the version that shipped the SYMBOL, at symbol level (scripts/check-since-tags.ts, composed intocheck:docsascheck:since-tags; M95d, X50-2). For every@since X.Y.Zthe gate resolves the declaration the tag sits on, fetches that file at that version from the jsr.io registry, and fails when the symbol is absent — a file-level check would pass any tag on a long-lived file, so the symbol is the unit. Scan roots derive from the rootdeno.jsonworkspace list, so the three starters are covered where apackages/*/srcglob would silently skip them. A version ahead of the registry is skipped, not failed, so a release branch cannot be blocked; a registry outage is reported on stderr and the run exits 0 withverified Nprinted — exit 77 would failcheck:docs’&&chain.Reported in code review before merge: the gate originally SKIPPED any version absent from the registry, under a message asserting it was “ahead of the registry” — two different claims. A version that was never released and never will be (
@since 0.6.1, on a line that went0.6.0→0.7.0) is absent forever, so it was skipped forever; four such tags were live onmainand the gate could not have reported one of them. It now skips only a version that is newer than everything published, or one whose release LINE did ship (@since 0.1.0, a line released only as0.1.0-alpha.*), and reports the rest. The test covering that branch used@since 9.0.0, which is genuinely ahead and therefore passes under either rule — it could not tell them apart, which is why the hole shipped.Two more from automated review, both fail-open. Only
404now means “absent”: the gate read ANY non-OK registry response as absence, so a429— realistic, since a run issues roughly 200 requests — raised afile-absentfinding and exited 1, failing the wholecheck:docschain on a transient fault, the opposite of the outage contract. And the behaviour gate matched itsUNCHECKED-EXEMPTmarker with a bare substring test, so the marker alone exempted a component, as did a comment merely mentioning it. The marker must now OPEN its comment line and carry a reason: requiring the documentedMARKER: <reason>form was the first fix and was NOT enough, since// Never write UNCHECKED-EXEMPT: without reviewcarries both the colon and a reason and is still a mention — a second review pass caught that on the fix itself. It matters because the exemption is the only way past an unchecked verdict.
Changed
-
deno.lockre-resolved to the current satisfying versions of every declared range. No manifest range changes, so this moves only what the lockfile pins:@hono/hono4.13.0→4.13.8, the seven AWS SDK v3 clients3.1103.0/3.1121.0→3.1134.0,zod4.5.4→4.6.5,react-router8.3.0→8.4.0,@bufbuild/protobuf2.13.0→2.15.0,@connectrpc/connect2.1.2→2.2.0, the three OpenTelemetry packages2.9.0/2.10.0→2.11.0,@launchdarkly/node-server-sdk9.13.0→9.13.6,@google-cloud/pubsub6.0.0→6.1.0,@google-cloud/storage7.21.0→7.22.0,@azure/identity4.13.1→4.13.3,@azure/cosmos4.10.0→4.10.1, andws8.21.2→8.21.3. Five stale entries naming ranges no manifest or source declares any more are dropped with them. The declared dependency-compatibility ranges are unaffected — zod stays inside>=4.4.0 <5, and the Drizzle (0.45.2) and Prisma v7 baselines do not move.deno task audit:cireports the same 0 low, 4 moderate, 0 high, 0 critical baseline before and after, so no advisory drove this. -
BREAKING —
logger-pluginnow redacts common secret-shaped fields by default. SetLoggerPlugin({ redact: [] })to restore the prior no-default-redaction behaviour. The legacyredactlist remains supported for both console and Pino transports and takes precedence over a policy where both name a path. -
Multipart
Content-Dispositionparsing admits the unquoted parameter form and drops a part with no usable name (behaviour changes, both with migration notes). Two changes decided together (M95c §3.4) because either alone is a regression. (a) The parser now accepts the unquoted token form fornameandfilename— value to the next;or end of header, then trimmed, the parameter NAME matched case-insensitively — where the previous quoted-only, case-sensitive regexes silently LOST the name of a part sent asname=x(the platform delivers it) and demoted an upload sent with an unquotedfilename=a.txtto a plain text field, sogetUploadedFile()found nothing. (b) A part whose Content-Disposition carries nonameparameter at all — including one whose header cannot parse — is DROPPED, matching the platform, where the previous code promoted it to a real field literally namedunknownthat collided with a legitimate field of that name; the sentinel is removed outright, so a nameless part and a field namedunknownare distinguishable again. An EMPTY value in either spelling —name=""orname=— is a defined name and is KEPT, as an empty-named field / empty filename: the web standard’s file-versus-text discriminator is the PRESENCE offilename, not its truthiness. Migration: an application reading a form field namedunknownwas reading parts no correct client sends (the platform discards them); read the part’s real name now, or accept that malformed parts are dropped. An unquotedNAME=xuppercase part is now delivered where Deno’s ownResponse.formData()drops it — the runtimes disagree on this row, and delivering is the side that loses no data; the full normative table is pinned bypackages/common/test/unit/form/multipart-platform-parity.test.ts. -
csrfTokenField(ctx)now renders the plugin’s configuredcsrf.fieldNameby default, not always'_csrf'. ThecsrfFormMiddlewarepublishes its resolved configuration intoctx.stateunder the new exportedCSRF_CONFIG_STATE_KEY— BEFORE itsignoreMethodsshort-circuit, since the GET that renders the form is itself an ignored method — and the helper reads it, so the README’s own recipe (render with the helper bare, post the form) no longer 403s on every post whenfieldNameis customized. An explicitcsrfTokenField(ctx, { fieldName })argument is an override; with no published config and no argument — a request the middleware never saw, such as a React Router action — the shared'_csrf'default is unchanged. What is published is a frozen, request-localPublishedCsrfConfig({ fieldName }), never the middleware’s ownResolvedCsrfConfig: that object is resolved once at registration and shared by every request, so publishing it put the verifier’s configuration in reach of any handler holding the context — measured, onectx.state.get(CSRF_CONFIG_STATE_KEY).ignoreMethods.add('POST')turned a403into a200for every LATER request in the process, each with a fresh state map. Migration: none needed; if you passed the configured name manually to work around the mismatch, the workaround is now optional and may be deleted. -
BREAKING (for JavaScript callers and
unknown-typed call sites) —inject()carries the body shapes a request actually has and refuses every other by name.InjectRequest.bodywidens from an anything-goesunknown(documented “will be stringified if not a string”, implemented asJSON.stringifyof whatever arrived) tostring | Uint8Array | ArrayBuffer | Blob | URLSearchParams | Record<string, unknown>: the byte-ish shapes pass through verbatim with NO content-type default (only the caller knows whether bytes are multipart, JSON, or an image), aURLSearchParamsis serialised with its owntoString()and defaultsapplication/x-www-form-urlencoded, and a plain object (JSON) and a bare string keep theapplication/jsondefault. A byte body is COPIED rather than aliased, so a handler that mutates whatctx.request.bytes()returned cannot corrupt theUint8Array/ArrayBufferthe test passed in, and a fixture reused across two injected requests carries none of the first request’s mutation. Previously aUint8Arrayarrived as{"0":97,…}, anArrayBuffer/Blob/URLSearchParamseach arrived as the two bytes{}, and the same release namedinject()a producer ofIRequest.formData?()— so an injected multipart upload parsed as an empty form. Migration: TypeScript callers are compile-checked. A JavaScript caller passing an array,Date, class instance or number must convert first: arrays and plain data to a plain object,Dateto a string or number, binary data toUint8Array. Per-shape bytes and content-type defaults are pinned bypackages/kernel/test/unit/inject-body-shapes.test.ts. -
BREAKING (for out-of-repo adapter implementors) —
@setu-ts/common+@setu-ts/database-plugin:IDatabaseAdaptergains the optionalisHealthy?(): Promise<boolean>liveness member, and thedatabaseindicator stops reportingupfor a backend it cannot vouch for. X51-1 (High): the indicator read the lifecycleadapter.isReady(), so a stopped MongoDB reportedup,/readyanswered200, and a rolling deploy rolled forward over a pod answering500to every request that touches data. The payload now carriesreachablewhen the adapter probes:true/up,false/down, and a probe that exists but does not answer inside the 2-second bound reports'unknown'/degraded— neverup. An adapter with NO probe is the deliberate no-change row:reachableomitted,up,/ready200 — mapping a missing probe todegradedwould fail/readyfor every healthy Cosmos/Bigtable/DynamoDB application on upgrade. The lifecycle read still gates the probe, so an adapter whose ownisHealthy?()does not re-deriveisReady()cannot reportupfor a connection it has already dropped;MemoryAdapterreports its own lifecycle rather than an unconditionaltruefor the same reason. Shipped probes: MongoDB (db.command({ ping: 1 })via the new optionalIMongoDatabase.command?facade member), Prisma and Drizzle (SELECT 1; Drizzle omits it for instances withoutexecute()), memory (true).DatabaseServiceexposeshasReachabilityProbeand a boundedreachability()(theIMessageBroker.reachability()twin);isHealthy()keeps its published lifecycle signature. Migration: none for callers; a hand-written adapter that can probe should declareisHealthy?(), one that cannot should omit it. Verified against a real MongoDB container through adocker stop/startoutage gate. -
@setu-ts/messaging-plugin: broker reachability reads the plane the application uses, and every arm’s probe is bounded. The Service Bus indicator saidupwhile every publish threw: the management probe resolvesunknownagainst an unreachable management plane in both states, andunknownmaps toup. The broker now records the outcome of real publishes — the DATA plane — in an evidence window thatreachability()consults first, narrowed twice (onlytransport.sendrejections count, and only status-less ones: a deleted topic or quota error is an application fact, never an outage);ServiceBusOptions.dataPlaneEvidenceMs(default5000) bounds the window, and is refused at construction if it is not a positive integer —NaN(whatNumber(env.X)yields for an unset variable) would otherwise freeze the window so recorded evidence never ages out, and0would disable it, reinstating the defect. The RabbitMQ probe is a real round trip (a throwaway channel open/close) replacing the fault-flag read a HUNG broker never trips, cached 5 seconds and bounded 2 seconds inside the broker so every caller shares one round trip per TTL —realtime-backplane-plugin’s'messaging'transport callsbroker.isHealthy()directly and keeps no cache of its own, so an uncached probe there meant one AMQP channel open per health poll and a paused broker left that indicator to hitHealthPlugin’s deadline and reportdown.RabbitMqBroker.reachability()now resolvesboolean | undefined(the committedIMessageBrokertri-state) and itsisHealthy()reports “not known down” for an unanswerable probe, matchingServiceBusBrokerexactly. The indicator bounds ALL seven arms’ probes (5-second TTL, 2-second bound): a probe that cannot answer now settlesreachable: 'unknown'instead of holding/healthopen forever. Residual posture, documented rather than implied: an idle Service Bus deployment with an unreachable management plane staysreachable: 'unknown'until its first publish. Verified against the real Service Bus emulator (docker stop/start2×2 gate) and real RabbitMQ 4 (docker pausehung arm). -
The Setu-TS website now identifies each page to social and search crawlers. The shared layout publishes the page’s canonical URL as
og:urland SoftwareApplication JSON-LD describing the framework, its supported runtimes, and its free offering. -
The root README no longer foregrounds the obsolete Hono Enterprise migration notice. The historical release record remains in this changelog for users maintaining legacy installations.
Fixed
-
The example-behaviour gate judges escaping on the value type production renders, and looks for a
javascript:scheme in an ATTRIBUTE rather than anywhere in the output (#333). Two fail-safe false positives, both properties of how the probe delivered its payload rather than of any documented component.hono escapes an attribute value only when it is
typeof === 'string', and the probe delivered aProxy— so a SAFE component rendering a prop intotitle=came back unescaped and was reported as a defect, while a real string is escaped correctly. The probe now sweeps several delivery modes, each putting a real string where the component finally reads, and a mode that does not fit a component simply contributes nothing instead of failing it. What makes the sweep sound is that a non-leaf level stringifies to a SENTINEL, never to the payload: a mode that puts an OBJECT where the component reads therefore cannot look like an unescaped payload. Disabling that one line reports two of the corpus’s own safe components as UNESCAPED.Separately, the scheme check ran one regex over the whole render, so body text reading
href=javascript:alert(1)matched although no attribute existed — a false positive on a document merely DESCRIBING the hazard, whichdocs/mvc.mddoes. It now walks tag regions quote-aware and inspects onlyhref,srcandactionvalues, matching an attribute name by suffix soxlink:hrefis covered. The probe imports that scanner rather than carrying a second copy, so it and the gate’s own tests cannot disagree.The sweep would have opened a false PASS on its own — a component reading past every mode has each payload resolve to
undefined, renders nothing, and scores as escaped — so a REACH probe decides whether the component touches its props at all, and the two disagreeing is reported as UNCHECKED. That probe is a SEPARATE object which deliberately does not carry the sentinel: every level stringifies to the payload, which is what lets it answer a read at any depth, and is only safe because its output is never judged for escaping. Sharing one object between the two roles made the guard inert for exactly the components it exists to catch, which a negative control found.Reach also improved: a nested
props.note.linkin anhrefis now caught, where before it was not delivered at all.One consequence of the sweep needed guarding, found in review:
escapedis now!raw, so a component NO mode delivered to scores as escaped because nothing appeared in its output at all. A counter-example — a component its own comment labels UNSAFE, checked in the other direction — was therefore told it “ESCAPES its input” and invited to drop a warning that is still true, which is the worst advice this gate can give. That verdict now requires delivery; when nothing was delivered the UNCHECKED finding is the honest report, and it has already fired. -
The weekly
Dependency driftworkflow reported atestfailure that was never drift, and gated a graph it had not resolved. Three defects, one shape — the job’s report did not describe what the job had done. Found while acting on the drift issue it filed on 2026-09-14.It started none of the twelve backends ci.yml’s
denojob starts, while running that job’s full suite. GitHub setsCI=truein every workflow, sotest/apps-gate.test.ts’s REDIS_URL reachability assertion — the M53 pin that stops a dropped container becoming a silent skip — failed on every scheduled run. That single assertion was the ENTIREtest: failurereported on 2026-09-07 and again on 2026-09-14; nothing about the fresh dependency graph was wrong. The invisible half cost more: six guarded suites (70 steps) skipped there, and those are exactly the suites a driftedioredis,amqplib,kafkajs,mongodbor AWS SDK would break, so the one job whose purpose is noticing that a new dependency broke something could not see it. The job now declares the sameenv,servicesand backend startup steps, derived from ci.yml by the parity test intest/unit/release-notes.test.tsso a backend added to the PR job is required here too. The assertion was right and the workflow was wrong: it is unchanged.Its source listing matched
.tsonly, whilepackages/carries three.tsxfiles — one of which reaches@hono/hono/jsxthrough its manifest’sjsxImportSource. So the lock thecheckandtestgates were handed was resolved from a smaller graph than those gates then walk.test/dependency-drift-gate.test.tsnow derives the listing from the tracked tree rather than pinning it as a literal, because the literal was the defect.And resolving from sources alone produced a lockfile
deno cirefuses to install — measured: it demanded@std/async,@std/data-structuresand@std/fs, which no.tsfile imports by name — so the “fresh resolution” every gate ran against was one that could never be committed. The same gap mis-attributed drift:@prisma/clientis declared inpackages/database-plugin/deno.jsonand imported by no source, so dropping it tookdrizzle-orm’s optional peer from7.10.0down to7.8.0, reported for weeks as two rows of upstream drift that no upstream release had caused. The job now resolves the workspace manifests before the source graph, and reports a manifest failure separately — the per-file retry below it exists to absorb a transient batch failure, and an unresolvable manifest range would have sailed through it with every individual file caching fine. -
Documentation — the last three sites asserting the false Deno compiler-option mechanism.
docs/decorators.md, thePUBLIC_API.mddecorator note, and apackages/clitest comment still stated that declaring anycompilerOptionskey in adeno.jsonreplaces Deno’s default set. M90h corrected six sites and these three survived the sweep. The mechanism is false, re-measured here on Deno 2.9.6 rather than taken from the earlier record: a manifest declaring onlyexperimentalDecoratorsstill reportsTS7006for an implicitany, sostrictremains in force, while the control declaringstrict: falsebeside it type-checks cleanly — which is what proves the probe discriminates rather than always failing. All three now state what is true: the decorator surface needs no compiler option, and declaring one leaves Deno’s other defaults alone, so an existingcompilerOptionsblock needs no edit. The stated reason matters on its own, because a reader who believed the old one would avoid declaring an option their project needs. -
@setu-ts/decorator-plugin: the unresolvable-parameter warning no longer tells a developer to write source that does not compile. Since M76 retired parameter decorators,Ctx()returns aParamSourceand@Ctx()in method position fails withTS1241 Unable to resolve signature of method decorator; the valid form is@Params(Ctx()). The hint logged for a decorated parameter that no resolver can satisfy named the invalid spelling, and so did four JSDoc blocks acrossdecorators/security.ts,resolvers/parameter-resolver.tsand three test-prose sites. All eight are corrected. The twonot.toContain('@Ctx() ctx')assertions in the CLI schematic tests are deliberately left as they are: that string is the legacy parameter form they exist to guard against reappearing. -
@setu-ts/static-plugin: thecacheControlcallback’s documented input matches what it receives, and the published option says so. The callback has always been handed the FULL leading-slash request path INCLUDINGurlPrefix(/assets/app-A9acsx54.js), deliberately — a cache policy is about the URL the client caches under. Three doc sites described it as the “root-relative path” and named the parameterrelativePath, and the README’s own worked example branched onpath === '/', which a directory request can never deliver because the index is resolved first. The behaviour is unchanged (stripping the prefix would silently change what every existing callback matches, AI_GUIDELINES §9.4); the parameter is renamedrequestPatheverywhere it is declared — including the barrel-exportedStaticPluginOptions, which is the spelling a consumer’s editor shows — the three doc sites are corrected, and the dead example branch is replaced. The exact string delivered for each mount shape is pinned bypackages/static-plugin/test/unit/cache-control-path.test.ts, including the one configuration that does deliver a bare'/'(arootpointing at a file rather than a directory, outside whatrootdocuments), so the guarantee reads as scoped rather than absolute. -
The Mongo injection seam admits the real
mongodbdriver. X47-1:PUBLIC_API.mdpresentedIMongoClientas the seam a realMongoClientis supplied through and claimed the driver implements its structural shapes — false, so the documented arm needed the cast this repository forbids internally. The facade’sconnect()widens toPromise<unknown>(the driver returnsPromise<this>; the adapter binds nothing), and the compile-time fixture the claim now cites —packages/database-plugin/test/types/mongo-seam.assert.ts, a static import of the real driver assigned toIMongoClientwith NO cast — caught two more divergences the single named fix missed:ClientSession.startTransaction(options?): void(the session member is(options?: unknown) => unknown) and theSortunion onfind/findOne(sort?: unknown). Source-compatible for existing structural implementations; the cast atmongo-client-seam.test.tsthat laundered the drift is gone with it. -
ConsoleLoggerno longer writes'[Redacted]'into the application’s own objects. A nested redact path corrupted the caller’s data:#redactFieldsshallow-clones the metadata record, sologger.info('login', { auth: user })underredact: ['auth.token']walked intouser— still the application’s own object — and assigned the placeholder to itstoken. The damage outlived the log call and was silent: a later unredacted log of the same object emitted'[Redacted]', and anything persisting it afterwards stored that literal string. Only paths of two or more segments were affected; a top-level path was already safe, because the shallow clone stood between the walk and the caller. The path is now walked READ-ONLY and nothing is copied until the leaf is known to exist; the traversed objects are then replaced top-down by copies the logger owns, so the caller’s metadata is untouched at any depth. Deferring the copy is a correctness requirement rather than an optimization — copying on the way down spreads every traversed value into a plain record, so aDatebeneath a path that matches nothing emits as{}and a class instance loses thetoJSONthat produced its output, changing a value the configuration never named. An entry whose configured paths match nothing is therefore byte-identical to the same entry logged with no redaction configured, asserted against that control. The one behaviour that does change is a path which ACTUALLY redacts through a non-plain object: the emitted copy is a plain object, so a class instance’s own enumerable properties are emitted rather than itstoJSONoutput. A copy is the only way to write the leaf without corrupting the caller, and the previous behaviour there was to assign onto the caller’s own instance. The existing nested-path tests could not have caught this — each built its metadata inline, so nothing held a reference to read back; the regression cases keep the object a caller would have kept, and four of the five fail without the fix. A path through an ARRAY still redacts nothing and that is unchanged and now documented rather than silent: pino resolves an array element with bracket notation ('users[*].token') and would equally not match the dotted form, so descending would make one option behave differently per transport. Unifying the two syntaxes belongs to the redaction seam milestone, not to this fix. -
ConsoleLoggerno longer throws out of a log call.LogMetadataisReadonly<Record<string, unknown>>and carries no JSON constraint, so every valueJSON.stringifyrefuses is legal metadata by the contract —common’sJsonValuedocuments that limit for SSE precisely because it chose a narrow type, and the logger did not. Measured, four inputs took the caller down on both the JSON and the pretty path: a circular structure, abigint, a throwingtoJSON, and a throwing getter. Pino survives all four, so the default logger was also the only one that could not. That is worst where logging is most needed — acatchblock — because theTypeErrorreplaces the error being reported, and the handler that would have recovered never runs. An internalsafeStringifynow neutralizes cycles ('[Circular]', the token pino emits, so the two transports describe one shape identically) and renders abigintas its decimal string; metadata that throws while being read cannot be handled per value, since the throw happens insideJSON.stringify’s own property read, so the entry is emitted without its metadata and carrying'[unserializable metadata]'rather than being lost — the level, time and message are usually the half an operator wants and are always serializable. A log call therefore emits exactly one line and never throws. Cycle detection tracks the ancestor chain, not every object already seen, and that is the correctness of the fix rather than a refinement: aWeakSetof seen objects reports the second appearance of a legitimately shared object as circular, so{ x: user, y: user }would serialize as{"x":{…},"y":"[Circular]"}and silently drop a field the caller supplied — pino renders both, and swapping the algorithm fails exactly those guards. Thebigintrendering is quoted where pino emits unquoted digits: pino’s form is exact in the emitted bytes but loses precision the moment a consumer callsJSON.parse, and this is reachable in ordinary use, since drivers returnBigIntfor int8 columns. No public API change — the helper is internal, matchingnormalizeMetadatabeside it, andsrc/index.tsis untouched. -
A scaffolded workspace’s generated deployment now starts under its own security posture. A
setu new --workspacemicroservice, built with its own generateddocker/Dockerfileand deployed under its own generated Kubernetes manifest (readOnlyRootFilesystem: true), crash-looped before serving anything: the first timeMessagingPluginregistered, Deno tried to add its driver edges (npm:amqplib,npm:ioredis) todeno.lockand died on the read-only root —error: Failed writing lockfile … Read-only file system (os error 30). The generated DenoCMDnow runs with--no-lock: every package the graph reaches is already in the build-time module cache (the Dockerfile’sdeno cache main.tsstep), so the lockfile has no job left inside an image and the start resolves offline. No warm list is emitted — measured on a fresh scaffold, the build-time cache already holds every package the runtime reaches, so a derived specifier list would name packages that are already present. An existing workspace picks the fix up on its nextsetu generate app(the file is regenerated, no migration step).docs/deployment.mddocuments the finding, thedeno install --frozendiagnostic the runtime error should have given you, and the rule that the image’s module cache is the member’s only dependency source at runtime; andcheck:deploygains a--generatedmode that scaffolds a workspace, builds its generated Dockerfile — previously built by no gate — and passes only when the member serves/healthunder--read-only --network none. Measured to discriminate: without the flag the new mode reproduces the crash; with it the member serves. -
MVC form guidance now matches
IRequest.formData?(). The guide no longer says forms lack an accessor: all built-in request producers provide its memoized parser for URL-encoded and multipart bodies. Because customIRequestimplementations may omit the optional member, the copyablereadFormhelper now falls back to the sameparseFormBody(await request.bytes(), request.headers.get('content-type'))path rather than silently returning an empty form. The guide gate executes both fallback encodings and the accessor-present path. -
@setu-ts/view-plugin,@setu-ts/decorator-plugin— the documented class-based view example did not escape.packages/view-plugin/README.md(the page jsr.io renders) anddocs/migration-nestjs.mdboth wrote the component as a PLAIN template literal —(props) => `<ul>${props.users.map((u) => `<li>${u}</li>`).join('')}</ul>`— which is astring, so the engine returns it unchanged and nothing escapes it. Measured against the published0.6.0artifact with a hostile name: JSX and hono’shtmltag both emit<script>, while the plain literal emits<script>alert(1)</script>verbatim. The view-plugin README contradicted itself 58 lines apart, telling the reader to “write views with JSX or thehtmltag” beneath an example that did neither, and the two remaining sites (docs/mvc.md,packages/decorator-plugin/README.md) usedhtml— so no two of the four agreed. No package source changes; escaping always belonged to the rendering runtime and always worked. -
All four view examples are now JSX, which is what
ViewPlugin()selects with no options:'hono-jsx'is the default arm andViewEnginedoes not branch on the arm at all — one class serves both authoring modes — so every example was showing the NON-default mode and its extrahtmlimport for no reason. The'hono-html'arm keeps a worked example as the documented alternative for a project that would rather not add a JSX toolchain. -
The fence gate could not have caught it, and now can. Both fence compilers wrote every fence to
.ts, and Deno decides whether to PARSE JSX from the extension — so a real JSX fence dies withSyntaxError: Expected ',', got '.'before type-checking starts. It held only because everytsxfence in the corpus contained no JSX (anhtmltagged template parses as ordinary TypeScript). One sharedfenceExtension(lang)now decides, so the guide and package-README compilers cannot disagree. Verified to discriminate: breaking the props bag inside a JSX example fails the gate withTS1241, which is@Renderrejecting a handler whose return does not match its component. -
messaging-plugin— thev0.6.0release notes claimed a safety net that does not exist; both copies of the claim are corrected in place. The0.6.0section above (and the probe’s own JSDoc) said a namespace that is genuinely gone “still reportsdownregardless: the data client stops being ready, and the indicator checksisReady()before it consults the probe.” Measured against the emulatordocs/messaging-emulators.mddocuments:isReady()is LIFECYCLE state — set whenconnect()succeeds, cleared only bydisconnect()— with no liveness input, so a broker dead for minutes answersisReady() === truein 0 ms; nothing gated the probe, and a stopped namespace reportedupwith/readyanswering200while every publish threw. This milestone ships what actually catches a dead data plane — the broker records recent data-plane outcomes andreachability()consults that evidence before the management probe (M95b) — and the false sentence is struck from the published section and the source. -
Twelve
@sincetags named versions that do not ship what they claim; all corrected against the registry (M95d, X50-2 plus the gate’s first full run). Six inpackages/common/src/form/named0.5.0, a release whose tarball does not contain the module — the module is new in0.6.0, and the miss is PR #286’s named cause repeated: the sweep’s branch was cut before the module landed. The gate’s first full run then surfaced six more, each verified present at its corrected version before the edit:UnsupportedFormEncodingError(common,0.5.0→0.6.0),ProbeTimingandresolveProbeTiming(common,0.4.0→0.5.0),UnsupportedMigrationError(database-plugin,0.4.0→0.5.0),csrfTokenField(session-plugin,0.5.0→0.6.0), andcontentEncodingFor(static-plugin,0.4.0→0.5.0). The corrected run verifies 736 tags.Seven more were found by running the finished gate against merged
main— nineteen in all — and are corrected here: three@since 0.6.0tags onconfig-plugin’s typed-config-section module, a file0.6.0does not contain, and four naming0.6.1acrosskernelandsession-plugin— a version that was never released. All move to0.7.0. That is PR #286’s named systematic cause repeating, which is the argument the gate was built on. -
The documentation now states what the code does at the three places a reader following it ended up wrong (M95d, X46-1 / X49-1 / X49-2).
docs/mvc.md§Escaping states the boundary — escaping protects HTML structure, not URL schemes, a difference BETWEEN rendering runtimes (React neutralises the payload, hono’s runtime passes it through) — with the application-side remedy (validate the scheme before it reacheshref/src/action) and a cross-reference from the inline-<script>section noting that'unsafe-inline'is exactly the CSP that does not block ajavascript:URL; and a new §Error pages carries the one-sentence rule (respondcovers whaterrorHandlercatches; every responder terminal is outside it) beside the list of what that leaves serving raw Problem Details into a browser.packages/exceptions/README.mdgains the workedrespondexample whosectx.response.status(error.statusCode)line is the whole fix —renderViewtakes no status, so the natural spelling served200for every branded error —packages/view-plugin/README.mdstates the URL-scheme boundary where a reader of the package lands, andPUBLIC_API.md’srespondcontract note carries the one-line status fix.
0.6.0 — 2026-09-13
Added
-
@setu-ts/common— one form-body abstraction.IRequest.formData?()resolves aFormBodyfor bothapplication/x-www-form-urlencodedandmultipart/form-datarequests — one read-onlyget/getAll/entriesview with the webFormData’s SEMANTICS (orderedgetAllfor repeated names,entries()in wire order, file-versus-text discriminated onfilename !== undefined,filename=""still a file) over a framework-owned value whose bytes stay synchronous: the webFile’s async, copying access would add a second full copy per file part on the upload path this exists to make cheaper, so it is deliberately not adopted (and the shape is namedFormBody, neverFormData, so it cannot shadow the global). The parse isparseFormBody(body, contentType)— the ONE parse all three producers share, the X37-1 precedent — fed byformEncodingOf, the ONE case-folding content-type classifier that deletes the two privateincludes()checks the upload middleware and the CSRF verifier had let drift. A non-form content-type throws the newUnsupportedFormEncodingError, self-branded415 Unsupported Media Typein theMalformedRequestBodyErrorpattern; an unparseable multipart body yields an EMPTY form — the promoted parser’s released behaviour, now stated as the accessor’s documented limit. The member is OPTIONAL (signal?/fs?/raw?precedent) and provided by all three in-repo producers (runtime’sFrameworkRequest, the kernel’sinject(),@setu-ts/testing’sMockRequest), each memoizing the in-flight promise exactly asjson()does; a request without the accessor falls back to the sameparseFormBody, which is the fallback, not a second implementation. The storage-plugin’s private multipart parser moves byte-identically intocommon(parseMultipart/ParsedPartstay internal —parseFormBodyis the only public entry), and both first-party consumers now read the shared accessor:createUploadMiddlewarekeeps every bound and refusal where it was, andextractToken(see the session-plugin entry). -
@setu-ts/common— three multipart/form-parsing defects fixed while promoting the parser. All three were pre-existing in the@setu-ts/storage-pluginparser this release moves intocommon, and all three failed silently; promoting it is what made them reachable from the CSRF verifier and from everyIRequest.formData()caller, so they are fixed where the code now lives. (1) Boundary detection now requires the full delimiter context — a line break,--<boundary>, then a line break or the closing--, per RFC 2046 §5.1.1. Matching the raw byte sequence anywhere truncated any value containing it: the field valueprefix--AaB03xsuffixcame back aspref, silently corrupting a CSRF token or uploaded file rather than refusing it. (2) Part header field names are matched case-insensitively, as RFC 7578 defines them. The parser compared the exact stringsContent-DispositionandContent-Type, so a client sendingcontent-disposition— legal, and what several HTTP libraries emit — lost its field name, filename discriminator and MIME type: the part arrived under the nameunknown, so a CSRF token was never found and an upload was never delivered. (3)formEncodingOfmatches the media-type token exactly and readsboundaryas a real parameter, through a new sharedparseContentTypethatparseMultipartreads too — so the classifier can no longer promise a parse the parser refuses. Substring matching admitted three non-forms into form parsing:application/x-www-form-urlencoded-v2(a suffixed type),text/plain; note="application/x-www-form-urlencoded"(the type inside an unrelated quoted parameter), andmultipart/form-data; xboundary=q(boundary=matching a different parameter name). A quoted boundary containing;now parses correctly, ONLY a double quote delimits a quoted value (an apostrophe is an ordinary token character, soboundary='abc'delimits with--'abc'), and the CLOSING delimiter is validated to its end of line, so file data containing--<boundary>--no longer truncates the part. Each behaviour was checked against the platform’s ownResponse.formData(). -
@setu-ts/runtime—IRequest.formData()reads the request’s PUBLIC headers. It read the native request’s headers, so a middleware rewritingcontent-typewas ignored on the served path while the kernel’sinject()and@setu-ts/testing’sMockRequest— which read their public headers — honoured it. The three producers of one shared parse now agree. Framing headers (content-length,transfer-encoding) are still read natively, which is correct: framing is a property of the wire, not something a middleware restates. -
@setu-ts/session-plugin— CSRF tokens can arrive in amultipart/form-dataFIELD. The verifier previously read only the configured header for multipart posts, because parsing the body would have meant importing the storage plugin’s parser. With the parser promoted intocommon,extractTokenreads BOTH form encodings through the request’s sharedformDataaccessor (or the sameparseFormBodyfallback) after the configured header, so a browser form posting a file alongside its token verifies where it previously could only403. The header still wins and a client that sends it triggers no body parse; a token submitted as a multipart FILE part is REFUSED as absent (a client chooses freely whether a part carries afilename, so a non-string value must never reach the timing-safe comparison); a non-form body still reports the ordinary mismatch rather than the accessor’s415; and the urlencoded path is byte-identical to before. -
@setu-ts/session-plugin—csrfTokenField(ctx, options?)renders the session’s existing synchronizer token as a complete hidden form input, so server-rendered forms no longer hand-write its markup. It mints throughgetCsrfToken, escapes a configured field name, and keeps the existing default'_csrf'; in an escaping Hono template, application code wraps its trusted generated markup withraw(). The helper does not change CSRF policy:SessionPlugin({ csrf: {} })still globally verifies unsafe methods. (That entry originally added that a multipart form still had to carry its token in the configured header “until the separate M94b form-body work ships”; M94b ships in this same release, so a multipart FIELD now verifies — see its session-plugin entry above.) -
@setu-ts/messaging-plugin— versioned integration-event contracts over the existing messaging capability.defineIntegrationEvent<T>({ type, version, topic, parse })declares a contract and refuses a topic that does not end with the exact.v${version}suffix, making the versioned-topic rollout policy mechanical (the rawbroker.publish/subscribesurface is unchanged and remains the route for a pre-existing unversioned topic).publishIntegrationEventwraps the caller’s payload in a portable envelope —id,type,version, ISO-8601occurredAt,data, optionalcorrelationId/causationId/aggregateId/aggregateVersion— as payload data, never transport headers, so every broker arm carries it with no adapter change; it never runsparseon publish, so a producer CAN publish a payload its own consumers reject.onIntegrationEventproduces the existingSubscriptionDefinition: it validates the envelope structurally (ignoring unknown extra fields), checkstype/versionexactly, runsparse, rebuilds the envelope with the parsed value soenvelope.data === payload, and only then calls the application handler; a refusal throwsIntegrationEventRejectedErrordiscriminated byreason(malformed/type-mismatch/version-mismatch/parse, the parser’s own error carried ascause).causedBy(envelope)extracts the chain-root rule for correlation propagation. A payloadless event publishesnull, neverundefined, which JSON drops (refused at the producer, naming the remedy); a non-finiteaggregateVersionis refused for the same reason; and the consumer type-checks every optional causal field and requiresoccurredAtto be a real ISO-8601 instant, so the envelope’s declared types hold for application code and forcausedBy. Separately,NatsBrokernow reports a rejected handler beforenak()— it redelivers, so that branch was the one retry loop in the plugin with no diagnostic at all. NoIMessageBrokermethod changes, no new capability token, and no dependency on@setu-ts/events-plugin; ingress behaviours observe the raw envelope, before the wrapper, by design. -
@setu-ts/exceptions— application-owned caught-error responses.errorHandler({ respond(error, ctx) { ... } })can now return an application response — including an HTML view throughctx.response— after the framework has normalized, disclosure-masked, and status-sanitized the caught error. The hook may be asynchronous; returning or resolving toundefinedpreserves the existing formatter-led JSON or Problem Details output exactly. Logging retains the unmasked diagnostic and records the selected response’s status. The hook deliberately does not cover M70f responder terminals such as unmatched-path404, malformed-request400, or drain503. -
@setu-ts/events-plugin— aggregate-localcreateDomainEvents()andIDomainEvents. A framework-independent recorder for facts raised by an aggregate during an operation. It preserves insertion order, returns isolated snapshots, and never publishes; application code owns persistence, dispatch, and acknowledgement policy. -
@setu-ts/view-plugin(new package) — server-rendered HTML as a capability. Registers anIViewEngineunder the newCAPABILITIES.VIEWtoken; the view is named BY REFERENCE (@Render(UserList), never'users/index'), so there is no view resolver and no filesystem lookup, making the capability Workers-portable by construction. Two zero-new-dependency arms ('hono-jsx'default,'hono-html'tagged template) plus a'custom'arm for any engine that adapts its templates toComponent<P>functions. Two entry points, one implementation:@Render(Component)(and its exportedRenderDecoratortype) in@setu-ts/decorator-plugin(type-checks the handler’s return against the component’s props;CAPABILITIES.VIEWjoins itsoptionalDependencies; a rendered route with no provider fails atregister()naming both remedies) and the freerenderView(ctx, component, props). Rendered output is a buffered primitive string; a tree holding a pending<Suspense>boundary is refused withUnresolvedSuspenseErrorrather than served as its fallback forever (streaming resolution deferred to a follow-up milestone). Escaping stays in the rendering runtime;raw()is re-exported as the documented opt-out. A@Renderhandler may return aHandlerResultfromctx.responseinstead of the props bag — a redirect, most usefully — so POST-redirect-GET is expressible on a rendered route;HandlerResultis branded, so a props bag of the wrong shape is still a compile error. A top-level return follows the rendering runtime’s own rules —null,false,trueand''render as the empty string so(p) => p.show && <Banner />behaves as it does nested, whileundefinedis refused as a probable missingreturn. AddsCAPABILITIES.VIEW,IViewEngineandComponent<P>to@setu-ts/common, adocs/mvc.mdguide, a Views section todocs/migration-nestjs.md, and the 48th member to the release list (first publish needsrelease:create-packages+release:link-repos; seedocs/releasing.md). -
@setu-ts/testing—createTestAppgains a composition-root arm: passapp(an already-constructed, not-yet-started application — a scaffolded project’screateApp()fromsetu.config.ts, or a starter factory’s return value) instead ofplugins, with optionalwithoutandoverrides, and the test starts from the application the project actually ships. Everything that root registered is present — its middleware, error handling, health indicators and route ordering, except whatwithoutexplicitly removes — so a test observes production’s composition rather than a second one assembled by hand.TestAppOptionsis now a union of the two exported arms,TestAppFromPluginsandTestAppFromApp, so supplying bothpluginsandappis a compile error; thepluginsarm is otherwise unchanged. Closes the@setu-ts/testingwatch-item X11 left open (M91). -
@setu-ts/testing—overrideCapability(token, service)builds the replacement plugin AI_GUIDELINES §3.4 describes, applying the three constraints that make one work: it declares noprovides(a second declaration of a live token is refused before any plugin runs), registers with{ override: true }, and orders itself through anoptionalDependenciesedge on the token plusPLUGIN_PRIORITY.HIGHEST— after the provider, whatever its band, and before an ordinary consumer. It throws duringstart()when nothing provides the token — without that, a mistyped token registers the double under a nonsense name, leaves the real service serving, and the test passes against the real dependency. It likewise refuses a multi-provider capability, wheregetAllreturns the single and multi registrations concatenated so an override would ADD a provider while every real one kept running — detected generically by a provider count rather than a list of known tokens, so a capability an application registers itself with{ multi: true }is refused too. The override is ordered AFTER the provider and BEFORE ordinary consumers — anoptionalDependenciesedge on the token plus an early priority — so it reaches a consumer that resolves the capability during its ownregister()(NotificationPlugindoes) as well as one that resolves it per request, and it replaces a provider in any priority band. It does not undo the provider’s eager side effects, and it requires the provider to declare the token inprovides; both bounds and thewithout-plus-createMockPluginalternative are stated in the README andPUBLIC_API.md.createMockPluginis unchanged and still the right tool for providing a capability an application lacks; it cannot replace one, because itsprovidesdeclaration collides with the real plugin’s (M91). -
common—createCachedProbeis generic over its outcome type, with afallbackrecorded on timeout or rejection (defaultfalse, so every existing caller is unchanged). A probe that reads a proxy for the thing it reports on — Service Bus’s administration endpoint standing in for its data plane — can now say “could not determine” instead of “down”.fallbackstays optional for abooleanprobe and is required once the outcome type is wider, sincefalseis the only value the helper can name on its own: omitting it for, say, a'up' | 'down'probe would have resolvedfalseunder a signature promising it could not. -
common—causeMessage(value)is published: anError’s own message, orString(value)otherwise. It was a private helper with a byte-identical copy in@setu-ts/scheduler-plugin, because the two arms of onejobsoption have to produce the same sentence and §2.2 forbids the import that would let one read the other’s. One implementation now, reachable by both. -
common—IMailer.isHealthy?()reports the mail transport’s reachability:true,false, orundefinedwhen the question cannot be asked. OPTIONAL, so no implementor breaks.MailServiceimplements it by delegating to the configured provider’s own probe. It exists on the capability because a HOLDER of anIMailercould not previously ask at all — the probe lived onmail-plugin’s internal provider port, and AI_GUIDELINES §2.2 forbids another plugin importing that package to reach it. The probe is cached and bounded at that capability boundary rather than in each caller: one aggregate health check asks the question once from themailindicator and once per configurednotification-pluginemail alias, so a cache per caller would still be one transport call per caller.MailServiceOptions.probeTimingcarries the runtime’s clock and timers; without it the delegation is bare, since there is no clock this package may lawfully read. -
audit-plugin,notification-plugin— optionalisHealthy?()on theIAuditStorageandNotificationChannelports, so a third-party backend or channel can report reachability. -
common— optionalIGrpcService.refuses?(request), answered from the request HEADERS and consulted by the kernel BEFORE it reads the body. Optional, so an implementor that omits it keeps the previous behaviour exactly.
Changed
@setu-ts/exceptions: a415is served with its canonical title.STATUS_TITLESgained a415: 'Unsupported Media Type'row. It had none, because415is the first status the framework brands withwithHttpStatusHintand never produces through a factory (M94b’s form accessor), and the Problem Details formatters derivetitlefromstatusTitle(status)— so a client reading a415undererrorHandler({ format: 'rfc9457' })got"title": "Error"while the SAME error under'default'read"message": "Unsupported Media Type": the two configured formats disagreeing about one error. Every test asserted the title on the brand (the error object) and none read the served body, which is why nothing saw it;status-title-served.test.tsnow drives each branded status througherrorHandlerin both formats, and the rootstatus-title-coverage.test.tsreads the branded statuses out of first-party source so a package branding a NEW status fails the gate instead of serving a meaningless title. An application that produced its own415— bynew HttpError(415, …)or its own hint — also stops reading'Error'under the Problem Details formats.- BREAKING —
@setu-ts/storage-plugin: a multipart part with nofilenameunder the upload field name is no longer an upload. It used to be reported as anUploadedFilewhosefilenamefell back to the field name; since the upload middleware reads forms through the new shared accessor (M94b), a no-filenamepart is a plain form value — the web standard’s answer — and only parts that declared afilenameare delivered,filename=""(the empty file input) included. Migration: a browser file input always sends afilename, even an empty one, so ordinary uploads are unaffected; a non-browser client posting a plain value under the file field should send that value under a non-upload field name (read it fromformData()), or add afilenameto the part if it genuinely is a file. Relatedly, amultipart/form-datacontent-type carrying noboundary=now passes through the middleware unparsed instead of being answered400as a malformed body: the shared classifier classifies it as not-a-form (a body that could never be parsed), and a caller that wants the fields gets the accessor’s415. Everything else is unchanged: theContent-Lengthcheck, thebytes()read, themaxBodyBytescap,maxFiles,maxSizeandallowedMimeTypesrefusals and their statuses all stay where they were. The promoted parser (internal —parseMultipartstays unexported) now also matches theboundaryPARAMETER NAME case-insensitively, as RFC 9110 parameter names are: aMultipart/Form-Data; Boundary=…content-type parses where it previously bypassed the middleware or answered400, disclosed here because the move that promoted the parser otherwise froze its behavior. - BREAKING —
@setu-ts/kernel:IKernelApplicationgains a requiredunregister(name: string): boolean. It removes a pending plugin beforestart(), returningtruewhen at least one carried that name and throwing once the application has started. It removes every pending plugin with the name, not the first: two may share one, since the kernel refuses duplicates atstart()rather than atregister(), so dropping only the first would leave the other running while the caller was told the name was gone — and would turn that loud startup failure into a silently-running plugin. Plugins do not run untilstart(), so removing one means itsregister()— and any eager side effect inside it, such asDatabasePlugin’sadapter.connect()— never happens at all. That is the one thing overriding a capability cannot do: an override replaces what consumers resolve, after the real plugin has already run. Migration: callers are unaffected, andcreateApplicationalready returns an implementation. Only a hand-written stand-in forIKernelApplicationbreaks; give itunregister(name) { return false; }if it holds no plugins of its own, or splice its own pending list. Removing a plugin another declares independenciesis not refused byunregister—IKernelApplicationalso gains a requiredhasPlugin(name): boolean— a pure read used to validate a whole exclusion set before removing any of it, so a misspelled entry leaves the application untouched rather than partially excluded.start()reports it, naming the dependent plugin and the unsatisfied capability — not the removed plugin’s own name when the two differ (M91).
Fixed
-
messaging-plugin— the Service Bus health probe reported a live broker asdown. It reads the namespace’s administration endpoint to report on its data plane, and every failure — including its own two-second bound — resolvedfalse, so/healthsaiddownand/readyanswered503for a broker that was publishing successfully. Measured against the emulatordocs/messaging-emulators.mddocuments, whose administration endpoint has no TLS listener. Reachability is now tri-state, and the question each outcome answers is does this response establish a fact about the data plane. A404/410does — the namespace is not there — and still reportsdown. A401/403proves the namespace answered, so it still counts as reachable. Everything else reports on the management request rather than the namespace and now leaves the replica in rotation withreachable: 'unknown': a network-layer failure with no status at all, and a management-plane status that establishes nothing — a429, which Azure documents as temporary throttling or a conflicting management operation, or a5xx. Those last two previously answereddown, which is the same defect as the network-failure case reached through a status code instead of a socket error. (This entry originally continued: “A namespace that is genuinely gone still reportsdownregardless: the data client stops being ready, and the indicator checksisReady()before it consults the probe.” That sentence was measurably false and is corrected in place:isReady()is LIFECYCLE state — set whenconnect()succeeds, cleared only bydisconnect()— with no liveness input, so it answerstruein 0 ms for a broker dead for minutes. A genuinely-gone namespace is caught by the data-plane evidence window (M95b), not byisReady().)IServiceBusTransport.isHealthy?widens toPromise<boolean | undefined>; an implementation resolving a plainbooleansatisfies it unchanged. -
audit-plugin— theaudithealth indicator reportedstorage.isReady()alone, which is a hardcodedtrueon three of the four shipped backends, so the indicator was in practice a literalup: a database whose connection had gone and a file path whose volume had been unmounted both read healthy while every audited record was being lost (H-70c-1). It now reports reachability too — the database backend byselecting a sentinel key that matches no row (never aninsert, which would write a fabricated record into the trail), the file backend by the last append’s outcome and astatof the target directory — cached 5 s and bounded 2 s. This is a behaviour change: an application whose audit sink is unreachable now fails/readywhere it previously passed, which is the point. The payload gains{ storage, reachable }, wherereachableistrue,false, or'unknown'. -
audit-plugin—FileAuditStorage’s reachability probe reported a sink it cannot write to as healthy. It treated any successfulstat()of the configured file’s parent as the answer, so a regular file at that path —/var/auditwhen the sink is/var/audit/trail.log— passed whileensureDir()and every append under it failed. It now readsStatResult.isDirectory. -
notification-plugin— thenotificationhealth indicator hardcodedstatus: 'up'beside a live channel list, so a configured channel whose transport was down was invisible to/ready(H-70c-4). The payload now carries per-channelreachable, and one channel that was contacted and did not answer takes the indicatordown. The email channel delegates toIMailer.isHealthy; the send-only transports report'unknown', because a probe may not deliver a notification to a real person and none of them offers a side-effect-free alternative this plugin can reach —'unknown'never reads as healthy, where the hardcodedupdid. Channel probes run concurrently: each may consume its full bound, so awaiting them one after another let a handful of stalled channels exceed the health service’s own per-indicator deadline and replace every channel’s outcome with a generic timeout. This is a behaviour change for an application whose mail transport is unreachable. -
worker-pool-plugin— theworker-poolhealth indicator hardcodedstatus: 'up'beside a liveavailablefield that could readfalse, so a pool that could not execute a single task — everyrun()rejecting withWorkerPoolUnavailableError— reported healthy (H-70c-5). The status now derives fromavailable, reportingdegradedwith areasonwhen no worker host is present.degradedrather thandownbecause M45 registers this plugin on Cloudflare Workers deliberately;degradedkeeps/readyat 200 there while still surfacing in the payload. -
mail-plugin— themailhealth indicator read the provider’s probe directly, past the service. It now reads it throughMailService.isHealthy(), so the capability and the indicator answer through one implementation and cannot drift. No payload change. -
database-plugin— a{ operator: 'eq', value: null }filter matched nothing on the Drizzle adapter. It renderedcol = ?bound toNULL, and SQL evaluatesNULL = NULLto UNKNOWN, sofindAll,findOne,countandfindPagesilently returned no rows where the memory adapter, Prisma, MongoDB and D1 all return the rows whose column isNULL. The plain-column arm now emitsIS NULL, which the adapter’s ownin-with-null arm and its JSON-path arm were already doing. No API change; a query that returned nothing now returns the NULL rows, which is what every other adapter already returned. -
graphql-plugin,storage-plugin— a REFUSED request body was re-reported with a different, misleading status. Since M90a a body read can reject rather than return —RuntimePlugin({ maxBodyBytes })bounds it and rejects with a413-hintedRequestBodyTooLargeError— and three sites wrapped that read in atrywhosecatchanswered something else: the GraphQL HTTP and SSE transports answered400 INVALID_JSON, and the upload middleware answered400 "Failed to parse multipart body"(V5-1). Each named the wrong cause — the body was never parsed, so it was never invalid — and the wrong remedy, since a client told its payload is malformed re-sends the same oversized one. All three now read the thrower’s own status hint: the GraphQL transports answer413withextensions.codeREQUEST_BODY_TOO_LARGE, and the upload middleware serves the hint throughrespondWithError, so the refusal comes out in the application’s configured error format. A genuinely malformed body still answers400with its existing code — including the sharedMalformedRequestBodyError, which M90f made status-hinted at400, so honouring every hint blindly would have replaced the publishedINVALID_JSONcode on the commonest failure there is. -
cli— a generated CQRS or event handler could fail the project’s owndeno fmt --check, and whether it did depended on how long the artifact’s name happened to be. The three schematics hand-wrapped theirimplementsclause UNCONDITIONALLY, sosetu generate query-handler find-orderemitted a wrap the formatter immediately rejoined (92 characters, under the emittedlineWidth: 100) whileplace-orderat 103 was correct (V5-3) — M63’s D6 and M70h’s X2-4 in a third place. Every such line is now rendered from its own measured length by onerenderDeclarationHeader/renderMethodSignaturepair, so the output no longer depends on the name. Driving a REALdeno fmt --checkover the emitted files across five name lengths found three more overflows of the same class that the row did not name — theextends CqrsQuery<…>interface header, thehandle(…)signature, and a barrel registration entry — all fixed here, because a scaffold that fails its own format check for a long name is the same defect whichever line does it. Sweeping the name one character at a time against a real formatter then found two more class-header forms and a fourth line kind: the formatter has FIVE header forms, and the fourth returns theimplementskeyword to the declaration line while the type ARGUMENTS split — the opposite of what the first three suggest, and picked on whether the declaration, keyword and type head still fit rather than on the declaration’s own length. Theexport consta handler names itself after wraps too. Generated handlers are proven clean by that sweep for every artifact name up to 67 characters, which is a measured ceiling rather than a round number: at 68 an interface’s ownreadonly type: typeof …;property is the next line the formatter rewrites, and the test names where the bound is so raising it is a deliberate act. -
kernel,grpc-plugin— a native gRPC client hung instead of reaching the refusal. The kernel buffered the whole request body before dispatching, and a client-streaming or bidirectional call holds its request stream OPEN, so that read never resolved and the caller received no frames at all.grpcurlopens a bidirectional reflection stream before anything else, so it hung on every request — which is why M70i’s Trailers-OnlyUNIMPLEMENTEDrefusal and its root base path were both correct and neither helped (V5-5). A nativeapplication/grpcrequest is refused whatever its body contains, so the refusal is now decided from the headers and answered before the read. Probed at the HTTP/2 frame level: a dispatch-map path with the stream open produced no frames, the same path withEND_STREAMproduced the correct refusal, and a non-map path answered immediately — the last of those being the tell, sinceclaims()rejects it before the body read. Connect and gRPC-Web are untouched: the content-type set is an exact match, never a prefix, soapplication/grpc-web+protois not caught. Thegrpc-pluginREADME’s claim that the bidi hang was “the transport reason the bidi bullet above gives, not because of this refusal” is corrected — it was the buffering, and the README now says what the buffering still costs a client that holds its stream open past its last message. A DRAINING service still answers503rather than the header-only refusal: afterclose()the only paths it claims are the ones it served, precisely so their drain answer survives, andUNIMPLEMENTEDwould tell a client to stop asking where503tells it to retry. -
kernel— an RPC response’s repeated headers were collapsed when copied onto the framework response. The copy usedIResponse.header, which delegates toHeaders.set, so a service answering twoSet-Cookievalues — the one headerHeaders.entries()yields once per value instead of comma-joining — kept only the last. It appends now. -
scheduler-plugin— a declared job that could not be scheduled named neither the plugin nor the entry.SchedulerPlugin({ jobs: [...] })correctly refuses to boot on an invalid cron expression, but reported onlyInvalid cron expression: not a cron— so an application declaring several jobs was told an expression was bad and left to find which one, while the plugin held both the declared index and the job’s ownname(X23-1). Both arms ofjobsnow fail identically, through one labelled path:Failed to schedule SchedulerPlugin({ jobs })[1] (name 'bad-expr'): …, with the original refusal retained ascause. The index is the entry’s position in the DECLARED array, not among entries of its own kind, so a factory ahead of the failing entry cannot make it name a working one. This is the convention M86 already stated for a throwing factory; an instance entry took a different path and got none of it. Also corrected a stale comment claiming instance registration is DEFERRED intoonInit— M86’s own code review reverted that and gated delivery instead.
Documentation
-
storage-plugin— documented thatS3Provider.getStreamis demand-driven, that the other four providers are NOT, and that process RSS does not measure any of it. X45-1 read a slow-client RSS rise as the S3 stream pulling ahead of its consumer; counted at the socket instead, read-ahead is 2-3 MiB and CONSTANT across 20 s and 20 MiB delivered (3.1 MiB at a 1 MiB/s consumer, 1.9-2.5 MiB at 2 MiB/s), a stalled consumer stops the wire, and cancelling releases the upstream connection. The scoping is the part worth reading:GcsProviderandAzureBlobProviderstream natively but drain their SDK stream eagerly with nopulland nocancel, andMemoryProviderandLocalStorageProviderhave no nativegetStream, soStorageServicereads the object whole and emits it as one chunk — on all four, a large object is memory-resident however slowly the client reads. A per-provider table now says so rather than leaving the S3 measurement to read as a general guarantee. The RSS reading does not discriminate — forcing GC mid-transfer returnsheapUsedandexternalto baseline while RSS stays up, and a generated-ReadableStreamcontrol is not like-for-like because it never pays the AWS SDK’s ~29 MiB import. The finding’s own figures say the same: a 1 GiB object plateaued at 132 MB and then fell to 113 MB. The suggestedpull()adapter measures 3.2 MiB against 3.1 MiB, so it was not adopted. No code change; the gap was that no gate drove a large real object through a slow reader, now closed by a guarded suite measuring wire bytes through a byte-counting relay (verified to fail at 47.9 MiB against a 16 MiB cap whengetStreamis replaced with an eager unbounded pump). -
graphql-plugin— documented thatsubscriptions.websocket.onConnectand a pipeline guard on the socket path cannot both authenticate a subscription.connectionParamsis the protocol’s auth channel andonConnectreads it, butconnection_initis a message on an already-open socket, while since M70a a guard runs on the UPGRADE — so a guard covering the/graphqlprefix answersGET /graphql/wswith401andonConnectnever runs. Following the README’sonConnectexample andPUBLIC_API.md’s “Authenticating a subscription” bullet at the same time could not work, and neither site mentioned the interaction M70a created (X6-8). Both now name the two working compositions: scope the guard to the HTTP endpoint, or authenticate on the upgrade with a cookie throughauth-plugin’s session strategy. No code change. -
session-plugin— documented whatSameSitedoes not separate.cookie.sameSitedefaults to'lax'and is evaluated against the SITE (scheme plus registrable domain), so a port is not part of it: two ports on one host are same-site and the session cookie rides a subresource request between them. Paired withignoreMethods’ correctGET/HEAD/OPTIONSexemption, one assumption carries the weight — thatGEThandlers have no side effects — and a developer reading “SameSite=Laxby default” reasonably concludes another port cannot reach their session. Measured: the identical attack suite lands from127.0.0.1:5301and is blocked fromlocalhost:5301, changing nothing but the origin (X33-1). It also says what does NOT close the gap: cookies are not scoped by port at all (RFC 6265 §8.5), so no cookie prefix isolates one —__Host-is a SUBDOMAIN boundary and worth setting for that, but a service on another port of the same host receives the cookie whatever attributes it carries. No code change; both defences are behaving as designed.
0.5.0 — 2026-09-09
Added
-
@setu-ts/common,@setu-ts/database-plugin,@setu-ts/messaging-plugin— operator error diagnostics survive wrapping and string logger sinks (M90j).serializeErrornow carries a bounded, scalar-only driver classifier allowlist andAggregateErrormembers; database adapter wrappers retain their original cause; and broker string loggers render the complete bounded diagnostic rather than JavaScript’s lossy default error interpolation. -
Documentation — the Deno compiler-option mechanism, corrected wherever it was asserted. Six sites — the
0.1.0-alpha.8CHANGELOG entry, threepackages/cli/srcJSDoc blocks, the M63 entry inCLAUDE.md, and the ROADMAP record — stated that declaring anycompilerOptionskey in adeno.jsonREPLACES Deno’s entire default set, and blamed thefull-stacktemplate’s 79TS2686errors on an unrelatedexperimentalDecoratorsentry displacing thereact-jsxdefault. That mechanism is false: measured on Deno 2.9.6, declaring one option leaves every other default in force, and the default JSX transform is the CLASSIC one — so a React template must declarejsxitself, and the missingjsxwas the whole cause. The fix those sites describe is unaffected and was always correct; only the stated reason was wrong, which matters because a reader who believed it would avoid declaring a compiler option they need. The0.1.0-alpha.8section is edited in place, with the superseded sentence quoted rather than deleted. No generated output changes — the three CLI edits are comments only. -
Documentation —
docs/upgrading.md, and four published claims corrected (M90h / X22-4, X26-1, X26-2, X33-2, X20-3). A new upgrade guide answers “what must I change in my project”, version by version, where the CHANGELOG answers what changed in the framework;docs/releasing.mdgains the release step that maintains it, anddocs/README.mdindexes it. No package’ssrc/changes. The auth README’s rate-limit example no longer claims// per IP— the default key is the literal'anonymous'for every unauthenticated caller unlessipSecurityMiddlewareis registered withtrustProxy— and a new test pins the resolved key rather than the comment. The session README gains the safe-request-then-mutation CSRF sequence, because its## Session fixationand## Form CSRFsections each compile and answer403when composed; a new integration test drives the sequence and asserts the successful path first. The secrets README andPUBLIC_API.mdgain a per-providerset()/rotate()table — Vault and Azure create on write, AWS and GCP require the secret to pre-exist,envrefuses — each cell marked verified-against-a-real-backend or not. Two published sections are corrected in place: the 0.2.0 entry now announces the requiredIRepository.findPage(announced nowhere when it shipped, while its siblingfindOnewas announced twice), backed by a hand-written implementor committed as a compile-time tripwire, and the 0.1.0-alpha.10 decorator entry now says that a reader’s OWN manifest must dropexperimentalDecorators. A third published section is corrected below, by a separate change. -
@setu-ts/common,@setu-ts/database-plugin— explicit transaction isolation (M90g / X24-2, X38-3).TransactionOptions.isolationcarries one of the four portable isolation names — the newTransactionIsolationLevelunion, exported from@setu-ts/commonand re-exported from@setu-ts/database-plugin— throughIDatabaseService.transaction()to the adapter. An adapter declares what it honours through the newITransactionIsolationSupport(common), and a level it does not honour is refused with the new exportedUnsupportedIsolationLevelError(database-plugin), answered501, rather than silently downgraded. What an adapter honours is connector-dependent, not per-adapter: Prisma honours all four on PostgreSQL, MySQL and SQL Server, onlyserializableon CockroachDB and SQLite, and none on MongoDB or a connector it could not resolve; a Drizzle bridge honours all four only when the application wraps it with the new exportedwithIsolationSupport(), which is the declaration that its bridge forwards options; and Memory serializes process-local transactions.PUBLIC_API.mdcarries the per-adapter table. MongoDB snapshot isolation is not labelled as portableserializable, so MongoDB refuses every requested portable level by name rather than silently using its default. -
@setu-ts/session-plugin— documented snapshot-commit concurrency limit (M90g / X22-6). Cookie and store sessions each commit their complete payload snapshot, so overlapping requests that update different keys remain last-writer-wins. The README and public API now name this explicitly, with short-lived sessions and an application-level serialized or optimistic write path as remedies. -
@setu-ts/cache-plugin— in-process cache-miss coalescing (M90g / X24-1).CacheService.getOrSet(key, factory, ttlSeconds?)shares one cold read per key and resolved store, stores its successful result with the explicit or configured default TTL, and clears a failed load so every waiter can retry its own factory.cacheMiddlewareuses the same per-process mechanism and report successful waiters asX-Cache: COALESCED; streaming, uncacheable, and failed leaders are never replayed. Separate processes do not coordinate. -
@setu-ts/messaging-plugin— named NATS JetStream startup errors and an explicit subject set (M90d / X28-2, X28-3).NatsOptions.streamSubjects(and the same member on the publicnatsarm) supplies the subjects the broker may create the configured stream with when it is absent on the server. There is deliberately no default and no catch-all: NATS refusessubjects: ['>']withoutno_ack, andno_ackmakes every JetStream publish reject unobserved (the rejected design, measured against a real server). Supplied and the stream is absent, the broker creates it with exactly those subjects; an existing stream is never touched; absent with the stream absent,connect()rejects with the new exportedJetStreamStreamError, naming the stream and both remedies. A JetStream-less server now rejects with the new exportedJetStreamUnavailableError(the raw503carried ascause) instead of surfacing a bare platform error. The messaging-plugin CI suite also gained two real-backend suites (nats-real,kafka-real) againstnats:2-alpine -jsandapache/kafka:4.0.0, started by both workflows; the package manifest’s scopednetgrant gained4222and9092. -
@setu-ts/messaging-plugin—messageId/timestampon the cloud brokers’ metadata (M90d / X28-4).IPubSubTransport.openandIServiceBusTransport.opendelivered-message callbacks gain optionalmessageId/timestampmembers (source-compatible for existing transports — a two-member implementation stays assignable), the real adapters readmessage.id/message.publishTimeandmessage.messageId/message.enqueuedTimeUtc, and both brokers copy them ontoMessageMetadataonly when the transport carried them, so an absent member means “none delivered” rather than “did not look”. All seven first-party brokers now populate both members. -
@setu-ts/messaging-plugin— Service Bus transport reachability and a retry budget (M90b / X28-5, X28-6). The adaptedServiceBusSdkModuleadministration client gains an optionalgetNamespaceProperties()and the adapted transport now implements the documentedisHealthy()probe: a successful read proves the namespace is reachable, a positively identified 401/403 counts as reachable too (the namespace answered — a send/listen-only credential is not a network outage), and any other failure is unreachability. The newServiceBusRetryOptionstype (exported) is accepted asretryOptionson theservice-busproduction arm only and is passed toServiceBusClientalone, never to the administration client; omission preserves the Azure SDK defaults andmaxRetries: 0is the documented short budget for failing fast toward a dead broker. -
@setu-ts/health-plugin— bounded, concurrent aggregation. NewHealthPluginOptions.indicatorTimeoutMs(positive finite, default 5,000) applies a per-indicator deadline; the service runs selected indicators concurrently, records each latency individually, maps a timeout to{ status: 'down', data: { reason: 'timeout' } }and a rejection to{ status: 'down', data: { reason: 'error' } }without serializing the thrown value, and keepschecksin registration order. The deadline is validated by ONE check shared by both entry points:HealthServiceis barrel-exported, sonew HealthService(runtime, { indicatorTimeoutMs })refuses zero, a negative,NaNandInfinityexactly asHealthPlugindoes rather than storing a value the deadline timer cannot honour. See Changed for the behavior change to timing. -
@setu-ts/database-plugin— pool capacity as data. NewDatabasePoolCapacitytype (exported) andDrizzleAdapterOptions.poolStats— an application-owned callback reading the driver’s own pool API. The adapter publishes the snapshot to thedatabaseindicator underdata.capacity; omitted, no capacity fields appear. No threshold is applied.DatabaseServicealso gainsreadonly isClosed: boolean— a lifecycle-only read that reaches no adapter — which is what the indicator’s uncached gate now reads: gating onisHealthy()calledIDatabaseAdapter.isReady()on the one path deliberately outside the probe’s 2-second bound, and cost two readiness reads per poll. Every adapter readiness call now happens inside the bounded, cached probe, and the gate is re-read after the probe settles so a poll already in flight whenclose()begins cannot publish a reachability answer the probe cached before it. -
@setu-ts/cache-plugin,@setu-ts/secrets-plugin— truthful reachability. Thecacheandsecretsindicators now reportreachable(true/false/'unknown') beside lifecycle, through probes cached 5 s and bounded 2 s viacreateCachedProbe— exported from@setu-ts/commonwith itsCachedProbeOptionsandProbeTimingtypes and theresolveProbeTiminghelper that reads a runtime’s clock and timers: Redis probes withping(), Vault with an unauthenticated/v1/sys/healthrequest (no secret read, no token), memory/noop/env report lifecycle truth, and a cloud facade without the new optionalisHealthy()member reports'unknown'— never a secret read standing in for a probe. -
@setu-ts/queue-plugin—backlogfact. Thequeueindicator carriesbacklogwhenever at least one depth read succeeded: the sum of each successful name’sready + processing, deliberately excluding terminaldead. A fact, not a threshold. -
@setu-ts/common—createPathMatcher(patterns)andPathPattern, the one path-exclusion matcher. Three middlewares already carried a private copy of the matching loop and X32-1’s fix would have made a fourth (§11.1). The copies disagreed: two matched literals only, one accepted aRegExpbut ran an O(n)typeofbranch per request, and exactly one of the three resetlastIndexbefore.test— which is the difference between ag-flagged pattern matching every request and matching every other request. The list is partitioned ONCE at construction, literals into aSetand patterns into an array walked only on a literal miss, so an all-literal list (every default in this framework) costs the same hash lookup the hand-rolled copies did, andtenantMiddlewaregets faster than its previous per-entry loop.RateLimitOptions.exclude,MultiTenancyPluginOptions.exclude,RequestLoggerOptions.excludePathsandMetricsPluginOptions.excludePathsall read it. Those last two widen fromreadonly string[]toreadonly PathPattern[]: source-compatible for callers, and breaking only for a consumer EXTRACTING the option type into a narrower one. -
@setu-ts/runtime—RuntimeOptions.maxBodyBytesandRequestBodyTooLargeError, the request body bound no header can switch off (X32-4).requestSizeMiddlewarerefuses on a declaredContent-Length; a chunked request declares none, and since M87 made the body read lazy the read happens inside the handler, after every middleware has returned — so the middleware could not close the bypass at all, andRequest.arrayBuffer()buffered a body of any size. The read now streams with a byte cap, comparing the running total before a chunk is retained and cancelling the source on refusal rather than abandoning it. Omitted, the read is unbounded and the uncapped branch keepsarrayBuffer()verbatim, so an application that configures no limit runs the previous code exactly. The refusal is branded with a413status hint, so an application runningerrorHandleranswers413 Payload Too Largein its configured format. There are deliberately TWO knobs for one concern — the mapping runs before any plugin and no channel exists between them — and the docs say which is load-bearing: a reader who sets onlymaxBodySizekeeps the previous behaviour rather than getting a silent regression.HttpAdapterFactories’ members now take an optionalHttpAdapterOptions, which keeps a zero-argument injected fake assignable. -
@setu-ts/http-security-plugin—IpSecurityOptions.trustedProxiesandproxyHops, resolving the client from the RIGHT of a proxy header (X32-3).trustProxy: truetook the LEFTMOST entry, which is safe only behind a proxy that overwrites the header — and the standard nginx idiom (proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for) APPENDS, so a request arriving with a forgedX-Forwarded-For: 7.7.7.7reached the application as7.7.7.7, 198.51.100.9and the leftmost entry was the value the caller chose. Anything keyed onCLIENT_IP_STATE_KEY— includingdefaultRateLimitKey— was then keyed on attacker input.trustedProxieswalks right to left and returns the first entry that is not a trusted proxy;proxyHopsreturns the nth from the right for proxies with no fixed address, and a header shorter than the declared chain resolvesundefinedrather than falling back to a guess. The two are mutually exclusive and supplying both throws at middleware construction, as does atrustedProxiesentry whose CIDR width is not plain digits (Number('')is0, so'10.0.0.1/'would compile to a/0matcher trusting every IPv4 address, and a chain whose leftmost entry is IPv6 would then return that caller-supplied value as the client) and aproxyHopsthat is not a non-negative integer (0is the rightmost entry): without that guard it resolvesundefinedfor every caller and silently degrades a rate limiter keyed on the client IP to one shared bucket. With neither supplied resolution stays leftmost, unchanged — X32-3 requires the operator to have opted intotrustProxy, so a silent default flip would be a larger change than the finding. Only IPv4 CIDR is expanded numerically; every other form is compared as a case-insensitive literal, deliberately, since a wrong expansion would silently TRUST an untrusted hop. -
@setu-ts/graphql-plugin—GraphqlPluginOptions.maxNodes, a query-breadth budget (X32-6).maxDepthwas the only query-cost control and it bounds NESTING: a document two levels deep can still ask for a hundred thousand fields, because every alias is a separate field. Measured, one such request produced a ~5 MB response and +822 MB RSS withmaxDepth: 5configured and unable to see it, and the only thing bounding it was the request-body size.maxNodescounts the fields a document would resolve — aliases separately, and each fragment spread costing its definition at EVERY spread site, so defining a hundred fields once and spreading them a thousand times counts as a hundred thousand rather than eleven hundred. Expansion is memoized, so a deep fragment graph reports a large count in time linear in the document’s own size; without that the counter would itself be the denial of service. Off by default (0), so no released application starts refusing a document it used to serve. A document’s cost is its largest operation, not the sum of all of them — only the operationoperationNameselects is ever executed, so summing refused a bundled document whose selected operation was within budget, and the siblingmaxDepthwas already per-operation. A value that is not a non-negative integer throws at construction, for the same reasonmaxBodyBytesdoes:maxNodes <= 0isfalseforNaN, so the rule would be built and then never report, silently enforcing nothing while reading as configured. The rule itself is deliberately NOT exported: it is configured throughmaxNodes, nothing outside the package constructs it, and exporting it would leak the plugin’s private graphql facades into the published surface — whichdeno doc --lintreports (the M82 precedent). -
@setu-ts/common— the shared authorization refusal (M90c / X18-2).respondWithAuthorizationFailure(target, failure)and theAuthorizationFailureunion ('authentication-required' | 'not-configured' | 'insufficient-privileges') are exported soauth-plugin’s guards anddecorator-plugin’s@Roles/@Permissionsmiddleware write the same refusal without either plugin importing the other. Each arm writes through the existingrespondWithErrorseam, so all three answer in the application’s configured error format:401 Unauthorized/Authentication required,501 Not Implemented/Authorization is not configured, and403 Forbidden/Insufficient privileges. That last detail is deliberately fixed text — see Changed for the non-disclosure it replaces. -
@setu-ts/common— the shared JSON-body parse (M90f / X37-1).parseJsonBody(text)andMalformedRequestBodyErrorare exported so all threeIRequest.json()implementations — the served HTTP path (@setu-ts/runtime), the kernel’sinject(), and@setu-ts/testing’sMockRequest— reject a malformed body with the SAME400-branded error instead of each raising its own bareSyntaxError.IRequest.json()’s contract now states the rejection. -
@setu-ts/database-plugin— classified driver errors (M90f / X38-1, X35-2). New exportedSerializationConflictErrorandDatabaseUnavailableError.DatabaseServiceclassifies a driver rejection at its four interception sites (repository wrappers, thebeginTransaction()acquisition, the transaction catch, andquery()), carrying the original driver error ascause. -
@setu-ts/secrets-plugin— the package’s first error class (M90f / X20-2).ReadOnlySecretProviderError, rejected byEnvProvider.set— the provider’s only write method — soSecretsService.rotate()inherits the refusal from the one throw site. -
@setu-ts/common,@setu-ts/queue-plugin— the queue carries a trace (M90i / X34-1, X29-3).AddJobOptions.headers?andIJob.headers?mirrorMessageMetadata.headersexactly, giving the queue ingress the header channel it had none of —grep -c headersovercommon/src/services/queue.tswas0, so this was a designed capability gap rather than an adapter declining to read something. Both are OPTIONAL, so every existingIQueueimplementor stays source-compatible. With aCAPABILITIES.TELEMETRYprovider registered,queue.add()now runs in anenqueue <name>producer span that injects a W3Ctraceparent, and every processor runs in aprocess <name>consumer span parented from it — through theprocessorsarm, aprocessorsfactory, and imperativequeue.process()alike. All four adapters carry the map, and an M86 ingress behaviour reads it asIngressContext.headers, the same member the'messaging'arm uses. Absent telemetry nothing is injected and behaviour is byte-identical; aheadersmap the CALLER passed is delivered either way, since it is a public option whose delivery must not depend on which capabilities are registered. -
@setu-ts/cloudflare-plugin—WorkersQueuecarries the queue header channel (M90i, review finding). The Cloudflare queue accepted the widenedAddJobOptionsand droppedheaderson the floor, so a caller-supplied map — and any propagatedtraceparent— was silently lost, while the contract says a supplied map reaches the processor. The{ v, name, id, data }envelope gains an OPTIONALheaders, so the version is deliberately NOT bumped: an older consumer ignores the field and a newer consumer reads an older message as carrying no channel, which is what absent means. A malformed map is dropped and the job runs untraced rather than being refused, because the envelope guard RETRIES what it rejects and losing the work to protect the record of it is the worse trade. -
@setu-ts/common,@setu-ts/telemetry-plugin,@setu-ts/logger-plugin— log records name their trace (M90i / X34-2).trace_id/span_idappeared incommonandtelemetry-pluginand in no logger, formatter or transport, so every signal the framework emits was individually good and mutually unjoinable.ITelemetryService.activeSpanContext?()and the matchingTracerHost.activeSpanContext?()are new OPTIONAL members — the read could not be expressed before, sincewithSpanhands its span only to its own callback — andLoggerPluginnow enriches every record, at every level and throughchild(), when a telemetry capability is registered. The field names are snake_case to match the OpenTelemetry log-correlation convention that log backends key on.NoopTelemetryServiceomits the member, and a service that cannot report an active span enriches nothing rather than emitting empty identifiers. Validity is decided by the SHARED trace- context codec rather than a rule spelled out twice, so the read path and the propagation path cannot disagree about which contexts are real: an empty, malformed or W3C all-zero identifier — what OTel reports for an INVALID span context — is reported as no active span, and never reaches a log record as a trace nothing else could carry.
Changed
-
BREAKING —
@setu-ts/logger-plugin— the registeredILoggeris a decorator, not the transport instance (M90i / X34-2).CAPABILITIES.LOGGERnow resolves to an internal trace-enriching wrapper around the configuredConsoleLogger/PinoLogger/NoopLogger, sologger instanceof ConsoleLoggeron the resolved capability no longer holds. EveryILoggermethod behaves identically andlevelpasses through, so code written against the contract is unaffected. Migration: if you branch on the concrete class, branch on behaviour or on your own configuration instead —ILoggeris the contract and the concrete class was never part of it.LoggerPlugindeliberately does NOT declareCAPABILITIES.TELEMETRYinoptionalDependencies:TelemetryPluginalready declaresCAPABILITIES.LOGGERin its own, and the two edges together are a cycle the kernel’s plugin resolver refuses atstart()(Circular plugin dependency detected). The capability is resolved at call time instead, which is required rather than prudent — the resolver ordersLoggerPluginfirst, so telemetry is guaranteed absent when it registers. -
BREAKING —
@setu-ts/messaging-plugin— the Kafka default consumer group is derived per topic (M90d). Measured against a real broker: members of one consumer group must subscribe the SAME topics — kafkajs logs “Consumer group received unsubscribed topics” and every member ends with an EMPTY assignment, so the previously sharedmessaging-consumersgroup silently stopped ALL delivery the moment an application subscribed two topics (including request-reply, whose responder subscribes the derivedrr.req.<topic>channel). A subscription with noqueuenow uses<defaultQueue>:<topic>; same topic across instances still load-balances, different topics never share a group, and a caller-suppliedqueuestill names the group itself. The separator is a colon because-is not injective —'orders-eu'+createdand'orders'+eu-createdboth yieldorders-eu-created, restoring the same failure — and a Kafka topic name may not contain:(probed: the broker refuses one) while a group id may. Migration: tooling keyed on the literalmessaging-consumersgroup reads the derived<defaultQueue>:<topic>names now. -
BREAKING —
@setu-ts/cache-plugin—IRedisClientgains a requiredping(). The Redis store’s reachability probe invokes it. Migration: an injected client structurally typed asIRedisClientaddsping(): Promise<string>(ioredis already has it); the probe treats a rejection as unreachability. -
BREAKING (behavior) —
@setu-ts/health-plugin— indicators run concurrently under a deadline. Previously each selected indicator was awaited in registration order, so a 2-second outage across six dependency indicators held/healthfor 12+ seconds and one never-settling indicator left the endpoint pending forever. A timeout is now recorded as{ status: 'down', data: { reason: 'timeout' } }and a rejection as{ status: 'down', data: { reason: 'error' } }; report shape and key order are unchanged. Migration: an indicator relying on serial execution relative to a sibling (none is documented to) no longer gets it; assert on the report, not on wall-clock interleaving. -
@setu-ts/realtime-backplane-plugin— the'messaging'backplane calls the broker’sisHealthy()through its owner (X21-1). The probe was captured as a detached reference, so a broker whoseisHealthyreads instance state (ServiceBusBroker’s private probe cache) threw a bareTypeErrorthrough the health endpoint. It now invokesbroker.isHealthy()and retains the broker’s own probe cache rather than adding a duplicate one. -
BREAKING —
@setu-ts/auth-plugin— refresh-token stores now revoke credential families.RefreshTokenStoregains required atomicrotate(jti, successor: RefreshTokenRecord): Promise<IRefreshTokenRotation>— the new exported union reports whether the presented token was live when its successor was stored, so a concurrent refresh cannot mint two descendants — andrevokeFamily(jti): Promise<readonly RefreshTokenRecord[]>, which returns the records it revoked so their paired access-token identifiers can be revoked with them. Custom implementations must persist the optional lineage fields onRefreshTokenRecordorrevokeFamilyhas no chain to walk.MemoryRefreshTokenStoreimplements both.RefreshTokenServicenow issues typed, distinct access and refresh JWT identifiers; bearer authentication rejects a signedtype: 'refresh'token. For immediate access-token logout, construct oneIAccessTokenRevocationStoreand pass it to bothAuthPlugin({ jwt: { ... } })andRefreshTokenService; the new exportedMemoryAccessTokenRevocationStoreis single-process and the refresh service requiresaccessToken.expiresInwhen this option is used. Policy failures still answer403, but their detail is nowInsufficient privilegesrather than disclosing required roles or permissions. Migration: add atomicrotateandrevokeFamilyto custom refresh stores, retain family fields, serialize family revocation with rotation, and update tests that assert the old 403 detail. -
BREAKING —
@setu-ts/cli— baresetu generateis informational, not an error. With no schematic named it printed the available schematics through the normal output sink yet exited2— an error exit whose guidance read like help. It now lists the schematics and exits0, printing exactly the textsetu generate --helpprints. No refusal moves:generate <schematic>with a missing name,generate customwith a missing name, an unknown schematic, and an over-arity invocation all keep exit2with nothing written. One addition: the--runtimevalidation every generating path runs now sits above the listing, so baresetu generate --runtime deno2refuses with exit2where the listing used to print over the typo. Migration: a script branching on baresetu generateexiting2sees0now — branch on the printed list or drop the branch. -
BREAKING —
@setu-ts/cli— unknown flags are refused instead of silently ignored.setucollected every--flagand never consulted the valid names, so a typo scaffolded a DIFFERENT project with exit0and no warning:new app --dry-run --templat restproduced the minimal project where--template restproduces therestcomposition, and--base-port(the real flag is--port) was swallowed the same way. Each command now refuses a flag outside its own set — subcommand-aware forgenerate app,generate library, andworkspace ports— with exit2, a message naming the command and, within a small edit distance, a “did you mean” suggestion, and nothing written or created. Flags kept deliberately recognized for a NAMED refusal (--diand--depends-ononnew; the transport flags,--runtime, and--diongenerate app) still reach their specific guidance, and plugin-registered commands accept only--dirand--config— aCliCommandHandlerreceives positionals only, so no plugin can read any other flag. Migration: a script passing a stray flag today exits0and will now exit2— remove or correct the flag. No compatibility mode preserves ignored flags: accepting an unrecognized option would restore the silent misconfiguration this change removes. -
BREAKING —
@setu-ts/cli— extra positional arguments are refused instead of silently ignored.newread the first positional only, sosetu new app extra junkscaffoldedappand reported success with exit0;generateread only the schematic word, its name, and (forcustom) the custom-schematic name, dropping anything after; andadopt,workspace ports,commands, andhelpconsumed no positionals at all. Each now refuses an over-arity invocation — exit2, nothing written or created, a message naming the command and the expected count — in the dispatcher before any command body runs, mirroringadd’s existing refusal. Tokens after--are positionals ([parseArgs]) and refuse the same way. Valid arities are unchanged (new app,generate <schematic> <name>,generate custom <schematic-name> <name>,generate app|library <name>, and the bareadd/adopt/workspace ports/commands/helpforms), and plugin-registered commands still receive every positional. Migration: a script passing stray positionals today exits0and will now exit2— remove them. -
BREAKING —
@setu-ts/database-plugin— default memory raw-SQL and programmatic-migration refusals now answer501 Not Implementedinstead of a masked500.query()now rejects withUnsupportedRawQueryErrorrather than throwing synchronously, so callers using.catch()now receive the refusal.migrate()rejects with the newUnsupportedMigrationError: migrations are a framework-wide unsupported capability, not a raw-query or query-shape refusal. Update status assertions from500to501and synchronoustry/catchcall sites toawaitor.catch(); use each adapter’s migration CLI instead ofmigrate(). -
BREAKING (behaviour) —
@setu-ts/auth-plugin— the rate limiter’s429body answers in the application’s configured error format (X32-2).rateLimitMiddlewarewrote its own body withctx.response.status(429).json({ error, message }). M70f routed every first-party short-circuit through therespondWithErrorseam — upload, tenant, session, the nine auth guards, http-security’s three, the flag guard — and missed this one, presumably because the limiter is middleware rather than a guard. So the ONE status a client is most likely to handle programmatically was the one status that ignorederrorHandler’s configuration, and a consumer parsing Problem Details (which this framework’s own generated SDK does) got an unreadable body underapplication/json. TheRetry-AfterandRateLimit-*headers are unchanged. Migration: the body shape changes. WitherrorHandler({ format: 'rfc9457' })the429is now Problem Details underapplication/problem+json, carryingdetailand nomessage; withformat: 'default'it is{ statusCode, message, details: { detail } }, the shape every other refusal already used; with noerrorHandlerregistered it is the seam’s{ error, detail }fallback. A client readingbody.messagefor the limiter’s text should readbody.detail(orbody.details.detailunder'default'). -
BREAKING (behaviour) —
@setu-ts/auth-plugin— an exhausted rate limiter no longer refuses the operational probes (X32-1).RateLimitOptionshad no exclusion member at all, and the middleware is documented as a global one, so a burst of abuse made/liveanswer429— which a Kubernetes kubelet reads as a failed liveness probe and restarts the container, whose only fault was being under load.excludenow defaults to the six operational paths (/live,/ready,/health,/metrics,/openapi.json,/docs), the same listtenantMiddlewareexempts. An exempt path increments no counter and carries noRateLimit-*headers, because the limit does not govern it. Migration: passexclude: []to restore the previous behaviour. A caller list REPLACES the defaults, so spreadDEFAULT_RATE_LIMIT_EXCLUDED_PATHSto extend them. -
BREAKING (wire keys) —
@setu-ts/auth-plugin—RedisRateLimitStorenamespaces its keys (X32-5). Keys were written exactly askeyGeneratorproduced them —anonymous,ip:203.0.113.7,user:42— so two applications sharing one managed Redis counted against each other’s budget, and combined with X32-1 service A’s traffic would restart service B’s pods. Both sibling Redis stores in this framework already namespace and both explain why in their own source.keyPrefixdefaults to'setu:ratelimit:', exported asDEFAULT_RATE_LIMIT_KEY_PREFIX. Migration: in-flight counters under the old keys are orphaned, so the blast radius is one window — they are TTL-bounded and expire on their own. PasskeyPrefix: ''for the previous keys byte for byte. -
BREAKING —
@setu-ts/database-plugin— driver conflicts and outages reach the client as409and503(M90f / X38-1, X35-2). A backend-reported serialization failure (PostgreSQL SQLSTATE class40—40001,40P01— Prisma’sP2034, MongoDB’sTransientTransactionErrorlabel, Bigtable’s gRPCABORTED, Cosmos449) now answers409 ConflictthroughSerializationConflictError, where it was a masked500that told a well-behaved client to give up — silently dropping the write and making optimistic concurrency unusable. A transiently unreachable backend (SQLSTATE class08,57P03, the node-postgres pool timeout,MongoNetworkError/MongoServerSelectionError, gRPCUNAVAILABLE, Cosmos429/503, the net errnoECONNREFUSEDfamily) now answers503 Service UnavailablethroughDatabaseUnavailableError. The original driver error is preserved ascause(the SQLSTATE stays log-reachable); the serveddetailis a fixed sentence, never driver text. No classified answer carriesRetry-After— the hint channel has no header and no component holds an honest horizon. Migration: an application branching on the masked500for these conditions branches on a body it could never have parsed (“Internal Server Error”);instanceof SerializationConflictErroris the retry signal. The typed Drizzle builder (getDrizzleDatabase) bypasses the classifier, and DynamoDB’sTransactionConflictException/ Cosmos’s449are documented-unverified — neither was reproducible against DynamoDB Local or the local emulator. -
BREAKING —
@setu-ts/runtime,@setu-ts/kernel,@setu-ts/testing— a malformed JSON request body answers400(M90f / X37-1).IRequest.json()rejects withMalformedRequestBodyError(branded400 Bad Request) instead of a bareSyntaxErrorthaterrorHandlermasked to500. The read stays lazy, so the400is produced when the handler reads, and a handler that catches its own rejection keeps full control. An empty body still parses as{}oninject()andMockRequest. Migration: none for well-formed callers; a500-matching catch for malformed bodies now sees400. -
BREAKING —
@setu-ts/secrets-plugin— a write against a read-only provider answers501(M90f / X20-2).EnvProvider.set(andSecretsService.rotate()on theenvprovider) rejects withReadOnlySecretProviderError, answered501 Not Implementedwith the class’s own sentence, where it was a masked500. Migration: an application catching the old plainError('EnvProvider is read-only; …')by message still catches the new class (same message); add aninstanceofbranch to distinguish it from faults. -
BREAKING —
@setu-ts/resilience-plugin— the shedding errors answer503/503/504(M90f / X32-7).BulkheadFullErrorandCircuitOpenErrorare answered503 Service UnavailableandTimeoutErroris answered504 Gateway TimeoutundererrorHandler, where all three were masked500s that read as bugs in every dashboard. Shedding is the bulkhead’s purpose, so the status is part of the contract. None carriesRetry-After— the hint channel has no header, and no component holds an honest drain horizon;503is itself the retryable signal. Migration: an application serving its own fallback for these errors is unaffected; one relying on the masked500must match the new statuses orinstanceofthe classes.
Security
-
The
deno.lockresolution ofnpm:nodemailermoves9.0.4→9.1.1(GHSA-2x7j-588g-ccc2, high). Quadratic (O(n²)) time in nodemailer’saddressparserlets a crafted address list drive a remote denial of service; the advisory was published 2026-09-08 and names< 9.1.0. No source change was needed —smtp-provider.tsalready importsnpm:nodemailer@^9, which the patched version satisfies — so this moves the resolution CI and the test suite use.Scope, stated precisely, because a lockfile entry is easy to over-read: this repository’s lock governs its own CI and tests, not a consumer’s. An application using
@setu-ts/mail-pluginresolvesnpm:nodemailer@^9against its own lock, so it was never pinned to9.0.4by us and receives nothing automatically from this entry. If your lock resolves that specifier below9.1.0, update it. Only the SMTP provider reaches nodemailer at all; every other mail backend is unaffected.
Fixed
-
@setu-ts/openapi-plugin— a type union is spelledanyOfon every supported zod v4, not whichever spelling the installed patch version chose (issue #253). Draft 2020-12 permits both spellings, and zod’s publictoJSONSchema()changed which one it emits INSIDE the declared>=4.4.0 <5support range: through 4.4 a union of bare types emittedanyOf: [{ type: 'string' }, { type: 'null' }], and from 4.5 it collapses totype: ['string', 'null']. The transformer passed that through, so an application’s zod PATCH version decided the document’s shape —isStructuralShapekeys onanyOf, so a nullable or primitive union silently stopped earning acomponents/schemasentry and a regenerated@setu-ts/sdkclient changed shape for no API reason. It also produced a value the publishedOpenApiSchemaObject.typecannot express (a single type name, not an array), so a consumer switching on it got no compiler warning, and atypearray is 3.1-only whereanyOfconverts down to OpenAPI 3.0.#adaptDocumentnow normalizes the collapsed form back at any depth, on both the plain and the component-hoisting path, keeping the v4 path in agreement with the v3 path — measured: one schema now yields byte-identical documents under 4.4.3 and 4.5.4. Sibling keywords stay beside theanyOfinstead of being distributed into its arms, which would apply a string constraint to thenullarm. No public type changed and no consumer call site breaks. Reached the weekly dependency-drift gate rather than a pull request because the committed lockfile resolved 4.4.3; it now resolves 4.5.4, and a pinnednpm:zod@4.5.4case asserts the behaviour against a version that really collapses, so a later lockfile move cannot make it vacuous. The rewrite descends only into schema positions, so adefault,const,enum, example or vendor-extension value keeps its owntypekey verbatim, and a node already assertinganyOfkeeps it — the normalized union is conjoined throughallOf. -
@setu-ts/static-plugin— gzip sidecars now use the registeredgzipcontent-coding token. A.gzsidecar was sent asContent-Encoding: gz, which standard clients cannot decode and a shared cache could retain after the origin was corrected. Purge affected cached variants to recover immediately. Conditional evaluation now runs after content negotiation, so anIf-None-Matchvalidator for the uncompressed source cannot incorrectly return304for a selected compressed sidecar. On runtimes without a filesystem, the registeredIStaticFilesservice now returns404as documented rather than throwing when an application callsserve(). -
@setu-ts/messaging-plugin— the Kafka broker starts (M90d / X28-1).KafkaBrokerwired its reconnect supervisor with the uppercase KEYS of kafkajs’sproducer.events('CONNECT'/'DISCONNECT') where the wire VALUES are required (producer.connect/producer.disconnect); kafkajs validates the name and threwKafkaJSNonRetriableErrorinsideregister(), so an application configured withbroker: 'kafka'never bound a socket. The broker now attaches two declared wire-value constants; a guarded real-import test pins them against the real module’s owneventsmap and proves the uppercase keys are rejected by real kafkajs. The corrected event names also fix the JSDoc that documented the keys as the accepted names (C1). -
@setu-ts/messaging-plugin— the NATS broker delivers (M90d, found by the new real-backend suite). Two delivery-path defects no injected fake could see, both probed againstnpm:nats@2.29:subscribe()created the durable consumer throughjs.consumers.add, which the JetStream client does not expose (consumer creation is a management operation onjsm.consumers.add), so every real subscribe threw a bareTypeError; and the delivery cast readinfo.timestamp, which nats 2.29 spellsinfo.timestampNanos— an integer above the float safe-integer range — so every real delivery carried an Invalid Date. Consumer creation now goes through the manager and the timestamp is converted from nanoseconds through BigInt; the test fixture models the realtimestampNanosfield rather than the string it claimed. -
@setu-ts/messaging-plugin— the NATS broker fails startup with a name, not a bare platform error (M90d / X28-3). See Added:JetStreamUnavailableErrorandJetStreamStreamErrorreplace the raw503and the catch-all-refusal text at bothconnect()failure sites, carrying the platform error ascausewherever the platform produced one. The absent-stream refusal is the one arm with nocause: no platform call failed, the broker declines to invent a subject set.
0.4.0 — 2026-09-05
A declaration that enforced nothing now enforces, and a caller’s mistake stops reading as a server
fault. @Roles/@Permissions recorded metadata and were evaluated by nobody, so a route that
looked guarded served every request; they are now enforced, and a deployment with no authorization
provider fails closed rather than open. Alongside it, three classes of refusal that a caller
caused — an unsupported query shape, a raw query on an adapter that cannot run one, an RBAC guard
with no rbac arm — answered a masked 500 Internal Server Error with the useful sentence
reachable only in the log; they now answer 501 Not Implemented and say what is wrong.
The in-memory broker stops deadlocking startup. A plugin that awaited its own publish during
register() hung with no boot, no error and no log; publish now resolves on dispatch hand-off,
the guarantee the real brokers always gave, and the behaviour chain’s delivery gate is bounded by
chainReadyTimeoutMs. Non-HTTP ingress also gains the tenant entry point 0.3.0’s notes advertised
and the shipped contract could not express: getRepositoryFor(tenantId, entity).
Six breaking changes ride with it, every one carrying migration text in Changed. Four are
behavioural: two refusals move from a masked 500 to 501, a restricted route starts answering
401/403/501 where it used to serve, and InMemoryBroker.publish resolves earlier — so a test
asserting the old status, or one that awaited publish and then read a handler side-effect, needs
updating. Two are API changes, reaching implementors of IMultiTenancyService and callers
constructing UnsupportedRawQueryError directly. Under 0.x a minor bump is where breaking changes
live — see Versioning.
Added
-
@setu-ts/common—IMultiTenancyService.getRepositoryFor(tenantId, entity), the ctx-free tenant-scoped repository entry point, modelled on the interface’s other ctx-free, id-taking memberprefixCacheKey. It exists because0.3.0’s release notes advertised a tenant concern as expressible once per ingress kind, and measured against the shipped contract it was not: both tenant-bearing members take anIRequestContext, which no ingress path has. The concern became expressible in THIS release, not0.3.0. An ingress behaviour reads the tenant id from the work item’s own payload (IngressContext.payload) and scopes through the ctx-free member; the id is trusted input, sogetRepository(ctx, …)stays the HTTP-path member. -
@setu-ts/messaging-plugin— the bounded chain-gate wait.MessagingCommonOptionsgainschainReadyTimeoutMs(default10_000;0waits forever; ignored when nobehaviorsfactory is configured, because the gate is then never armed; finite, non-negative values up to2_147_483_647): a dispatch held on the gate rejects after the bound with the new exportedChainGateTimeoutError, whose message namesregister()as the likely cause. A REJECTED gate still refuses delivery forever, as before. Also exported:InMemoryBrokerOptions, whose optionalonDispatchError(error, metadata)is the terminus of the in-memory broker’s handler-failure path (the plugin always supplies one backed by the application’s logger, read at call time; the option is reachable only by constructing the broker directly). -
@setu-ts/common— the HTTP status hint, a symbol-keyed brand through which a package that may not import@setu-ts/exceptions(AI_GUIDELINES §2.2) states how its own error should be answered:HTTP_STATUS_HINT,HttpStatusHint,withHttpStatusHint(error, hint)andhttpStatusHintOf(error). It exists because M70f’srespondWithErrorseam cannot reach every site — a data source throwing from deep inside an adapter holds noIRequestContext— so its error arrived aterrorHandleras a plainError, was normalized to500, and was masked.HttpStatusHintis anErrorResponseInitwhosedetailis required, anderrorHandlerbuilds the response from the hint’sstatus/title/detailand never from the error’smessage. Both entry points construct through one implementation, so a hinted throw and arespondWithErrorcall carrying the same values answer byte-identically under every configured format. A hinted error is exempt frommaskInternalErrors— narrow by construction rather than by trust, since what it serves is a fixed sentence the brand site wrote, so there is no driver diagnostic in the body for masking to remove. A brand on a deliberately thrownHttpErroris ignored; that error already states its own status.Symbol.for, so two copies ofcommonin one process resolve the same key.HttpStatusHint.statusmust be an integer in400–599; a hint outside that range reads as absent and the error takes the ordinary masked-500path. An unserveable status would otherwise make the webResponseconstructor throw out of the error handler itself, and a hint says how an ERROR is answered, which is never a success or a redirect. -
@setu-ts/common—ResponseSnapshotInit, and the optionalResponseSnapshot.responseInitmember that carries it (M88). Terminaljson/text/html/byte/redirect responses now defer building the frameworkHeadersobject until something actually reads the documented livesnapshot.headersview; when the kernel never needed a mutable instance it attaches this header input instead, and the shared runtime mapper hands it straight to the nativeResponseconstructor. Explicit and repeated header writes keep the existing path.The member is
@internal— a kernel-to-runtime protocol, not application surface. Middleware and handler code continue to readsnapshot.headers, whose behaviour is unchanged. The initializer is a fresh mutable copy of an immutable internal source, so a native server adding a derived header (@hono/node-servercomputingContent-Length) cannot contaminate another response; a real Node adapter regression test pins that.Source-compatible in both directions: the member is optional, so an existing
ResponseSnapshotproducer or consumer is unaffected. Measurement, stated narrowly: focused response conversion measured 35.7% lower median cost, and five alternating Node-adapter loopback samples measured a +4.9% median with material local variance. Neither is a production-throughput claim.
Changed
-
BREAKING (behaviour) —
InMemoryBroker.publishresolves on dispatch hand-off, not on handler completion (X16-1). The documented contract said it resolves “when all handlers have been invoked”; it actually resolved when every handler had RETURNED, and combined with the behaviour chain’s delivery gate — which opens at the end ofonInit— a plugin that published (and awaited the publish) during its ownregister()deadlocked startup silently: no boot, no error, no log. Measured on 2026-09-03, the blast radius was the in-memory broker alone; rabbitmq and redis-streams already returned before delivery and delivered through the complete chain.publishnow resolves once each matching subscription’s work item has been handed to dispatch, the guarantee real brokers always gave. Every invoked handler’s promise is RETAINED: a rejection reaches the broker’s failure path — on in-memory, a logger report via the plugin-suppliedonDispatchError— never the caller, and never an unhandled rejection. One slow or throwing fan-out handler also no longer delays or aborts delivery to its siblings.Migration: code that awaited
publishand then asserted a handler side-effect without awaiting anything else needs one moreawait(a completion deferred the handler exposes, or a polling wait). A handler rejection no longer propagates toawait publish; branch on the handler’s own error path or observe the logger report instead. -
BREAKING (API) —
IMultiTenancyServicegains the REQUIREDgetRepositoryFor(X16-2). Out-of- repo implementors of the interface must add it; the framework’s ownMultiTenancyServiceis the only in-repo implementor. Required rather than optional because an optional member returningundefinedcannot distinguish “no such tenant” from “this implementation does not offer the read”.tenantByIdwas deliberately NOT added: nothing in the committed surface can implement it —MultiTenancyServiceholds a CRUD store and no tenant catalog — and inventing one is its own design.Migration: add one method to any custom
IMultiTenancyServiceimplementation:getRepositoryFor(tenantId, entity)returns anITenantRepositoryscoped to the id GIVEN (new TenantRepository(store, tenantId, entity)is the shipped shape). Callers change nothing except where they previously could not do this at all. -
BREAKING (behaviour) —
@Roles/@Permissionsare now ENFORCED.DecoratorPluginappends enforcing authorization middleware to every route carrying a restriction: it resolvesCAPABILITIES.AUTHORIZATIONper request and answers401without a principal and403on a failed check, byte-identical with the equivalent@UseGuards(requireRole(...))spelling. When no authorization provider is registered the route FAILS CLOSED — it answers501 Not Implementedand never serves unguarded — andregister()warns once per affected route. A route decorated@Roles/@Permissionsthat serves today will start refusing, which is the point: an unenforced authorization decoration is the defect — but it will break tests that were passing for the wrong reason.Migration: register an authorization provider under
CAPABILITIES.AUTHORIZATION(e.g.AuthPlugin({ jwt: { secret }, rbac: { roles } })) so the declared restrictions are evaluated, or passDecoratorPlugin({ enforceRoles: false })to restore the previous behaviour (metadata recorded, nothing enforced, no warning). A route carrying both decorators now requires any of the roles AND any of the permissions; method-level declarations override class-level ones. -
BREAKING (behaviour) — a database query refusal answers
501 Not Implemented, not a masked500(X19-1).UnsupportedQueryFeatureError,UnsupportedFilterOperatorErrorandUnsupportedRawQueryErrornow carry the status hint above, so a DynamoDB non-keyorderByor a Bigtableoffsetreaches the client as501with a sentence naming the feature and the adapter, instead of{"title":"Internal Server Error","detail":"Internal Server Error"}with the useful message reachable only in the log. This is the shape a developer meets when SWITCHING BACKENDS: an application that worked on MongoDB answered500on every ordered endpoint under DynamoDB, and the response said the server was broken.Not every
UnsupportedQueryFeatureErroris branded. The class is shared by caller-caused query shapes and by configuration refusals, so only an allowlist offeaturevalues is answered501;mapping,endpoint,date-encodingandtransactionkeep the masked500, and an unclassified value is not branded. Branding it unconditionally made a blankcolumnFamilyanswer every request501 "Query feature 'mapping' is not supported …".The served
detailis composed from this package’s own structured fields (feature,operator,connector,adapter) and never from the error’smessage, which stays the full diagnostic and reaches the log alone. The four transaction and concurrency errors keep their masked500: they may legitimately quote backend state, and a concurrency conflict is transient rather than permanent.Migration: a client or test asserting
500on a refused query now sees501. Branch on the exported error classes withinstanceofrather than on the status where you need the distinction. -
BREAKING (behaviour) — RBAC guards without an
rbacarm answer501, not a masked500(X18-2).AuthPluginOptions.rbacbecame optional in M68; the four authorization guards (requireRole,requirePermission,requireAnyRole,requireAllPermissions) then resolvedCAPABILITIES.AUTHORIZATIONunconditionally, so the registry’s throw escaped into the pipeline and a principal that genuinely HELD the required role was refused with a masked500 Internal Server Error— a real fault with/health,/readyand/liveall reportingup. They now consultservices.hasand short-circuit with501 Not Implemented/Authorization is not configured.501rather than403because nothing is wrong with the caller: the deployment cannot evaluate the policy at all, and the condition is permanent for that deployment. A principal that genuinely fails a policy check keeps its403— that path is unchanged — and the guards still fail closed either way.Migration: supply the
rbacarm to make the guards evaluate. A test asserting500for this composition now sees501. -
BREAKING (API) —
UnsupportedRawQueryErrortakes the adapter name first. The constructor is now(adapter: string, message: string)and the class carries areadonly adapter, matching the structured fields its two sibling refusals already had. Without it the serveddetailcould not name which backend refused, leaving the one refusal in the family less actionable than the others.Migration:
new UnsupportedRawQueryError(message)becomesnew UnsupportedRawQueryError('<adapter>', message). Every in-repo call site is updated; the class is exported, so an application constructing it directly gets a compile error.
Fixed
-
@setu-ts/common/@setu-ts/exceptions— an unserveable status no longer makes the error path itself the fault.respondWithErrorandcreateErrorResponder’s responder passedErrorResponseInit.statusstraight toresponse.status(...). The webResponseconstructor throwsRangeErroroutside[200, 599]and for a non-integer, andTypeErrorfor a body on one of the null-body statuses204/205/304— so such a value replaced the error response the caller asked for with an unhandled exception on the real serve path. The null-body three are in range and so survive a range check; every path through this seam writes a body, sinceErrorResponseInit.titleis required.This was reachable from application code through published options, not only from a framework literal:
FlagGuardOptions.statusCode, the multi-tenancyrejectionStatus, and aWebSocketGuardDecision.statusare all authored by the application, so4004— a plausible typo for404— crashed every request to that route. A new exportedresolveResponseStatus(status, target)clamps an unserveable status to500and reports it through the logger capability when the target carries one;Number.isIntegeris checked before the range comparisons, becauseNaNsatisfies neither<nor>. Both entry points sanitize before building their response, so the formatted body’s status member and the written status cannot disagree.A THIRD door was found while fixing the first two and is closed with them:
HttpError’s constructor validates nothing — its own JSDoc says the factory functions are what guarantee a correct status — sothrow new HttpError(4004, '...')reachedresponse.status()and threwRangeErrorout of the onecatchthat exists to contain throws.errorHandlernow clamps before it logs and before it builds the body, so the logged status, the body’s status member and the written status are one number; the thrower’s message survives, since only the number was wrong.No behaviour changes for a serveable status.
inject()could not observe any of this — it builds no nativeResponse— so the regression tests driveapp.fetch. -
@setu-ts/runtime— a null-body status written with a body no longer kills the request.new Response(body, { status })throwsTypeError: Response with null body status cannot have bodyfor204,205and304, and the shared snapshot mapper passed the body through regardless. The throw happened inside the HTTP adapter, after the pipeline had finished, so no middleware and noerrorHandlercould catch it: the request died with an unhandled exception rather than a response.Reachable from ordinary handler code, and not only as a typo —
status(204).send(new Uint8Array(0))andstatus(204).text('')carry a body that is present but EMPTY, and attaching a confirmation body to the204that aDELETEidiomatically returns is an easy mistake to make. Such a body is invalid — a204carries no content, and a client cannot read one — so it is discarded; return200when a confirmation representation is actually needed. Onlysend()with no argument worked. Present since the M23 fetch model; unrelated to M88, which changed the header source and left body handling byte-identical.The body is now dropped rather than the throw propagated — what Express and Fastify both do, and what RFC 9110 §15.3.5 implies, since a
204has no content. Headers are preserved (RFC 9110 permits representation metadata on a204), and a dropped stream body iscancel()led rather than discarded, so its source is released instead of leaking. -
setu addrefuses extra arguments instead of silently discarding them. The contract is singular;setu add auth cacheused to add one package, reportupdated deno.jsonand exit 0 with the rest uninspected. A second positional is now a usage error naming the count (exit 2, nothing written) — the same by-name refusal every other misapplied input to the CLI gets. -
MultiTenancyPluginwarns atregister()when a non-columnisolation strategy is selected with nodataStore.'schema-per-tenant'/'database-per-tenant'NAME the isolation a store is expected to implement; the shippedMemoryTenantDataStoreonly partitions by the strategy’s label, no shipped adapter is told the strategy, and the README no longer claims physical schemas or databases are created. The headline example moves to'column-per-tenant'. -
MultiTenancyPluginwarns atregister()when the resolved resolver chain contains aJwtResolver. Tenant identity comes from an UNVERIFIED token claim — a client can mint a token naming any tenant — acceptable only alongside authentication middleware which separately verifies the token. The README and thejwtoption’s JSDoc now say so where the choice is made. -
@Public’s documentation matches its behaviour. It contributessecurity: []only for a route without an enforced@Roles/@Permissionsrestriction. It does NOT bypass authentication or authorization; a restricted route keeps its derived OpenAPI security requirement.
0.3.0 — 2026-09-03
The request path gets roughly 2.4x faster, and non-HTTP ingress gains the behaviour pipeline HTTP
already had. Bare /json on Node went from 41,437 to ~100,000 rps — from roughly 34% to ~90% of
the bare Hono router this framework is built on — and Deno from 62,637 to 160,130. Alongside it,
queue jobs, scheduler ticks, broker messages and WebSocket frames can be registered declaratively
and wrapped in a behaviour chain, so an authorization, tenant or audit concern is expressed once per
ingress kind instead of hand-rolled inside every handler.
Two breaking changes ride with it, both narrow and both carrying migration text in Changed: the
IHttpAdapter signatures widen to sync-or-async, which reaches out-of-repo adapter implementors and
no ordinary caller, and the Node adapter now lets @hono/node-server install its own
Request/Response globals. Deno, Bun and Workers are untouched by the second. Under this
project’s versioning a 0.x minor is what carries breaking changes, which
is why this is 0.3.0 rather than a patch.
Changed
-
BREAKING —
IHttpAdapteraccepts and returns sync-or-async.setHandlernow takes(request: IRequest) => IResponse | Promise<IResponse>andfetchreturnsResponse | Promise<Response>. Callers are unaffected —awaitworks on both — but an out-of-repo adapter implementing the narrowerPromise-only shape must widen it, because the handler’s return type sits in a contravariant position. TypeScript’s bivariant method parameters mean an existing implementation still compiles; the break is narrow and shows up when a handler that answers synchronously is passed to it.Migration: widen the signatures and branch on promise-ness rather than awaiting:
import { isPromiseLike } from '@setu-ts/common'; setHandler(handler: (request: IRequest) => IResponse | Promise<IResponse>): void { … } const result = this.handler(frameworkRequest); return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);Use
isPromiseLike, notinstanceof Promise: the handler’s declaredPromise<IResponse>is satisfied structurally, so a cross-realm or userland promise answersfalsetoinstanceofand would be treated as an already-settledIResponse.Promise.resolvereturns a native promise unchanged and adopts a foreign thenable. -
BREAKING (behaviour, Node only) —
@hono/node-servernow installs its ownRequest/Responseas globals. The Node adapter previously passedoverrideGlobalObjects: false. That flag decided which class backsnew Response(...), and node-server serves a response through its synchronous fast path only when the object carries that class’s internal cache symbol — so opting out put every response on the slow path. It is node-server’s own default and what every Hono-on-Node deployment already runs, and its classes are web-standard compatible. Applications that depend onglobalThis.Responsebeing undici’s specific implementation on Node are affected; nothing else is. Deno, Bun and Workers are untouched. -
Route registration refuses nothing new, but matching got faster and
paramsmay now be a shared frozen object. A route with no path parameters receives one shared, frozenparamsrecord rather than a fresh{}. Reading it is unchanged; mutating it — which was never supported, sinceIRequestContext.paramsisReadonly— now throws in strict mode instead of silently succeeding on a throwaway object. -
@setu-ts/cqrs-plugin: internal composer promotion. Its private composer is removed in favor of@setu-ts/common’s shared composer. CQRS behavior and public surface are unchanged.
Added
-
isPromiseLike(value)in@setu-ts/common. Reports whether a value is thenable by the duck-typed test rather thaninstanceof Promise, which answersfalsefor a promise from another realm and for userland promise libraries — values that satisfyRouteHandler’s declaredPromise<HandlerResult>structurally, so TypeScript accepts them. The kernel and all four HTTP adapters use it to decide whether a handler result needed awaiting; without it the synchronous request path treats such a value as already-settled and sends the response while the handler is still running. -
@setu-ts/common: ingress behaviour contract. AddsIngressKind, immutableIngressContext,IIngressBehavior, structuralBehaviorLike, and the sharedcomposeBehaviorChain; also addsWebSocketUpgradeGuardandWebSocketGuardDecisionfor route-scoped upgrade decisions. -
@setu-ts/websocket-plugin: declarative routes and frame behaviours. Addsroutes,behaviors, and per-route upgrade guards. When a behaviour chain is configured, frame dispatch is promise-mediated; without behaviours it remains the original direct synchronous invoke. -
@setu-ts/queue-plugin: declarative processors and ingress behaviours. Addsprocessorsandbehaviors; behaviour errors preserve existing retry,onFailed, and final dead-letter handling. -
@setu-ts/scheduler-plugin: declarative jobs and ingress behaviours. Addsjobsandbehaviors; the chain runs inside the distributed lock, so a replica that loses the lock runs neither behaviours nor the handler. -
@setu-ts/messaging-plugin: declarative subscriptions and ingress behaviours. Addssubscriptionsandbehaviorson every broker arm. The chain wraps subscribe handlers only;respond()remains unwrapped because its handler returns a result.
Performance
The request path was rebuilt around two principles: allocate nothing a route did not ask for, and
never enter the microtask queue when the answer is already known. Measured with bombardier on Node
(-c 64, medians of interleaved runs), bare /json went from 41,437 to ~100,000 rps — from
roughly 34% to ~90% of the bare Hono router the framework is built on, and from ~65% to
130–160% of NestJS-Express. With three middleware registered — the shape any real application
has — Setu-TS runs at 132% of Hono and 113% of raw Express, because Hono’s compose()
awaits every stage and loses 40.6% of its throughput to three middleware where this pipeline loses
17.9%.
Every change below except the global-objects one is runtime-agnostic, so Deno and Bun benefit too.
Measured the same way, Deno goes from 62,637 to 160,130 rps — 2.56x, and from 33% to ~85%
of Hono on Deno. Bun runs at ~77% of Hono on Bun. Neither needs the global-objects change and
neither receives it: @hono/node-server exists to translate between Node’s IncomingMessage/
ServerResponse and web Request/Response, and its fast path is a shortcut through that
translation. Deno.serve and Bun.serve take a real Response natively, so there is no shim to
bypass and no lightweight class to install.
- Request bodies are read lazily and memoized. The mapping called
arrayBuffer()on every request, including bodyless GETs; under node-server that forces the full undiciRequestthe lightweight facade exists to avoid. AGET/HEADcarrying neithercontent-lengthnortransfer-encodingnow resolves to a shared empty buffer without touching the native request. A side effect worth naming: an ordinary WebSocket upgrade now reaches the handshake with its rawRequestundisturbed, which is the hazard M46 had to intercept inside the adapter to avoid. - Cold request-context members are lazy.
id,services,state,queryandsignalmaterialize on first access;RequestContextis a class, so every request shares one hidden class. - The framework’s own layers no longer force
async.Application.#handleRequestand all four adapters’ fetch handlers return synchronously when nothing needed awaiting, as does Bun’sBun.servecallback — that one is a separate entry point from the fetch handler, and leaving itasyncwould have lost on the socket path exactly what this won on the fetch path. Lifecycle hooks and global middleware still take the unchanged async path. - Route matching answers a single candidate without allocating. The
RouteEntryrides on the handler the router already returns, replacing a${method} ${path}key and aMaplookup;decodeURIComponentis skipped for values containing no%, where it is an identity function. - One URL parse per request instead of two, and often zero. Route dispatch reuses the path the
mapping already resolved, and that mapping extracts it by slicing rather than constructing a
URL, falling back toURLon the only inputs it rewrites — dot-segments and backslashes — so the result is identical for every input. Routing semantics are unchanged;/foo/../adminstill resolves to/admin.
Fixed
-
Two committed comments described behaviour the code did not have. The request
Headerscopy was documented as providing “immutability” whennew Headers()is mutable — it normalizes writability, since a server-receivedRequestthrows onheaders.seton Deno while Node and Bun permit it. And the upgrade-body refusal claimed the mapping “has already disturbed it viaarrayBuffer()”, which the lazy body makes false. -
When a
behaviorsarm carries a factory, the queue, scheduler, and messaging plugins now hold dispatch untilonInithas resolved the whole chain. Without the gate a handler could run against an INCOMPLETE chain: a broker holding a backlog delivers the moment a consumer attaches, so a message reached the handler having run only the instance behaviours, silently skipping every behaviour that needed a resolved capability — an authorization, tenant, or audit behaviour. Queue and scheduler share the shape through the poll loop and the job timer. The gate covers imperative registrations too, including a subscription a LATER plugin makes through the resolved capability in its ownregister(), which no amount of deferral inside these plugins could reach. It is released once and costs nothing thereafter, and no registration’s timing changes. The WebSocket arm needs no gate: a frame cannot arrive before its socket is open, which is afteronInit. -
@setu-ts/kernel:onClosehooks now ALL run even when an earlier one rejects, with the failures surfaced afterwards (a lone failure rethrown as itself, several as anAggregateError). A close hook releases one plugin’s resources, and aborting the loop at the first failure meant one plugin that could not disconnect kept every later plugin from releasing anything — the M50onStoppingdefect in a second place, reachable fromstop()and, since this release, from a failedstart().LifecycleManager.runShutdownstill aborts at the first failing hook and is deliberately unchanged here: it has no call site this milestone adds. -
QueuePlugin({ processors })registers in declared order when the array contains a factory.process()is last-wins on a job name, and registering every instance before every factory made[factoryA, instanceB]on one name resolve tofactoryA— the reverse of the declared array, so a processor the developer had replaced kept running the jobs. -
Ingress behaviour promise and ordering guarantees.
next()now always returns a promise, including when a downstream behaviour throws synchronously, so an outer behaviour can recover withnext().catch(...). Queue, scheduler, messaging, and WebSocket plugins also now run mixed instance and factory entries in the exact configured order. A failed factory resolution closes resources registered before startup failed.
0.2.0 — 2026-09-02
The version scheme drops the alpha label, and four NoSQL backends land. Every release up to
v0.1.0-alpha.10 carried a prerelease suffix. That suffix cost real ergonomics — JSR never tags a
prerelease as latest, so every install line in every document had to be pinned by hand — while
communicating nothing that 0.x does not already communicate on its own. Under semver, major
version zero is the “anything may change” signal, and it is the one consumers’ tooling already
understands.
So the label is gone and the promise is unchanged. The public API is still not stable, breaking
changes still ship in minor bumps, and 1.0 remains gated on Milestone 40 — benchmarks, a security
audit, and the Node/Bun compatibility suites as release gates. What changes is that a caret range
now works the way readers expect: ^0.2.0 resolves, absorbs patches, and stops before the next
breaking release. See Versioning.
This release is not a no-op. It carries every change listed below, including breaking ones — read the Changed section before upgrading.
Added
-
Cloud Bigtable backend.
@setu-ts/database-plugingains atype: 'bigtable'arm serving Google Cloud Bigtable overnpm:@google-cloud/bigtable@^6, withBigtableAdapterexported for the'custom'arm — the wide-column half of the gap the document-store backends only half closed. Bigtable inverts the DynamoDB problem: its row key is one lexicographically-sorted string, sofindByIdneeds no key object, while there is no secondary index of any kind. The per-entitytablesmapping composes that row key from several logical fields (rowKey: { fields, separator?, prefix? }), names each field’sfamily:qualifier, and chooses a value encoding —'tagged'by default, so a number, boolean,null,Dateor object round-trips as itself, with an untagged cell decoding as its raw string so a table written outside the framework stays readable, and'raw'for one that is entirely foreign. Key fields are written as cells AND recovered from the row key, with the cells winning: a Bigtable row cannot exist with zero cells, and the row key records no type.Exactly three narrowings reach the server — the row set (an
eqon the whole key is an exact key, aneqpinning a prefix is a prefix range, a pinned prefix plus aninon the final field is a key list), byte-exact value equality for each conjunctive non-keyeq, and the column projection. Everything else is evaluated by the same evaluator the memory adapter uses as the portable reference, and a push-down may only ever match a superset of what that evaluator keeps. A value test is an exact byte RANGE and never the SDK’s string form, which is a regex — measured,{ value: 'a.*b' }matched botha.*bandaxxb— and it is wrapped in aconditionfilter, because a bare chain strips every non-matching cell and the row would come back carrying only the cell that matched. The projection is interleaved with a one-cell arm for the same class of reason: a filter that removes every cell removes the ROW, so a bare projection would silently drop a row carrying none of the projected columns. Row keys are compared as UTF-8 bytes — that is, by CODE POINT — because JavaScript’s<compares UTF-16 code units and the two disagree for every non-BMP character, which dropped an emoji-keyed row from a cursor walk.orderByis honoured only as the full key ascending, or empty; a non-key sort, a strict key prefix and descending are refused by name, the last because the emulator this adapter is tested against silently ignoresreversed: true(measured: it answered ascending with no error), so the path could not be verified. A non-zerooffsetis refused too — Bigtable has none — andrawQueryis refused because Bigtable’s data plane is ReadRows, MutateRow and CheckAndMutateRow with no SQL surface behindquery(sql, params).Pagination is a start-key cursor over the portable keyset codec, which is Bigtable’s own continuation mechanism;
nextCursoris non-nulliff the page is non-terminal, so a page bounded bymaxPageFetchesreturns zero rows AND a cursor. Transactions are one row, because that is Bigtable’s only atomicity unit: writes are buffered, a second row key is refused at that write with the newBigtableTransactionScopeError, and commit sends one CheckAndMutateRow whose mutation list applies atomically and in order.createandupdateare conditional writes rather than blind ones — Bigtable’sinsertis an upsert — so the match flag is what makescreaterefuse an existing row andupdaterefuse an absent one, inside a transaction as well.connect()issues no RPC: a missing table already answers5 NOT_FOUNDquoting the resource path, whilegetTables()is a table-admin call a data-plane account commonly cannot make, so probing would refuse a working configuration; configuration is validated at construction instead. The arm is verified against the realcbtemulatorin a suite guarded onBIGTABLE_EMULATOR_ENDPOINT, and unlike the Cosmos one that suite is run by CI. Nocommonchange and no new capability token: a composed row key shares no type with DynamoDB’s partition/sort key pair.A key field’s type is not part of the row key — the key is bytes and a numeric field renders as its decimal text, so
1and'1'are one physical row andfindById('1')answers the row stored under1, whoseidcell still decodes as the number. Tagging the key would make it unreadable incbtand break every table this adapter did not write, so the mapping does not: choose one type per key field, and zero-pad a numeric one whose lexicographic order matters. -
Azure Cosmos DB backend.
@setu-ts/database-plugingains atype: 'cosmos'arm serving Cosmos DB’s NoSQL (SQL) API overnpm:@azure/cosmos@^4, withCosmosAdapterexported for the'custom'arm. EveryNormalizedQuerymember is translated natively; nothing is filtered, sorted or paginated in JavaScript. The per-entitycontainersmapping names the container, the primary key and the partition key, and an omitted partition key is DISCOVERED from the container definition — a wrong one answers 404 rather than an error, so a declared path is validated against the container and refused by name when the two disagree. Pagination uses the framework’s portable keyset cursor rather than a Cosmos continuation token: measured against the emulator, anORDER BYquery returns none even whenmaxItemCountis supplied as a query option, and paging by a stable sort is this adapter’s design choice rather than a claim about every Cosmos deployment; on a real account each paged container therefore needs a composite index over(sort field, id). Transactions are a deferred batch, atomic within one container and one partition-key value and capped at 100 operations, withCosmosTransactionScopeErrorraised at the write that crosses a bound; a bufferedupdateis sent as a patch while its payload fits one patch request, so two such updates of one row compose, while a wider update falls back to a whole-document replace and a second write to that row is then refused; androllback()is idempotent so a failed commit still reports its own per-operation diagnostic.rawQueryis refused by name, because a Cosmos query is scoped to a container the signature cannot name.Cosmos DB’s MongoDB API needs no new arm: it speaks the MongoDB wire protocol, so the existing
'mongodb'arm serves it. That route is documented and unverified against a live account, because the emulator’s MongoDB endpoint tops out at API version 4.0 (wire version 7) while thenpm:mongodb@^6driver this package pins requires wire version 8 — measured, the endpoint handshakes and then refuses on that floor. A live account offers versions above it. The NoSQL arm is verified against the realazure-cosmos-emulator:vnext-preview, in a suite guarded onCOSMOS_ENDPOINT; it is not run by CI, whose reason and run instructions are in the package README. -
@setu-ts/sdkrealtime clients.createSseClientconsumes authenticated SSE streams with frame parsing,Last-Event-IDresumption, bounded reconnects, and abort teardown;createRealtimeClientfilters and replies to the WebSocket heartbeat protocol, re-joins rooms, and reconnects with bounded backoff.setu generate sseandsetu generate ws-routeprovide matching application-local starting points. The generated React hook is emitted only for a project that already configures both React Router and the SDK.The generated
src/controllers/index.tsbarrel now passes the service registry to each generated registrar, so itsregisterGeneratedRoutestakes an optional second parameter. A project scaffolded before this keeps compiling untouched —generatenever rewritessetu.config.ts— but a generated SSE controller resolvesCAPABILITIES.SSEfrom that registry and throws by name until the call is updated toregisterGeneratedRoutes(app.router, app.services). -
@setu-ts/decorator-pluginnow exports@Module({ controllers, providers, imports })andDecoratorPlugin({ modules }). Activated modules flatten imported modules depth-first, register providers before controllers, deduplicate diamonds and cycles by class identity, and warn when a supplied class lacks module metadata. A service with required constructor parameters now refuses startup unless it declares the explicit@Inject(...)tokens the runtime needs. -
The default
resttemplate now includes a functional greeting controller and service registered through the same seam barrels thatsetu generatemaintains. -
Keyset cursor pagination.
IDataSource.findPage?(query)returns{ rows, nextCursor }, andNormalizedQuery.cursorcarries the incoming position. The member is optional so an out-of-repo adapter keeps compiling; when it is absent the repository refuses by name, so absence means “cannot page by cursor” and never “there are no more rows”. Implemented on all five shipped adapters — Memory, Prisma, Drizzle, Mongo and D1.offsetis untouched and not deprecated; a query carrying both a non-zerooffsetand acursoris refused, because the two express contradictory positions. Note that the repository-facingIRepository.findPageadded in this release is required — see theChangedentry below it. -
@setu-ts/commongainsEntityKey,PageResult,CursorPayload,CursorValue, and the pureencodeCursor/decodeCursor/keysetPredicate/sortFingerprint/mintNextCursorcodec. The codec lives incommonbecausecloudflare-pluginneeds the identical encoding and a plugin may not import another plugin (AI_GUIDELINES §2.2) — one copy, not two. -
The primary-key tiebreaker is a correctness requirement, not a refinement. Measured against live PostgreSQL and MongoDB: over six rows carrying only two distinct sort values, a keyset walk omitting the key tiebreaker returned four of six and reported success.
keysetPredicatealways appends the resolved key columns, and the negative control is committed as a test. -
Composite primary keys per adapter:
D1EntityMapping.primaryKeyandMongoEntityMapping.primaryKeyaccept a column list,MongoEntityMapping.idTypegains'compound'(a subdocument_id, built in the mapping’s declared column order — a Mongo subdocument_idis matched by exact, order-sensitive equality, so the caller’s key-object property order must never reach the driver), andPrismaAdapterOptions.entities/DrizzleAdapterOptions.entitiescarry per-entity key configuration. Prisma’s compound-key field name is derived by joining the key columns with_; a model declaring a named@@idmust setcompositeKeyName, because Prisma rejects the derived name on such a model. -
UnsupportedQueryFeatureErrorin@setu-ts/database-plugin, for a query feature the active adapter cannot serve.cloudflare-plugincontinues to refuse withCloudflareUnsupportedError; no error class is added tocommon, which exports none. -
@setu-ts/openapi-pluginexportsSchemaIoandZodToOpenApi.transformtakes it as an optional second argument (transform(schema, io?), defaulting to'output'), so an existing single-argument call is unchanged. -
Added a native MongoDB backend to
@setu-ts/database-plugin.DatabasePlugin({ type: 'mongodb', options: { url } })now provides repositories over the lazy-loaded MongoDB driver, including_idmapping, ObjectId-aware injected clients, query translation, and session transactions.MongoAdapterOptionsis a union of two arms, so a registration supplying neitherurlnorclientis a compile error rather than aconnect()throw;MongoAdapterOptionsBasenames the half both arms share. The'mongodb'member of the publishedPrismaSqlProviderunion is retained but documented as unreachable on Prisma v7 — the adapter arm is the supported MongoDB route. Identity round-trips: anObjectIdis rendered as its 24-hex string while a JSON scalar keeps its own type,primaryKeymay name_iditself, and the primary key never travels in an update payload (MongoDB refuses a$setthat would change_id). An empty filter group compiles to its boolean identity, matching Memory and Drizzle, because MongoDB refuses$and: []/$or: [].containsis case-sensitive and cannot be made otherwise — MongoDB does not apply collation to$regex. The arm accepts the sharedlogQueriesoption like every other built-in adapter. -
Added a native DynamoDB backend to
@setu-ts/database-plugin.DatabasePlugin({ type: 'dynamodb', options })now provides repositories over AWS SDK v3, including partition-and-sort-key mapping, GSI access-path selection, query translation, and deferred transactions.DynamoAdapterOptionsis a union of two arms — an injectedclientor a lazyendpoint/region/credentialsimport (import('npm:@aws-sdk/client-dynamodb@^3')) — so a registration supplying neither is a compile error rather than a lazy-import throw.createcarriesattribute_not_exists(<pk>)because an unguardedPutItemon an existing key silently overwrites the item and drops every attribute absent from the new one;updatecarriesattribute_exists(<pk>)+ReturnValues: 'ALL_NEW'because an unguardedUpdateItemon a missing key silently creates a ghost item and returns it as though it were an update; both guard failures surface as rejections naming the entity. Non-keyorderByand non-zerooffsetare refused by name withUnsupportedQueryFeatureError— the SDK silently accepts and discards both, so the refusal is a correctness requirement.findPage’snextCursoris non-nulliff the page is non-terminal perLastEvaluatedKey— including a zero-row non-terminal page — and a GSI cursor carries all four table+index key attributes; the fill loop is bounded bymaxPageFetches(default 10), and a bounded return always carries a cursor so it is never mistaken for the last page.dateAttributesdeclares the per-attributeDateencoding ('iso' | 'epochMs'); aDatecomparison against an undeclared attribute is refused by name. Transactions buffer writes and flush one de-duplicatedTransactWriteItemsat commit (≤100 items; a duplicate item key and an over-limit batch are rejected by name);rollbacksends nothing. -
Added the
check:docsexecutable prose-assertion gate. Marked Markdown tables are evaluated in a permission-denied Deno subprocess, including.roorules, so false language-semantics claims fail CI instead of remaining unchecked prose.
Changed
-
The version scheme drops the prerelease suffix:
0.1.0-alpha.10→0.2.0. No code changes with it. Two consequences for consumers: a baredeno add jsr:@setu-ts/kernelnow resolves, because JSR pointslatestat a normal release; and a^0.2.0range behaves conventionally, where^0.1.0never matched any0.1.0-alpha.Nat all. An application pinning an exact alpha version keeps working and is unaffected until it chooses to move. -
Breaking (generated class-based modules):
setu generate modulenow emits<name>.module.ts; the managed aggregate barrel exportsMODULESand config passes it throughDecoratorPlugin({ modules })instead of exporting controller/service arrays. Existing generated module directories are reported when their declaration is missing, rather than silently omitted. The regenerated barrel retains deprecated controller/service arrays so an existing config keeps compiling while it migrates. Add@Module({ controllers: [<Name>Controller], providers: [<Name>Service] })to each legacy directory (or delete and regenerate it), then updatesetu.config.tsto importMODULESand passmodules: [...MODULES]toDecoratorPlugin.
Breaking, portable data-access contract (M79). Three widenings to @setu-ts/common. All three
are source-compatible for callers and breaking for implementors, because each widens a type
in a position an implementor must satisfy.
Three widenings to @setu-ts/common’s data-access contract. All three are source-compatible for
callers and breaking for implementors, because each widens a type in a position an implementor
must satisfy. No application that only calls repositories needs a change; an application that
implements IDataSource or declares a custom repository key type does.
-
IDataSource.findById/update/deletetakeEntityKey, notstring | number. The new arm is a composite record (Readonly<Record<string, string | number>>), so a join table or a tenant-scoped table can finally be addressed through a repository. A parameter is contravariant, so an out-of-repo adapter still declaring the scalar-only form is a compile error.// Before — still valid, nothing to change for a scalar key. findById(id: string | number): Promise<Record<string, unknown> | null>; // After — an implementor must widen the parameter. findById(id: EntityKey): Promise<Record<string, unknown> | null>; -
FilterComparison.fieldacceptsstring | readonly string[]. An array is a nested document path (['address', 'city']); a plainstringis unchanged. Any code that READSfilter.fieldand assumesstringmust handle both. A dotted string is deliberately NOT a path — a column whose name contains a dot keeps its meaning. -
The ordered-comparison arm (
gt/gte/lt/lte) acceptsDate. Required for keyset pagination over a timestamp column, and it makes a portable date-range filter expressible for the first time.IRepository’s key type parameter is now constrained toEntityKey, which is breaking only for a declaration that was never supported at runtime. -
IRepositorygains a requiredfindPagemember (M79, keyset cursor pagination). TheIDataSource.findPage?(query)the Added entry above describes is optional — an out-of-repo adapter keeps compiling and the repository refuses by name when it is absent. That is a different type: the repository-facingIRepository.findPage(options): Promise<Page<Entity>>is required, so a class implementingIRepositorydirectly — without extendingBaseRepository— must now implementfindPage, which is a compile error until it does. This is the same class of change as the requiredIRepository.findOnemember announced in the 0.1.0-alpha.8 release, and it is the member a reader searching the CHANGELOG forfindPagemost needs to find: the siblingfindOneaddition was announced twice, and this one was announced nowhere. The in-repo tripwire ispackages/database-plugin/test/fixtures/repository-implementor.ts, a hand-written implementor that failsdeno checkif a required member is added toIRepositorywithout updating it. See docs/upgrading.md for the reader-side step.
Fixed
-
The Prisma test fixture evaluated
NOTas its inverse.matchesPrismaWheredispatchedANDandORbut notNOT, so{ NOT: { status: 'archived' } }fell through to the compound-key branch and MATCHED the rows it was asked to exclude — onfindMany,update,deleteandcountalike. The array form’s semantics were established by measurement against real Prisma 7.10 on live PostgreSQL rather than read off the grammar:NOT: [A, B]returns only the row matching NEITHER, so the array negates each condition and ANDs them (NOT A AND NOT B) rather than negating their conjunction, which would have returned three of four rows. Fixture-only; nosrcbehaviour changes. -
A memory-adapter transaction did not read back its own mutations to rows it had created.
effectiveRecordsapplied shadows and tombstones while mapping the COMMITTED rows and then appended buffered creates untouched — so a row created and then updated in the same transaction read back with its original values whileupdate()had just returned the new ones, and a created row that was then deleted stayed visible althoughdelete()reportedtrue. Both committed correctly, so the divergence was confined to reads inside the transaction. -
decodeCursoraccepted two further malformed tokens. The scan stopped at the first=and never inspected what followed, sotoken + '=$'decoded to the original payload; and validation ran after-/_were normalised to+//, where a decoded character is indistinguishable from one the caller supplied, so the standard-base64 spelling of a token was accepted as if it were base64url. Validation now runs once, on the raw token, against the base64url alphabet, with padding accepted only as a trailing run of at most two=. -
findPageon the memory adapter ignored the caller’swhere. It applied the cursor predicate andfilterbut notwhere, whilefindAllapplied all three — so a page scoped by tenant, owner or role returned rows outside the caller’s own criteria. Reproduced on the public surface:findPage({ where: { role: 'admin' } })over three rows returned all three. It returns MORE rows than asked for, so no assertion on a row count alone would necessarily have caught it. -
A memory-adapter transaction could hide rows its delete never targeted. The overlay key encoded a scalar as
entity::valueand a composite ascol=valuejoined with|, and both forms collide: string'42'and number42produced the same key although record lookup compares with===, and{ a: 'x|b=y', b: 'z' }produced the same key as{ a: 'x', b: 'y|b=z' }. Deleting one of a colliding pair made BOTH invisible inside the transaction and then committed a delete for only one, so an in-transaction read reported a state that never existed. The key is now type-tagged and length-prefixed — self-delimiting, so no value can forge a component boundary — and canonicalized by the mapping’s declared column order, which is what record lookup uses. The JSDoc claiming the delimiter “cannot appear inside the column values we accept” was simply false for a string. -
encodeCursordid not validate on its public path.mintNextCursorguards its inputs, butencodeCursoris exported and reaches the value encoder directly, so an adapter or application building a cursor by hand bypassed the guard: a non-finite number serialized asnulland was refused only on the NEXT request’s decode, and an invalidDatethrew a bareRangeError: Invalid time valuenaming neither the cursor nor the value. -
decodeCursoraccepted a token with junk appended. The base64url decoder SKIPPED any character outside the alphabet, sotoken + '$'decoded to the same payload astoken— two different strings decoding to one payload, in a function whose input is untrusted. Non-alphabet characters are now refused; trailing=padding is still tolerated, since it is padding rather than data. -
A
--transport grpcworkspace can now add afull-stackmember without disabling form CSRF. The CLI mountsGrpcPluginat/grpc, preserves that direct contribution by registering it aftercreateFullStackAppFromConfig()creates the application and before startup, and emits a form-CSRF exclusion for that protocol prefix only.CsrfFormOptions.excludealso lets an application explicitly exempt exact paths or regular expressions for another separately-mounted non-browser protocol surface. Broker transports remain refused because they need to rewrite the starter-owned messaging configuration, which cannot be expressed as a safe appended plugin. -
Prisma’s
findByIdandupdatethrew synchronously on a malformed composite key, bypassing a caller using.catch(), whiledeleteon the same object rejected correctly. All three now reject. -
A multi-column
orderByfailed outright on Prisma 7, and had since the connector’sfindManypath was written. The adapter emitted one multi-key object ({ a: 'asc', b: 'desc' }), which Prisma 7 rejects withArgument 'orderBy': Invalid value provided. Expected …OrderByWithRelationInput[], provided Object— measured against 7.10 on live PostgreSQL, where a single-key object is accepted and an array of any length is accepted and honours element order as sort precedence. It now always emits an ARRAY of single-key objects, so no sort arity is a special case. Latent until this release, because a two-column sort was the first thing the framework itself produced: the keyset cursor walk appends the primary key as its tiebreaker. -
SqlJsonDialectis exported from@setu-ts/database-plugin.DrizzleAdapterOptions.dialectis typed by it, so without the re-export the option’s own type was unnameable by a consumer — the defect M52c found onNormalizedQueryreaching a publishedDataSourcesignature. -
A nested JSON path filter now translates on Drizzle rather than being refused, per dialect — PostgreSQL
#>>against a text-array path, MySQLJSON_UNQUOTE(JSON_EXTRACT(…)), SQLitejson_extract— with the dialect read off the Drizzle instance and a newdialectoption for when detection cannot name it. Extraction normalises to text on every dialect and casts back to numeric for an ordered comparison against a number, soage > 9matches30instead of comparing'30' > '9'as text;inexpands to anORof equality legs, since no dialect offers a path membership operator. On Prisma the operator names were established the same way:containsmaps tostring_contains(rawcontainsis rejected on a path) andinhas no path form at all. -
A Mongo query naming the primary key discarded every other member of
whereandorderBy. The scalar-key branch REPLACED the whole object with{ _id: … }rather than renaming the column in place, so{ id, status }silently droppedstatusand matched rows it was never asked for, and a sort of{ score: 'asc', id: 'asc' }collapsed to{ _id: 'asc' }. The sort half was reached on EVERY scalar-keyfindPage, because the keyset tiebreaker always appends the key column — so a page was ordered by a column the caller did not ask for and the keyset predicate did not agree with. The rename is now in place. -
Prisma’s scalar key path addressed a hardcoded
id, ignoring a configured single-columnkeyColumns, so a lookup silently queried the wrong column. It now addresses the resolved column; the default['id']keeps the pre-M79 shape byte-identical. -
Prisma’s composite-key refusals named
findByIdregardless of the method that failed. -
Mongo could write a partial compound
_idwhen a key column was absent from the row, storing a document that nofindByIdcould retrieve, since the read path requires every column. -
@setu-ts/openapi-plugindocumented a zod v4 REQUEST body as the shape the server holds after parsing, so a document could contradict the application serving it.ZodToOpenApiconverted every schema withio: 'output'. A field carrying.default('free')was therefore listed in the request body’srequiredwhile the server accepted a body omitting it and supplied the default — reproduced in one running app,201from the route andrequired: ["name","age","plan"]from its own/openapi.json— so a generated client took a required argument for a field the API defaults. A.transform()field documented as{}, silently, because a transform’s OUTPUT has no JSON Schema representation. AndadditionalProperties: falsewas emitted for a plainz.object, which strips an unknown key and answers 2xx rather than rejecting it.Every request-side position —
body,params,query,headers— is now converted from the input side;responsekeeps the output side. Az.strictObjectkeepsadditionalProperties: falseunder either view, so nothing a client may send is widened. This was a consequence of the alpha.10 zod-v4 fix rather than a regression in it: before that, a v4 schema transformed to{}, so there was norequiredlist to be wrong. Zod v3 documents are unchanged — that path has noioconcept and already emitted the input view, which is what makes the two majors agree on a request body now.Two visible consequences, both by design. A request body built from a plain
z.objectno longer carriesadditionalProperties: false. And a schema whose two views differ and that is used on BOTH sides is hoisted into one component per side rather than one shared component; a schema whose views are identical — every zod v3 schema, and any v4 schema with no default, transform, coercion or object-mode difference — still yields exactly one, so most documents are unchanged. A schema registered withaddSchema('Name', …)keeps that name on the output side and gains aNameInputtwin when a request site reaches it — identified by schema rather than by name, so registering an unrelatedAddressInputalongsideAddresscannot capture it.
0.1.0-alpha.10 — 2026-08-28
The decorator surface moves to the TC39 standard, and realtime grows up. Every shipped decorator
was the legacy TypeScript form, which requires the experimentalDecorators compiler option Deno now
deprecates and warns on. Removing that option would not merely untype the parameter decorators — the
Stage 3 proposal has no parameter position at all, so they would stop parsing. The surface is
therefore migrated deliberately now rather than under time pressure later: parameter injection
becomes positional @Params(...), constructor injection collapses onto the class-position
@Inject(...) list, and no compiler option is required anywhere any more. This is the one change in
this release an application must act on, and the migrations are worked through below.
The realtime story closes the two gaps a browser actually hits. A browser can send exactly one
credential over an EventSource request or a WebSocket constructor — a cookie — and neither
built-in strategy read one, so the two transports the framework offers for realtime could not
authenticate a browser at all. AuthPluginOptions.session now reads the session cookie through a
new headers-only session read, and an authenticated upgrade’s principal reaches onOpen. Both
realtime registries also gained a non-mutating read: room(name) and channel(name) are
get-or-create, so a presence endpoint reporting size for a request-supplied name was registering
one entry per distinct name polled.
Traces now cross a broker and nest under the request that caused them. Every first-party messaging
broker writes traceparent on publish and reports the transport headers it read, and telemetry
spans run with their span active rather than arriving as detached roots — which changes exported
trace shape for anyone already exporting.
Nothing else here requires an application change unless it is named Breaking below.
Added
-
Broker trace propagation (M75).
@setu-ts/commonnow provides the W3C trace-context codec and header-name constants. When telemetry is registered, every first-party messaging broker writestraceparenton publish and exposes delivered transport headers; the messaging plugin creates producer/consumer spans around that path.MessagingPlugin({ tracing: false })opts out. -
@setu-ts/common: a headers-only session read and the authenticated-principal bridge for WebSocket upgrades.ISessionService.fromHeaders(headers)opens a session from aHeadersobject alone — the read for non-HTTP entry points (a WebSocketonOpenhandler, an auth strategy reading a cookie) that have no request context to commit onto — and returns a new read-onlySessionView({ id, data }) ornull. It runs the same envelope-open, snapshot-parse, and store-read path as the load behindfrom(ctx), so it inherits real revocation on the store strategy, but it never commits and never writes.WebSocketConnectionContextgains an optionaluser?: IPrincipal— the principal that authenticated the upgrade, omitted when it was anonymous — andIWebSocketService.routeUpgradegains an optional second parameter (principal?: IPrincipal) that carries it. Both additions are optional members, so existing implementors and consumers compile unchanged. -
@setu-ts/auth-plugin: a cookie-backedSessionStrategyand a caller-supplied strategy hatch.AuthPluginOptions.sessiontakes aSessionAuthOptionswhose single required member,toPrincipal(view), maps the openedSessionViewto the principal it carries (returningnullcontinues the chain). When present, the plugin appends an internalSessionStrategy— the strategy that reads the session cookie throughISessionService.fromHeaders— after the API-key strategy, and requires thesessioncapability (SessionPlugin) to be registered, orregister()throws naming both plugins.AuthPluginOptions.strategiesaccepts caller-suppliedIAuthStrategys, appended after every built-in in declaration order; anamecolliding with any other strategy in the assembled chain throws atregister(). The assembled order is fixed — jwt → api-key → session → caller-supplied — and the first non-null principal wins. This closes X3-5: a browser can now authenticate overEventSourceand a WebSocket upgrade, the two transports that can send only a cookie. -
@setu-ts/session-plugin,@setu-ts/kernel, and@setu-ts/websocket-plugin: the bridge wired end to end.SessionServiceimplementsfromHeaders; the kernel’s terminal handler passesctx.request.usertorouteUpgrade; andWebSocketServicethreads that principal intobuildContext, soonOpen’scontext.useris the authenticated peer. No adapter change is needed: since M70arouteUpgradeis the only live upgrade-routing path on every runtime. -
Declared dependency-compatibility ranges, and a reproducibility artifact on every release. The Zod-facing packages now state the ranges they are tested against rather than leaving them to be inferred:
@setu-ts/validation-plugin,@setu-ts/openapi-pluginand@setu-ts/decorator-pluginsupport zod v3 (>=3.24.0 <4) and zod v4 (>=4.4.0 <5), anddeno task check:compatexercises each declared major independently — no package imports zod, so an application may use either major, or both in one process.@setu-ts/database-pluginrecords its tested Drizzle baseline (0.45.2) and its Prisma v7 integration as the current claims, with wider ranges pending the work that would make them tested rather than guessed. Separately, every GitHub Release now carries aresolved-set.jsonasset wrapping the committeddeno.lockunder the release version — exact transitive versions and integrity hashes, so the tagged framework tree rebuilds against the reviewed resolution — and a weekly Dependency drift workflow re-resolves every workspace range into a fresh lockfile, runs the gates against it, and files one issue naming each committed→fresh change. It never modifiesdeno.lockand cannot block a pull request or a release.
Changed
-
Telemetry spans now nest. Real OTel
withSpancallbacks run with their span active using an async-local context manager (unlesscontextPropagation: false), so work initiated in a request becomes a child of its request span rather than a detached root. This changes exported trace shape for every application already usingexporter: 'otlp'or'console': spans that arrived as separate roots now arrive as one tree, so dashboards or alerts keyed on root-span counts will see fewer roots and deeper traces. Nothing needs changing to adopt it; setTelemetryPlugin({ contextPropagation: false })to keep the previous flat shape. Activation needsnpm:@opentelemetry/context-async-hooks; when that package is absent, or the runtime supplies no async-context store, the plugin logs onewarnand degrades to the previous behaviour rather than failing startup. -
traceparentparsing is stricter. The codec promoted to@setu-ts/commonrejects two inputs the telemetry plugin’s private copy accepted: an UPPERCASE-hex header (the previous regex carried theiflag, while W3C Trace Context defines the value as lowercase hex), and an all-zero trace or span id, which is not a valid parent. Both now yield “no extractable parent”, so a request from a non-conformant upstream starts a new trace instead of continuing an invalid one. -
MessageMetadata.headersis now populated by every first-party broker. It was already read by the RabbitMQ and Kafka brokers and omitted entirely by the memory, Redis Streams, Pub/Sub and Service Bus brokers; all seven now report the transport headers they read, and{}— notundefined— when the transport carried none. A consumer that branches on the member’s presence changes behaviour:if (metadata.headers)was falsy on those four brokers and is now truthy. Test emptiness instead (Object.keys(metadata.headers ?? {}).length === 0). -
BREAKING —
@setu-ts/decorator-plugin: the decorator surface moves to TC39 standard decorators, and the legacy form is removed. Every shipped decorator was a legacy TypeScript decorator, which requires theexperimentalDecoratorscompiler option. Deno deprecates that option and warns on everycheckandlintrun, and its removal would not merely untype the parameter decorators — it would make them unparseable, because the Stage 3 proposal has no parameter position at all. The surface is therefore migrated deliberately now rather than under time pressure later.No compiler option is required any more in the framework’s own declaration sites.
experimentalDecoratorsis removed from all eight declaration sites — thedecorator-plugin,openapi-pluginandrest-startermanifests,apps/di-decorators, the guide-snippet fixture, both CLI template stamps, and the generated Nodetsconfig.json. Do not add it back — and remove only that key: Deno applies its own defaults to every option a manifest does not specify, so declaring one option leaves the others alone (measured on Deno 2.9.6). A project needing no compiler option at all should declare none. If your project’s own manifest still declaresexperimentalDecorators— for example adeno.jsonthe alpha.8 CLI emitted — remove that key: a migrated project that keeps it still compiles its decorators under the legacy semantics and failsdeno checkwithTS1238/TS1241on every decorated member, errors that point at the decorators rather than the option. The full reader-side step is in docs/upgrading.md.Parameter decorators become positional sources inside
@Params(...).@Body,@Query,@Param,@Header,@Cookie,@CurrentUserand@Ctxkeep their names but change kind, from parameter decorator to source descriptor. Each stale call site is a compile error rather than a silent behaviour change, because the returned value is no longer a decorator.// Before @Get('/:id') show(@Param('id') id: string, @Ctx() ctx: IRequestContext) {} // After — sources listed in argument order @Get('/:id') @Params(Param('id'), Ctx()) show(id: string, ctx: IRequestContext) {}The declaration is now type-checked against the handler’s own signature, which the legacy form never was: a source whose value type disagrees with the parameter it binds fails
deno check.createParameterDecorator(name, metadata?)is replaced byCustom(name, metadata?), used inside@Params. Resolvers registered withregisterParameterResolverare unchanged.// Before export const TenantId = () => createParameterDecorator('tenant-id'); async list(@TenantId() tenantId: unknown) {} // After export const TenantId = () => Custom<string | undefined>('tenant-id'); @Params(TenantId()) async list(tenantId: string | undefined) {}Constructor injection collapses onto the class-position
@Inject(...)list, which already shipped and is no longer deprecated; the parameter position is removed.@Optional()changes kind the same way, becomingOptional(token)used inside that list.// Before @Injectable() class Repo { constructor( @Inject('database') private db: Db, @Optional() @Inject('cache') private cache?: ICacheStore, ) {} } // After — one entry per constructor argument, in argument order @Injectable() @Inject('database', Optional('cache')) class Repo { constructor( private db: Db, private cache?: ICacheStore, ) {} }Two startup refusals disappear with the form that caused them, because no input can reach them any more: “declares both
@Injectforms” and “parameter N has no@Injecttoken”. TheMetadataStore.mergeCtorParamandMetadataStore.ctorInjectmethods are removed with them — a class-position list is positional and cannot have gaps, soservices.injectis the one place a token list lives. A list shorter than the constructor simply leaves the trailing argumentsundefined.One
IMetadataStoreread narrows. A standard member decorator never receives the constructor, so it records ontocontext.metadataand the store replays those writes when the class is read by target. A carrier holds no reference back to its class, so the target-less reads — thecontrollers/services/routesgetters andgetCustomDecorators()— cannot replay: a class carrying member decorators and NO class decorator is absent from them until something reads it by target, where the legacy form recorded it eagerly. Every class the plugin registers carries@Controlleror@Injectable, and a class decorator flushes eagerly, so this is confined to a class the plugin never registers either.New exports:
Params,Custom,Optional(new kind),ParamSource,SourceValues,InjectToken,OptionalToken, and the three decorator-kind typesSetuClassDecorator,SetuMethodDecorator,SetuClassOrMethodDecorator.Generated projects change shape.
setu generate controllerandsetu generate moduleemit@Params(...)and the class-position@Inject, and no scaffolded project stamps a decorator compiler option. A project generated before this release keeps working only until it is regenerated; migrate its decorated sources with the transformations above.Node is unaffected in requirement but not in reason. A generated Node project still runs through
tsx, because V8 has not shipped decorators — measured on Node v24, bothnodeandnode --experimental-strip-typesanswer a standard decorator withSyntaxError: Invalid or unexpected token. Itstsconfig.jsonno longer sets any decorator option. -
@setu-ts/common:ISessionService.fromHeadersis a REQUIRED member. Callers are unaffected — the addition is source-compatible for every consumer. But an application that implementsISessionServiceitself (not the framework’sSessionService) now fails to type-check until it addsfromHeaders(headers: Headers): Promise<SessionView | null>. It is required rather than optional because a strategy in another package must call it without a capability-shaped guard, and the framework’sSessionServiceis the only in-repo implementor (the M51bIGraphqlService.subscribeprecedent). Migration: add the member to your implementation; the contract is read-only — it must open the session from the supplied headers, returnnullfor every condition the load path treats as “no usable session” (absent cookie, unopenable envelope, unparseable snapshot, absolute expiry or idle timeout passed, or a gone store entry), and never commit, advance theseenstamp, or write. -
A non-mutating read on both realtime registries:
IWebSocketService.peek(name)andISseService.peek(name)(M74, X3-8).room(name)andchannel(name)are get-or-create, so a presence or dashboard endpoint reportingsizefor a name taken from a request registered one entry per distinct name polled — measured at 3 → 53 rooms across 50 read-only requests, with nothing to reclaim them until an unrelated socket disconnected.peekreturns the room or channel when one exists andundefinedotherwise, registering nothing:const present = ws.peek(`board:${boardId}`)?.size ?? 0;This is breaking for anyone implementing either service contract outside this repository:
peekis a required member, not an optional one, because an optionalpeek?returningundefinedcannot distinguish “no such room” from “this implementation does not offer the read”. Add a lookup that does not create; both first-party services delegate to a single map read. Consumers are unaffected. -
ISseService.channelCount(M74) — the counterpart toIWebSocketService.roomCount, reported by thessehealth indicator aschannels. Nothing reclaims a channel, so the value only rises for the life of a running application — a climbing value is the signal that channel names are being derived from unbounded input. Shutdown discards every channel and resets it to zero. Breaking for an out-of-repo implementor ofISseServicein the same waypeekis: add a getter returning the registry size. The health payload gains a field, which is additive for consumers. -
JsonValuein@setu-ts/common(M74) — a recursive JSON-safe value type. Its object arm admitsundefineddeliberately, becauseJSON.stringifydrops such a property rather than failing, so an optional field still assigns. -
SseMessage.datais narrowed toJsonValue(M74, X3-8). Breaking. The member wasstring | number | boolean | null | readonly unknown[] | Record<string, unknown>, whose last two arms admitted valuesJSON.stringifycannot represent — whilePUBLIC_API.mdclaimed the member “accepts any JSON-serializable value”. It now does. What this rejects, and what to do instead:- A
bigintanywhere in the payload ({ balance: 10n },[10n]).JSON.stringifythrows on it, and the throw surfaced differently depending on configuration:conn.sendthrew to the caller,channel.publishwith no backplane delivered to nobody and reported nothing, andchannel.publishwith a backplane threw synchronously. Convert the value first —{ balance: String(balance) }. - A function or symbol value.
JSON.stringifysilently drops the key, so the data never arrived. Remove it from the payload. - An
interfacethat extendsRecord<string, unknown>to satisfy the old object arm. Change it toextends Record<string, JsonValue | undefined>. A namedinterfacestill does not assign without an index signature — TypeScript grants implicit ones only to object-literal types, which was equally true before this change — so atypealias remains the simpler option.
A property written
T | undefinedis unaffected. A circular structure still throws at runtime; no type can express acyclicity. - A
-
@setu-ts/openapi-plugin: an unrepresentable schema node is now REPORTED instead of silently emitting{}. A type zod cannot represent in JSON Schema (z.date(),z.bigint(), …) still degrades to an empty schema — never a throw — but the operation owning it now carries a machine-readable vendor extension naming the route:"x-setu-unrepresentable": [{ "at": "<operationId>", "reason": "…" }]. The extension is absent when every schema is representable, so valid documents are unchanged.
Fixed
-
@setu-ts/messaging-plugin: a transport header whose bytes are not valid UTF-8 is dropped rather than decoded to replacement characters. The normalizer decoded byte values with a lenientTextDecoder, which substitutesU+FFFDfor malformed input — so a subscriber received a string the producer never sent, indistinguishable from a real value, while the module’s own contract says a value with no faithful string form is dropped. Decoding is now fatal and a rejected value is omitted. The throw is caught inside the normalizer, which matters on the Kafka path: an escaping throw insideeachMessageprevents the offset commit and the record is redelivered indefinitely. Valid multi-byte UTF-8 is unaffected. -
@setu-ts/messaging-plugin:MessageMetadata.headerscould carry values that were not strings. The member is declaredReadonly<Record<string, string>>and is documented as populated by every first-party broker, butRabbitMqBrokerandServiceBusBrokerreached it by assertion rather than conversion. An AMQP field table legitimately carries numbers, booleans, timestamps, byte arrays and nested tables, and the Service Bus SDK types application propertiesnumber | boolean | string | Date | null, so a subscriber readingmetadata.headers.xcould get a value of any of those types while the compiler promised a string. Both now normalize at the transport boundary through the same helper Kafka already used: a byte value is decoded as UTF-8, a number or boolean is stringified, aDatebecomes ISO-8601, the first element of a repeated header is taken, and a value with no faithful string form is dropped rather than rendered as[object Object]. Kafka’s private copy of that logic was deleted in favour of the shared helper. -
@setu-ts/messaging-plugin: the Kafka producer put the entire message payload into Kafka transport headers.publishpassed the payload object itself asheaderswhenever it was non-null, so every field of every message was duplicated into the record’s headers — and a non-string field is not a legalIHeadersvalue (Buffer | string | (Buffer | string)[]), so kafkajs had to coerce it. Only framework-owned headers are sent now. A consumer reading payload fields offmetadata.headerswas relying on the defect and must read them off the message. -
@setu-ts/messaging-plugin:metadata.headerson the NATS broker exposed the client’s private internals instead of the message headers. The deliveredMsgHdrswas cast toRecord<string, string>; probed against realnpm:nats@2.x, that yieldsundefinedfor every real key andObject.keysof['_code', '_description', 'headers']. Headers are now read through the object’s ownkeys()/get().NatsOptions.headersFactorysupplies theMsgHdrsconstructor when the connection is injected (a lazily-loaded module provides its own); with neither available the broker publishes without headers and reports it once. -
Three committed docs still said WebSocket upgrades and gRPC requests bypass the middleware pipeline, which M70a made false.
packages/websocket-plugin/README.mdclaimed “the adapter therefore consults the plugin’s upgrade router first”, and theARCHITECTURE.mdpackage-diagram notes said the §10 pipeline “is likewise bypassed for upgrade requests, by design” and that RPC is “intercepted inside the HTTP adapter’sfetchpath”. Since M70a the kernel’s terminal handler decides both, after the pipeline has run and before route matching; the adapter stores the upgrade router without consulting it, andsetRpcHandleris deprecated and consulted by nothing. Each correction now matches the canonical prose inARCHITECTURE.md§10 andPUBLIC_API.md. Both documents were reachable by a reader deciding whether a guard protects a socket, which is exactly the question M70a exists to answer. Two further copies were found in@setu-ts/common’s own JSDoc, which jsr.io renders:IHttpAdapter.setUpgradeRoutersaid the handshake happens “when the pipeline does not short-circuit and route matching returns no match” — the pre-review ordering that M70a’s code review inverted, because a catch-all was shadowing every upgrade — andRpcFetchHandlerstill described the adapter consulting it before mapping the request.ARCHITECTURE.mdalso claimedgrpc-plugindepends on thehttp-adaptercapability; it resolves no adapter at all since M70a. Documentation only — no behaviour, signature or export changed. -
@setu-ts/openapi-pluginproduced an EMPTY OpenAPI schema for every zod v4 schema. Zod v4 removed the private_def.typeNamemarker the transformer dispatched on, so every v4 schema fell through to{}and/openapi.jsonserved{"schema":{}}for any route documented with one. Zod v4 schemas are now converted throughschema.toJSONSchema()(draft 2020-12, adapted to OpenAPI 3.1), with dedup,$ref/componentsextraction and recursive-schema hoisting working on both majors. Both zod v3 and v4 are supported; the plugin imports neither — detection is by duck-typingtoJSONSchema. Zod v3 output is byte-identical.
0.1.0-alpha.9 — 2026-08-26
A security release, and the closeout of the alpha.8 smoke programme. Driving the published packages through twelve exercises — real projects, real brokers, a real cluster — produced a register of verified defects rather than a list of suspicions, and this release closes it. The five that change what an application is exposed to are listed first: no middleware ran before a WebSocket upgrade or a gRPC request, so a guard could not refuse either; a cached response crossed tenants; a session minted under one tenant authenticated a write into another; an unhandled error returned the failing SQL and its bound values to the caller; and feature flags had no tenant dimension at all.
The rest is what the register kept finding once it looked. Capabilities reported up with their
backends stopped, so a dead dependency triggered no restart and no rolling-deploy gate. The default
branch of an injectable seam was the one line no test ever ran, because every test injects — which
is how @setu-ts/grpc-plugin shipped unable to load on Node or Bun, and how the SDK’s own default
transport died on a browser’s first request. Generated artifacts compiled and were wired to nothing.
Database adapters reported success while doing something other than what their contract said.
Two contracts tightened as a result. The kernel’s application registry seals after bootstrap and a
duplicate route is refused rather than silently overwriting — one of the two handlers used to become
permanently unreachable with no diagnostic. And @setu-ts/common gained the optional runtime seams
(onSignal, onExit) that let a generated entry point stop hard-coding the runtime it was
scaffolded for: one main.ts body now serves Deno, Node and Bun byte-identically.
Nothing here requires an application change unless it is named Breaking below.
Security
-
The middleware pipeline now runs before every WebSocket upgrade and gRPC request (M70a).
setUpgradeRouterandsetRpcHandlerwere consulted inside the HTTP adapter before the request was mapped and entered the pipeline, so no middleware applied to either: an unauthenticated WebSocket could write through a guarded endpoint, an unauthenticated gRPC client could read and write through one, metrics and security headers were absent on both, and RPC kept answering200through a shutdown drain while ordinary paths answered503. Every inbound request now runs the pipeline first; the handshake or RPC dispatch happens only in the kernel’s terminal handler, after the pipeline declines to short-circuit, and before route matching so an application catch-all cannot shadow either. No application change is required. Note that an accepted upgrade is answered by the runtime’s own101, which does not carry response headers a middleware wrote onctx.response; the pipeline still runs, so a guard can refuse, and a refused upgrade is an ordinary HTTP response carrying everything. -
A tenant is no longer served another tenant’s cached response (M70b, X4-1).
cacheMiddlewarekeyed onmethod:urlalone, and thecache: { prefix: true }isolation the multi-tenancy plugin advertised was a string no package read — so with one route cached, whichever tenant missed first populated the entry and every other tenant was served its body, withX-Cache: HIT. The key now composes a length-prefixed tenant segment, read from the committedctx.request.tenant, around the default and any customkeyfunction. An application with no tenancy resolves no tenant and keeps byte-identical keys. -
A session is now bound to the tenant it was minted under (M70b, X4-3). Tenancy resolves at priority 40 and the session loads at 260, and neither consulted the other, so an
acmesession authenticated a write intoglobex— CSRF token included, because that token lives in the session that crossed with it. The resolved tenant is now sealed into the session on commit and a mismatch is refused with403before the handler runs (SessionPlugin({ tenantBinding }), defaulttrue). Inert without tenancy: nothing is sealed, so nothing is compared. -
An unhandled error no longer returns the failing SQL and its bound parameter values to the client (M70b, X12-3).
errorHandler— the middleware every CLI template emits — copied a raw error’smessageinto the response, so an ordinary unique-constraint violation disclosed the schema, the table, every column, and every bound value to any caller who could reach the route.maskInternalErrors(new, defaulttrue) replaces that message with the status title; the logger still receives the real error first. See Changed for the compatibility note. -
Feature flags can be scoped to tenants (M70b, X4-6).
FlagContext.tenantIdandFlagDefinition.tenantsadd a tenant restriction evaluated ahead of every other rule, so a user allowlist cannot cross a tenant boundary.createFlagGuardderivestenantIdfromctx.request.tenant. Absenttenants, evaluation is unchanged.
Added
-
setu new --broker <name>and--queue <name>select a standalone project’s message broker and job queue at scaffold time (M72). Values derive from the same transport registry the workspace--transportflag reads; the selected arm rewrites the template’sMessagingPlugin/QueuePluginwiring to an environment read, adds its connection variable to the generated dotenv pair, and emits a broker-onlydocker/compose.yamlso the scaffold can completeapp.start(). Each flag is refused — never silently ignored — wherever it would be a no-op: Cloudflare Workers, starter-composed templates (full-stack; use--template microservicefor a broker), templates registering no matching wiring, unknown or arm-less names, workspaces, andgenerate app. No existing flag’s behaviour changes; a default scaffold is byte-identical. -
setu newcan ask for the choices it already accepts as flags (M72). At an interactive terminal it prompts for runtime, template, broker and queue (standalone) or runtime and transport (workspace); every prompted value is expressible as a flag, so prompts are never a second configuration surface. Non-interactive by construction in three layers:CliDependencies.askis optional and programmatic callers pass none; the executable supplies the terminal prompter only behindDeno.stdin.isTerminal(); Deno’s own prompt returnsnullon a non-terminal.--yes(-y) takes every default and asks nothing. New exports: thePrompterandPromptChoicetypes;createTerminalPrompterstays internal. -
Decorated validation schemas can be enforced without hand-wiring middleware (M70n, E1/E2) — see Changed for the breaking default.
DecoratorPluginOptions.enforceSchemascontrols it, and@Body()/@Query()/@Param()read the VALIDATED value (transforms, defaults and coercions included) via the newvalidatedStateKey(target)helper exported from@setu-ts/common— the cross-package state-key wire format both plugins must agree on byte-for-byte, replacing two hardcoded literals. Presence-tested withctx.state.has, so a validatednullor0is honoured; falls back to the raw source when no validated value exists.@Header/@Cookiedeliberately unchanged (case-insensitive header lookup; no cookies schema key). -
PasswordHasher.verify(stored, secret)throwsMalformedPasswordHashError(M70n, X3-9) whenstoredis not a well-formedpbkdf2$…string, instead of returningfalse. Both parameters are plain strings, so a reversed call used to fail closed silently — every correct password answered401 Invalid credentialswith nothing logged. Exported forinstanceof; a genuinely wrong password still returnsfalse. -
StoredAuditEntryandAuditQueryexported from the audit-plugin barrel (M70n, X4-7). They are the return and parameter types of every exported storage class’squerymember, which were previously unnameable by any consumer (the M52cNormalizedQueryclass). -
SseMessage.dataaccepts any JSON-serializable value (M70n, X3-6):string | number | boolean | null | readonly unknown[] | Record<string, unknown>. The encoder already handled all of them; the type rejected what the implementation accepted, so named interfaces needed casts. -
A process-local notice on
RealtimeBackplanePlugin(M70n, X3-4). Registering bare defaults to the single-process'memory'transport AND silenced the consumers’ startup notices — worse than not registering it. The plugin now logs the notice itself atregister()naming'redis'/'messaging';localNotice: falsesuppresses it. -
ReactRouterPluginOptions.publicFiles(M70n, X5-5), defaulttrue: files Vite copies to the client build root (public/robots.txt,favicon.ico) are served withmust-revalidateinstead of answering an HTML 404 through the SSR catch-all.falserestores prefix-only behaviour. -
FullStackStarterOptions.static(M70n, X5-9): a gated arm registeringStaticPlugin— the browser-serving tier could not compose static files through configuration. Absent, the plugin list is unchanged. -
The OpenAPI document is derived from validation middleware (M70m, X11-5). A route carrying
validateBody(schema)contributed nothing to the document, so the generated client for an API’s only write took no argument and answered400against the live server, and every response was typedvoid.@setu-ts/commongainsVALIDATION_METADATA,RouteValidationMetadata,withValidationMetadataandvalidationMetadataOf— theRouteSecurityMetadatamechanism applied to request shape — every@setu-ts/validation-pluginhelper ANDIValidationService.middleware(...)brand what they validate, and@setu-ts/openapi-pluginreads the brand to fillrequestBodyandparameters. On by default; a declaredschemafield still wins per field, andOpenApiPlugin({ deriveRequestSchemas: false })turns derivation off. That flag disables derivation ONLY — the owner exclusion,operationIdformat and schema deduplication changes below are unconditional, so it does not restore the previous document. See Changed for the compatibility note. Acookiesbrand derives nothing, with cause:RouteSchemahas nocookiesfield and@setu-ts/sdkrefuses anin: 'cookie'parameter, so emitting one would turn a working document into a codegen failure. -
A derived route is documented as answering
400(M70m). The validation middleware genuinely answers it, and an operation carrying no4XXis flagged by every strict linter. Description only, no body schema — that shape depends on the validation plugin’s configurederrorFormat, which the generator cannot see. A route declaring its own400is left alone. -
OpenApiPlugin({ excludeOwners })(M70m, X11-8), defaulting to['health-plugin', 'metrics-plugin']./health,/live,/readyand/metricswere documented by default, so every generated client shipped the application’s operational surface. Exclusion is byRouteInfo.ownerrather than by path because those paths are configuration — a static path list stops working the moment an endpoint is renamed. Pass[]to document them again. See Changed for the compatibility note — a regenerated client losesgetHealthand friends. -
The generated SDK client can be published to JSR (M70m, X11-4).
createApihad an inferred return type, which JSR rejects as a slow type — so a consumer had to hand-edit a file whose own header says “Do not edit manually”, or lose.d.tsgeneration for their whole package. The generator now emitsexport interface Api { … }andcreateApi(client): Api, renameable with the newOpenApiCodegenOptions.apiTypeName. -
Declared error responses are typed in the generated client (M70m, X11-7). An operation declaring a non-2xx response now emits a
status-discriminated union and anis<Operation>Errornarrowing guard, so a document’s 4xx schemas finally type something.HttpClientErroris generic in its body (HttpClientError<TBody = unknown>), which is source-compatible: the bare name still meansHttpClientError<unknown>. -
ZodToOpenApiaccepts an optionalonSchemahook (M70m), consulted at every node it transforms. Additive —new ZodToOpenApi()is unchanged — and it is what lets the document generator deduplicate a schema nested inside another. -
IStorage.puttakes object metadata (M70k, X8-6).put(path, data, options?)accepts a newPutObjectOptionscarryingcontentTypeandmetadata. Optional, so every existing two-argument call is unchanged. Without it every stored object wasapplication/octet-stream, so a presigned URL — the entire point of the feature that produces one — downloaded the object instead of rendering it. S3, GCS, Azure Blob and Cloudflare R2 each record both on the object in their own spelling; the memory and local providers accept and do NOT persist them, documented per provider, because neither backend has anything that could read an attribute back. -
ProcessOptions.onFailed(M70k, X8-4). Invoked once when a job has exhausted its attempts, immediately before it is dead-lettered — the first programmatic notice that work was permanently abandoned.IQueuehas nogetJoband no dead-letter accessor, so before this the only way to find a dead job was to open a Redis client. A callback that throws or rejects is reported and swallowed; the dead-letter still happens. -
Queue metrics (M70k, X8-4). With
@setu-ts/metrics-pluginregistered, the queue publishesqueue_jobs_total{name,outcome}whereoutcomeiscompleted,retriedordead_lettered. Absent the metrics capability no collector is built and behaviour is unchanged. -
Per-name queue depths in the
queuehealth payload (M70k, X8-4).data.queuesreports{ ready, processing, dead }per job name for the memory and Redis adapters — the durable view a per-process counter cannot give after a restart. OMITTED, never reported as zeros, on RabbitMQ and SQS: “this adapter cannot tell you” and “there is nothing there” are different answers. -
QueuePluginOptions.deadLetterTtlMs(M70k, X8-4). Bounds how long a dead-lettered job’s payload is retained (Redis). Without it the jobs hash grows for the lifetime of the deployment; opt-in, because the retention exists for debugging. Configuring it MOVES a dead job’s payload out ofqueue:<name>:jobsintoqueue:<name>:dead:jobs, and only that key and the dead set carry the expiry — the live hash holds every queued job’s payload for that name, so expiring it would destroy work that is merely waiting. Retention is a bound, not a deadline: at least the TTL, and at most the TTL past the last dead-letter, because the sweep runs only when one arrives and the shared key carries a single deadline. It errs late deliberately — dropping a payload early would discard the debugging data the option exists to keep. The payload move is ordered before the dead-set insert so a concurrent dead-letter’s sweep cannot delete a member whose payload has not been written and strand it beyond every later sweep. -
UploadMiddlewareOptions.maxBodyBytes(M70k, X8-3). An explicit ceiling on the request body the upload middleware will parse, default 50 MB. -
IWorkerHandle.onExit?andIWorkerHost.reportsExit?(M70k, X8-7). An optional worker-exit signal, implemented overnode:worker_threads’'exit'on Node and Bun’s non-standard'close', and omitted on Deno, whose webWorkeremits nothing at all when a worker ends itself. Absence means “this runtime cannot tell me a worker died”, never “no worker has died”. -
WorkerExitError(M70k, X8-7), exported from@setu-ts/worker-pool-pluginforinstanceofhandling. Distinct fromWorkerTaskError, which carries an error the worker managed to report. -
QueueDepthsis exported from@setu-ts/queue-plugin: it appears in the public signature ofMemoryQueue.depthsandRedisQueue.depths, and a type a consumer can see but cannot name is a defect (the M52cNormalizedQueryclass). -
Per-adapter option arms on
DatabasePluginOptions(M70j, D7).MemoryDatabaseOptions,PrismaDatabaseOptionsandDrizzleDatabaseOptionsjoin the exportedCustomDatabaseOptions, andBuiltInDatabaseOptionsbecomes the union of the first three, keeping its published name.PrismaAdapterOptionsandDrizzleAdapterOptionsnarrowDatabaseAdapterOptionsfor their arm. See Changed for the compile-time requirement this introduces. -
A request-scoped error responder seam in
@setu-ts/common(M70f).respondWithError(ctx, { status, title, detail?, details? })and theIErrorResponder/ErrorResponseInit/ERROR_RESPONDER_STATE_KEYtypes let a package that produces an error response — but may not import@setu-ts/exceptions, where every formatter lives — answer in the application’s configured format.errorHandlerpublishes a responder (built from the formatter and content type it already resolved at factory time) intoctx.statebeforenext(); every short-circuiting site in the kernel and in the storage, multi-tenancy, session, auth, http-security and feature-flags middleware now delegates to it, so oneerrorHandler({ format: 'rfc9457' })governs every error body those sites produce.validation-pluginis deliberately not part of the seam — it owns its ownerrorFormatoption and formats validation failures itself — so an application configures the two together; the CLI templates andrest-starternow do (C3).@setu-ts/commonalso gainsserializeError/SerializedError, a pure serializer that turns any thrown value into a plain object with a boundedcausechain. -
INotifier.sendSettled?andChannelSendResultin@setu-ts/common(M70f, X8-12). An optional, non-throwing twin ofsendthat reports one{ channel, ok }result per requested channel (a failure carrying its serialized error), so a caller behind a retrying queue can retry the one failing channel instead of re-sending the whole notification.NotificationServiceimplements it;send’sAggregateErrormembers now each name their channel ("channel '<name>' failed"), the original error riding oncause. -
Reachability-aware health signals for six infrastructure plugins (M70c).
messaging-plugin,realtime-backplane-plugin,storage-plugin,mail-plugin,queue-plugin, andservice-discovery-pluginnow report a backend’s reachability, not just its lifecycle. Each provider/broker/adapter gains an optionalisHealthy()probe (aPING, aHEAD, aGetQueueAttributes, Consul’s/v1/status/leader, a Kuberneteslimit=1EndpointSlice LIST), and each plugin’s indicator maps the two signals —isReady()(lifecycle) andisHealthy()(reachability) — to a status. A configured backend that is down is nowdown(ordegradedfor the backplane, whose local delivery still works) instead ofup. An unprobeable provider honestly reportsdata.reachable: 'unknown'rather than claiming health. -
createCachedProbe(options)andCachedProbeOptionsin@setu-ts/common(M70c). A small pure helper that wraps a reachability check in a cache so a health scrape does not become load against the backend: the outcome is cached forttlMs(default 5000) measured on an injected monotonic clock, concurrent callers during one in-flight probe share a single probe call, and each probe is bounded bytimeoutMs(default 2000) on an injected timer seam (setTimer/clearTimer, defaulting to the ambient ones) so a customIRuntimeServices’ timers are honoured rather than bypassed. A probe that rejects, throws, or exceeds its timeout resolvesfalse— the returned function never rejects. Reporting unprobeable is the caller’s job, not the helper’s: a port that implements no probe is what each plugin’s indicator surfaces asdata.reachable: 'unknown'. -
ReconnectSupervisorinmessaging-plugin(M70c). Adrive-mode broker (RabbitMQ — amqplib has no reconnection of its own) now re-establishes its connection on loss through a backoff-driven supervisor instead of failing open, re-asserting the exchange and replaying every active subscription. Retries are unbounded by design, with full-jitter exponential backoff capped at 30s: an outage longer than any attempt cap is exactly the case that must still self-repair, and the health signal reports the fault throughout. A single connection loss opens exactly one attempt loop even when the client reports it through more than one event (amqplib emits'error'and then'close'). The broker exposes areachability()that distinguishes “connected and answering” from “connected but the backend stopped answering”. -
docs/health-indicators.mdandtest/health-indicator-audit.test.ts(M70c). A classification of everyctx.health.registersite in the framework —live-state,justified-literal, orconfiguration-literal— enforced by a test that fails if a new (or moved) indicator is added unclassified. Five out-of-scopeconfiguration-literalsites that hide a real backend are recorded insmoke/DEFECTS.md(H-70c-1…H-70c-5). -
Worker pool metrics (M45b). With
@setu-ts/metrics-pluginregistered,@setu-ts/worker-pool-pluginnow publishes six Prometheus series, all labelledtask_module: gaugesworker_pool_workers,worker_pool_busy_workers,worker_pool_queued_tasks, and countersworker_pool_tasks_completed_total,worker_pool_tasks_failed_totalandworker_pool_tasks_rejected_total(the last two also labelledreason). Pool saturation was previously answerable only by polling/healthand diffing counts by hand. Nothing is configured and nothing changes without the metrics plugin — the instruments exist exactly whenCAPABILITIES.METRICSdoes, there is no new plugin option, and no export was added. The gauges are written from the same snapshot theworker-poolhealth indicator reads, so the two surfaces cannot disagree, and no polling interval is armed...._failed_totalsummed overreasonequals the health payload’sfailed;..._rejected_totalcovers refusals that never became tasks (queue_full/pool_closed/unavailable), which the health payload cannot see at all. -
setu add <plugin>— installs a framework package into the current project, pinned to the version of the CLI that added it, and updatesdeno.jsonandpackage.jsonwhen a project carries both. Accepts a short name, the bare package name, or the full specifier (setu add auth,setu add auth-plugin,setu add @setu-ts/auth-plugin). It writes the manifest and reportsdeno install --min-dep-age 0rather than running it — on release day the pin is younger than Deno’s dependency-age policy, so the flags need to be visible. Both places that used to say “install@setu-ts/auth-plugin” now name this command. -
IRuntimeServices.onSignal?(signal, handler)and theRuntimeSignaltype in@setu-ts/common, implemented by the Node, Deno and Bun runtime adapters. Optional on the establishedfs?/workers?/dns?precedent: it is omitted on Cloudflare Workers (an isolate is evicted, never signalled) and on Deno for Windows (addSignalListener('SIGTERM')throws there), so a caller reads it with?.and gets a correct no-op rather than a crash. -
A generated project’s
main.tsnow installs a graceful-shutdown handler through that seam, and one body serves Deno, Node and Bun byte-identically. It reaches no runtime-specific API at all — noDeno.addSignalListener, noDeno.exit, noprocess.on, noDeno.build.oscheck — so moving a project between runtimes no longer means rewriting its entry point. -
setu generate migrationnow emits a managedsrc/migrations/index.tslisting every migration in filename order and a project-localsrc/migrations/run.tsthat applies them, run withdeno run -A src/migrations/run.ts(add--downto reverse). The migration it wrote was previously an orphan that nothing imported and nothing could run. -
Every template emits a
testtask, so the*.service.test.tsthatsetu generate modulewrites is reachable at all — and on each target it emits a harness that target can actually execute.@std/testing/bddreachesDeno.testinternally, so the test it used to emit everywhere died on Bun withReferenceError: Deno is not definedbefore a single assertion ran. Bun and Node now getbun:testandnode:test, which are built in, so those two targets also stop declaring@std/testing/@std/expect— two dependencies that could only fail there. -
setu generatedetects the target runtime from the project’s own manifests instead of assuming Deno whenever--runtimeis absent, which nobody passes.setu new svc --runtime bunrecords the choice once and every latergeneratenow honours it. -
IRequest.raw?: RequestandIRequestContext.raw?: Request— the undisturbed webRequest, attached by the HTTP adapter beside the mapped request whose body has been buffered. Optional, so existing implementors are unaffected; the kernel treats an absentrawas neither an upgrade nor RPC and falls through to the404. -
UPGRADE_INTENT,WebSocketUpgradeIntent,setUpgradeIntent,upgradeIntentOfincommon— how the kernel tells the adapter to perform a handshake it has already authorized. -
isWebSocketUpgradeRequestincommon— the shared RFC 6455 §4.2.1 predicate.@setu-ts/runtimere-exports it rather than keeping a second copy, so its published surface is unchanged. -
IWebSocketService.routeUpgrade?andIGrpcService.claims?— both optional, so existing implementors keep compiling.claimsis the path guard that keeps an ordinary404intact. -
inject()now populatesIRequest.raw, so an injected request can exercise the upgrade and gRPC paths instead of silently falling through to the404. -
CacheMiddlewareOptions.vary— a per-request discriminator whose values are length-prefixed into the cache key after the tenant segment, for any dimension beyond the tenant. -
MultiTenancyPluginOptions.exclude— paths that skip tenant resolution entirely, defaulting to the operational probes the framework’s own plugins serve (/live,/ready,/health,/metrics,/openapi.json,/docs). Pass[]to exempt nothing. See Changed. -
SessionPluginOptions.tenantBinding(defaulttrue) — seals the resolved tenant into the session and refuses a mismatch with403. -
ErrorHandlerOptions.maskInternalErrors(defaulttrue) — see Security and Changed. -
FlagContext.tenantId(in@setu-ts/common) andFlagDefinition.tenants— both optional, so every existing caller, implementor, and flag definition is source-compatible. -
A factory arm on the four registration seams (M70d).
CqrsPluginOptions.commandHandlers,queryHandlersandbehaviors,EventsPluginOptions.handlers, andHealthPluginOptions.indicatorseach accept, beside the instance, a factory —RegistryFactory<T> = (services: IServiceRegistry) => T(new,@setu-ts/common). A factory is resolved at theonInitphase — the first phase at which the registry holds every capability — and its result is registered exactly as the instance arm would be, so a generated handler or indicator can build a capability (the event bus, a database, the logger) at startup. Instance entries keep theirregister()timing byte-identically, so an existing configuration is unchanged. A factory that throws rejectsstart(), naming the option and the entry, with the original error preserved ascause(throughresolveRegistryEntry, also new in@setu-ts/common). -
DatabaseAdapterOptions.provider, the exportedPrismaSqlProvidertype, andUnsupportedFilterOperatorError— see Changed for thecontainsbehaviour they govern. -
Telemetry auto-instrumentation outcomes are now reported, not silent (M70e). The
instrumentationsregistry reports every outcome through the plugin’s logger (ctx.logger, read at call time): an enabled instrumentation logs one line atdebug, a loader or enable failure one line atwarncarryingkindandreason. A failure remains a documented no-op — it is still never thrown — but it is no longer invisible. The plugin declares the logger capability inoptionalDependencies, so the kernel registers a plugin-provided logger (e.g.LoggerPlugin) before it and the standard configuration (RuntimePlugin+LoggerPlugin+TelemetryPlugin) reports every outcome; without any logger plugin the app still boots and the outcomes are recorded on the registry handle with nothing emitted. The five instrumentation loaders’ default importers now take zero arguments (the specifier is a literal inside the default), so the default path is the one a published artifact can actually run. -
A recurrence gate for computed
import()specifiers (M70e).scripts/npm-specifier-audit.tswalks everypackages/*/srcfile and refuses any dynamicimport()whose first argument is not a string literal, unless the call carries acomputed-specifier: <reason>marker (an empty reason is rejected).test/npm-specifier-gate.test.tsruns it on every suite run, anddeno task release:verifyenforces it as check 6, so the X7-3 shape (a specifier routed through a variable that JSR’s static npm-compat rewrite cannot reach) cannot re-enter the source tree. -
Scheduler multi-instance deduplication (M70l, X10-2). A
cronoreveryfire claims a slot lock keyed on the fire’s intended time (scheduler:job:<name>:<slot>) which is never released and expires onttlMs, so replicas sharing one lock backend run an intended fire exactly once between them — a guarantee that holds only whilettlMsexceeds the maximum skew between replicas, since the TTL is how long a claimed slot stays remembered. Adelayjob differs: its slot is keyed on the job name (scheduler:job:<name>:once), claimed at REGISTRATION rather than at fire time, and released when the entry leaves the registry, so re-registering the name after it fired fires again. The existing per-handler mutex is kept unchanged for overlap protection.MemoryLocknow sweeps every expired entry onacquire, because a recurring slot key is never released and never reacquired, so the previous lazy per-key delete could never reclaim it and the map grew one entry per job per fire, forever. -
MetricsPluginOptions.excludePaths(M70l, X10-7). Request paths the HTTP metrics middleware skips entirely. When supplied it REPLACES the default['/health', '/live', '/ready']; the plugin’s own scrape endpoint is always excluded either way, so/metricsno longer counts its own scrapes or the health probes. -
Generated Kubernetes members carry the chart’s graceful-shutdown pieces (M70l, X10-4/X10-6).
setu generate appnow emitslifecycle.preStop.sleep.seconds: 5(Kubernetes 1.30+) on every container, andprometheus.io/scrape|port|pathannotations on members generated with a template (recorded via the newmetricsEndpointfield ofsetu.workspace.json; absent means unknown and emits none). -
SchedulerUnavailableError(M70l, X9-2).SchedulerPlugin.register()refuses to register on Cloudflare Workers, where its timers cannot fire, namingWorkersCronand[triggers] cronsas the replacement.
Changed
-
Breaking:
ctx.statekeys now use the owner-prefixed convention (M71). Migrate literal readers from'clientIp'to'http-security-plugin:client-ip','__he_telemetry_span'to'telemetry-plugin:span','validated:<target>'to'validation-plugin:validated-<target>','setu.error.responder'to'exceptions:error-responder', and'setu-ts:session'to'session-plugin:session'. PreferCLIENT_IP_STATE_KEY,TELEMETRY_SPAN_KEY,validatedStateKey, andERROR_RESPONDER_STATE_KEYwhere those exports are available;SESSION_STATE_KEYis exported from its own module but is not a public package export, so read a session throughgetSession(ctx)rather than the key. -
Breaking: application-scoped registry mutation now stops after bootstrap (M71). Plugins that retain a context and call
register,registerFactory, orunregisterafterrunBootstrap()now receive an error. Register duringregister(),onInit, oronBootstrap; use the request-scopedctx.servicesin middleware for per-request services. -
Breaking: request identity fields reject a second implicit assignment (M71).
request.userandrequest.tenantpermit one assignment and then throw; usereplacePrincipalorreplaceTenantfor a deliberate replacement. This is an accidental-overwrite guard, not an authorization boundary: a write before authentication remains a permitted first write. -
BREAKING:
@ValidateBody(schema)(and@ValidateQuery/@ValidateParams) now enforce their schema by default (M70n, E1). The decorators shipped as inert metadata — a decorated route accepted any body — so an application upgrading that carries a decorated route whose body its schema rejects starts answering400with no configuration change. Enforcement appends the validation capability’s middleware LAST in the route’s chain (innermost, after guards), preserving guard401/403precedence; with no validation provider registered the schema stays description-only and ONE warning per route names the controller, handler, targets andValidationPlugin. Migration: passDecoratorPlugin({ enforceSchemas: false })to keep the previous description-only behaviour. -
BREAKING: the default session cookie is renamed
hono_session→setu_session(M70n, X9-10). The framework is not Hono, and the rename only gets more expensive with every release that ships it. In-flight sessions are invalidated at deploy: every user is logged out once. Migration: pinSessionPlugin({ cookie: { name: 'hono_session' } })for onemaxAgewindow to preserve live sessions, then drop the pin. -
BREAKING:
IResponsegains a REQUIREDhtml(body)member (M70n, X4-11), implemented in the kernel’sResponseBuilderandtesting’sMockResponse. It setscontent-type: text/html; charset=utf-8— the charset is not optional, because a baretext/htmllets a browser sniff the encoding. Migration: an out-of-repoIResponseimplementor adds the member; every caller-side use is source-compatible. -
BREAKING: the
cacheControlcallback receives a leading-slash path (M70n, C5/D8). The value was slash-less for a file (assets/app.js) but the literal'/'at the prefix root, while the docs said only “root-relative” — a callback written against one observed shape was silently wrong for the other. Both shapes are now normalised to a leading slash (/assets/app-A9acsx54.js,'/'at the root) and documented as such. Migration: a callback comparing against a slash-less form (e.g.path.startsWith('assets/')) changes to/assets/; one matching on extensions or the root literal is unaffected. -
WorkersCron.dispatchnow rejects on failure instead of always resolving (M70l, X9-5). Breaking. A firing trigger with no registered handler throws naming the expression; one or more rejecting handlers run to settlement — a failing handler still never abandons the others — and thendispatchthrows anAggregateErrorcarrying every failure.createScheduledHandleris a bare delegation, so the rejection reaches Cloudflare and counts the whole invocation as failed: that is the signal, and it needs no logger configuration. A configured logger still receives both reports first. An application that wants the old fire-and-forget behaviour wraps its own handlers intry. -
The generated Cloudflare Workers
fetchexport no longer propagates a failed boot (M70l, X9-8). A boot error (a mistyped binding, a broker briefly down at cold start) is now logged to the platform’s console and answered with a generic503 Service Unavailable— the stack never reaches the client. The failed boot promise is cleared, so the next request re-attempts the boot rather than being pinned to the failure for the isolate’s life. An application that wants the error body can wrap its own handler aroundapp.fetch. -
The generated Dockerfile folds
chown -Rinto the dependency-cache layer (M70l, X10-5). A standalonechown -Rrewrites metadata on every file the cache layer created, so overlayfs copied the ENTIRE Deno module cache into a second layer — measured at 563 MB vs 362 MB with the fold, paid on every push and every node pull. Image contents are unchanged; only the layer layout differs, so existing deployments need no action beyond the next rebuild. -
everyjobs arm on an absolute epoch grid (M70l, X10-2). The first fire lands on(floor(now / interval) + 1) * intervalrather than a full interval after registration, at registration, re-arm, and resume alike. The period is unchanged; only the phase moves, and the fire is never LATER than before — it may come sooner than one full interval after registration, so a job assuming “I have been up a full interval” behaves differently. This is what makes replicas started at different instants agree on distributed-lock slot keys. Breaking (resume contract): the released contract stated that resuming aneveryjob restarts the interval “from now”; resuming now lands the next fire on the next epoch grid boundary instead. The implementation has always resumed on the grid (that is the alignment replicas need to agree on slot keys), so the contract andPUBLIC_API.mdare corrected to match — a resumed fire may come sooner than one full interval afterresume()(never later). -
RabbitMqBrokerdeclares its queues with the shape their subscription implies (M70l, X10-1). Breaking. A caller-supplied queue name (a consumer group) is declared{ durable: true }— it survives a broker restart, which is whatqueuedocuments; the private per-subscriber queue and the RPC reply inbox are declared{ exclusive: true, autoDelete: true }. The previous unconditional{ durable: false }named non-exclusive form is refused by RabbitMQ 4 outright (541 INTERNAL-ERROR … transient_nonexcl_queues). CI’s RabbitMQ service moves to major version 4 with this change.Migration — deployments upgrading against an EXISTING broker. A named consumer-group queue that the old client created as
{ durable: false }cannot be re-declared{ durable: true }: RabbitMQ answers406 PRECONDITION_FAILEDand closes the channel, so every subscriber on that queue stops until the queue is redeclared. To restore service, delete the existing non-durable queue (drain it first if in-flight messages must be preserved — a non-durable queue’s contents are lost on broker restart anyway) and let the new client re-declare it as durable:rabbitmqadmin delete queue name=<consumer-group-queue>Deployments whose brokers are provisioned fresh (or whose queues were already declared durable) see no change. Private per-subscriber queues and the RPC reply inbox are exclusive/auto-delete and are recreated per connection; they never carry this problem.
-
Reused OpenAPI schemas are deduplicated symmetrically, and named from their first use (M70m, X11-6). The first use of a reused schema was inlined and never rewritten, so one shape appeared both inline and as a
$refin one document, under the meaningless nameSchema1; and a schema nested inside another was not counted at all, so two structurally identical cases behaved differently. Every site of a reused schema now carries a$ref, nested schemas are deduplicated too, and a hoisted component is named from the site that first reached it (GetOrdersByIdResponse404). A generated client’s component type names change accordingly — regenerate it. A reused primitive stays inline: a$refto{"type":"string"}is larger than the schema it replaces. -
operationIdno longer carries path braces (M70m, X11-8).GET /orders/{id}derivedget-orders-{id}, which Redocly’s recommended ruleset flags as URL-unsafe and which breaks any tool that puts anoperationIdin an anchor, a filename or a URL. It is nowget-orders-by-id. This renames generated client methods —getOrdersIdbecomesgetOrdersById— so regenerate clients and update call sites. Nothing else reads anoperationId. -
Breaking (behaviour): an operation’s
requestBodyandparametersare now derived from its validation middleware (M70m, X11-5). Derivation is ON by default, so the document emitted for any application whose routes carryvalidateBody/validateQuery/validateParams/validateHeaderschanges without a configuration change, and a derived route also gains a400. Regenerate any committed client.OpenApiPlugin({ deriveRequestSchemas: false })restores the previous request shape — and nothing else; see the two entries below. -
Breaking (behaviour): operational routes are no longer documented (M70m, X11-8).
/health,/live,/readyand/metricsare excluded by default via the newexcludeOwners. A regenerated client therefore NO LONGER declaresgetHealth,getLive,getReadyorgetMetrics, so any call site using one stops compiling — that is the intended correction, since those operations were the application’s operational surface rather than its API. PassexcludeOwners: []to document them again. -
Breaking (behaviour): CORS no longer refuses
content-typewhile advertisingPOST(M70m, X11-3).CorsOptions.allowedHeadersdefaulted to[]whilemethodsdefaulted to every standard verb, so a preflight offeredPOST/PUT/PATCH/DELETEand then carried noAccess-Control-Allow-Headers— every browser blocked every JSON request made against the configuration the README itself showed. Omitting the option now ECHOES the preflight’sAccess-Control-Request-Headersfor an allowed origin and addsVary: Access-Control-Request-Headers. An explicit list still allows only those headers, and an explicit[]still allows none, so any application that configured the option is unaffected. The origin allowlist — the actual security boundary — is unchanged, and a denied origin echoes nothing. -
Generated SDK client source is
deno fmt- anddeno lint-clean (M70m, X11-9). It emitted 4-space indentation against the 2-space config every scaffolded project ships, left nested inline object types unindented, and opened with a blanketdeno-lint-ignore-filecarrying no rule list — so codegen output could not be committed to a scaffolded project without formatting a file whose header forbids editing it. Output is now 2-space and depth-indented, signatures past 100 columns wrap one parameter per line, a path template too long for one line is emitted as an equivalent[…].join(''), an empty-object schema emitsRecord<PropertyKey, never>rather than theban-types-rejected{}, and no pragma is emitted at all. The two committed generator-output fixtures were removed fromdeno.json’sfmt.exclude, so the repository’s ownfmtandlintgates now enforce this permanently. Every multi-line type is also hoisted into an exported alias rather than written inline at a use site — an inline (non-$ref) request body, parameter or success response was emitted at whatever indentation its use site sat at, and a success type is written at TWO indentation levels at once, so no single indent could be correct. Both original fixtures name every schema through$refand so could not show it, while a schema derived from validation middleware and used once arrives inline; a third fixture generated from an inline-schema document now covers the case. -
GrpcPlugin’sbasePathnow defaults to/(the root), and native gRPC-binary requests are refused withUNIMPLEMENTED(M70i, X7-2 / X7-4). The old default'/grpc'was unreachable by every native client — a gRPC path comes from the method name alone, and no client has a prefix option. RPC detection stays segment-aware, so ordinary routes like/grpcfooare untouched.Migration — applications that relied on the default. If you pointed clients at
http://host:3000/grpc/<service>/<method>, either passbasePath: '/grpc'explicitly or point the client athttp://host:3000/<service>/<method>. Applications that already setbasePathexplicitly see no change.Native gRPC-binary requests (
application/grpc,application/grpc+proto,application/grpc+json) are answered with a Trailers-OnlyUNIMPLEMENTED(grpc-status: 12) instead of reaching Connect’s handler, where every call failed with an opaque “missing status” transport error after a successful handshake: no runtime on which the plugin loads exposes the HTTP/2 trailers the native protocol requires. Connect (application/connect+*) and gRPC-Web (application/grpc-web+*) remain fully supported on all runtimes; point native gRPC clients at a gRPC-Web-capable proxy or switch them to Connect. -
graphql-plugin: resolvers can be typed without casts, and the WebSocket context no longer pretends to carry an HTTP request (M70i, X6-4 / X6-6 / X6-7).FieldResolveris generic (FieldResolver<TSource = unknown, TContext = unknown, TArgs = Record<string, unknown>>; existing all-unknownresolvers stay assignable), andDefaultGraphqlContextis typed against@setu-ts/common(services: IServiceRegistry,user?: IPrincipal,tenant?: ITenant). Over HTTP the context is{ services, requestContext, user?, tenant? }; over WebSocket it is{ services, connection }andrequestContextis absent — the upgrade request is closed once the handshake response returns, so a synthesized one would be dead by resolver time. The upgrade request’s headers/query live onconnection.headers/.query.requestContextis therefore typed optional; code that dereferenced it must narrow first.A resolver’s
ctx.servicesis now the live registry on every entry point.execute()andsubscribe()take their request context optionally, and a call passing onlyparamspreviously received{}there — soctx.services.get(...), whichservices: IServiceRegistrynow invites without a cast, threw aTypeErrorthat error masking reported asInternal server error. The fallback is the plugin-level registry instead.APQ refusals now follow the documented media-type watershed from one owner: under
application/jsonan APQ miss answers200withPersistedQueryNotFoundin the body (the one error a client must read and retry); underapplication/graphql-response+jsonit carries the resolver’s own status. -
BREAKING:
StoragePluginOptionsis now a union discriminated onprovider(M70k, X8-11). One unknown key used to make the compiler report EVERY property of the literal asnot assignable to type 'never'while never naming the offending one, because the union’s first member wasMemoryProviderOptions = Record<string, never>— which accepts any object shape while requiring every property to benever. Discriminating the union alone did NOT fix the reporting (measured); removing that member did. Migration:providerstays optional on the memory arm, soStoragePlugin()andStoragePlugin({ provider: 'memory' })are unchanged. Every other arm now requires its ownoptionsand its required fields —bucketfor's3'/'b2'/'gcs',containerNamefor'azure'— which were previously runtime failures.MemoryProviderOptionsis removed; the memory arm takes nooptions, so dropoptions: {}if you passed it. -
IAwsS3Clientis renamedIS3Backend, with the old name kept as a deprecated alias (M70k, X8-10). The old name promised something it never was:@aws-sdk/client-s3’s surface issend(command), so injecting a realS3Clientwas refused withInjected S3 client is missing required methods. What the type declares is the backend surface this package’s own adapter PRODUCES. Migration: rename the import; the shape is unchanged, and the alias keeps existing code compiling in the meantime (AI_GUIDELINES §9.2 — the replacement is a working, identical shape, so a rename does not need to be a compile error). There is still no supported way to configure the underlying SDK client. -
BREAKING:
WebWorkerLikerequiresaddEventListener(M70k, X8-7). A real webWorkeris anEventTarget, and the worker-exit signal needs it. Migration: an injected fake worker must add the method; a realWorkeralready satisfies it. -
Upload size refusals answer
413rather than400(M70k, X8-3). A body over the parse bound and a file overmaxSizeare now413 Request entity too large/413 File too large; a malformed body, a disallowed MIME type and too many files remain400. Telling a client its request was malformed when it was merely large sent it to the wrong fix. -
LocalStorageProvider.connect()proves its root is writable and refuses to start otherwise (M70k, X8-9). It also became a rejection rather than a synchronous throw, so a caller using.catch()on thisPromise-typed method reaches it. Migration: none for a working configuration; a root the process cannot write now fails at startup naming the cause — and, on Deno, naming--allow-write— instead of failing every upload while/healthreportedup. -
BREAKING (types only): a
'prisma'or'drizzle'registration must now name the options its adapter cannot run without (M70j, D7).DatabasePluginOptionsis a union discriminated ontype, and its built-in arm made every adapter-specific field optional on a nested bag, so omitting one was a runtime throw atconnect()rather than a compile error — unlike the'custom'arm, which has requiredadaptersince M52c.type: 'prisma'now requiresoptions.prismaClientandtype: 'drizzle'requires bothoptions.drizzleInstanceandoptions.drizzleTables. Only a registration that already failed at startup stops compiling, and the runtime guards are unchanged, so a JavaScript caller sees exactly what it saw before.// Before — compiled, then threw at app.start() DatabasePlugin({ type: 'prisma' }); DatabasePlugin({ type: 'drizzle', options: { drizzleTables: { User: users } }, }); // After DatabasePlugin({ type: 'prisma', options: { prismaClient } }); DatabasePlugin({ type: 'drizzle', options: { drizzleInstance: createDrizzleDatabase(db, (database, work) => database.transaction(work)), drizzleTables: { User: users }, }, }); -
BREAKING (behaviour): the Memory adapter refuses an unknown
selectororderBycolumn (M70j, X12-5). The default adapter used to accept both silently — a projection quietly lost the field and a sort quietly returned rows in insertion order — while Prisma and Drizzle answered500for the identical call, so a rule proved in development became a production failure. A field that no stored row carries is now refused with the entity, the clause and the observed column list, matching Drizzle’s message. A field present on at least one row counts as known, and an entity holding no rows accepts anything.whereandfilterare deliberately unchanged: with no schema this adapter cannot tell an unknown column from one absent everywhere, and matching nothing is a defensible answer. If a projection over a column that has never been written is intentional, write the column (even asnull) on one row, or list only fields the store has seen. Uniqueness and column types remain unenforced and cannot be enforced by a schema-less store — the package README now states that plainly. -
The Drizzle table registry validates
idlazily (M70j, X4-9).connect()refused every registered table without anidcolumn, so a composite-key join or per-tenant table registered only for the typed query builder made the whole registry unusable. The registry now accepts any table definition; a repository for anid-less table is refused by name atgetRepository(), whereIRepository’s single-key contract actually applies. Strictly widening — no configuration that worked before changes. -
Error bodies now answer in the application’s configured format (X4-8, C3) (M70f). Every short-circuiting site — the kernel’s 404/400/503/500 terminals,
createUploadMiddleware’s rejections, the multi-tenancy400, the session tenant-mismatch and form-CSRF403s, the auth guards, the http-security413/403s, and the feature-flag guard — now writes its body through the responder seam, so anerrorHandler({ format: 'rfc9457' })governs them all.Migration — with
errorHandlerregistered (every CLI template and starter). This is the case that changes, and it changes for every one of these sites: they previously bypassed the configured format and answered their ownapplication/jsonbody, and they now answer in it. Underformat: 'rfc9457'a rejection that was{"error":"Too many files","detail":"Maximum 3 file(s) allowed"}(application/json) becomes{"type":"about:blank","title":"Bad Request","status":400,"detail":"Maximum 3 file(s) allowed","instance":"/upload"}(application/problem+json). A client that branched on theerrormember must readdetailinstead — the site-specific label (Too many files,Tenant Required,Tenant Mismatch,Invalid MIME type) is no longer a body member, because RFC 9457 §4.2 requirestitleto be the status title for theabout:blankproblem type. Underformat: 'default'the label is themessagemember and the disclosure isdetails.detail. There is no option that restores the pre-M70f ad-hoc bodies; answering in the configured format is the defect being fixed (X4-8).Migration — with no
errorHandlerregistered. Sites that answered{ error, message }now answer{ error, detail }(the disclosure moves from the non-standardmessagekey to the RFC 9457detailkey), and the feature-flag guard’s baretext/plainNot Foundis now the JSON fallback{"error":"Not Found"}. -
IGrpcService.addService’simplementationparameter is nowunknown(M70f). It wasPartial<ServiceImpl>, an index-signature type that rejects a class instance — whose methods live on the prototype rather than as own properties — although Connect accepts one, so a class-based service implementation could not be passed without a cast. Widening is source-compatible for callers: an implementation that type-checked before still does.ServiceImplis removed from@setu-ts/common— the widening left it with no reader anywhere in the framework, and while the project is in prerelease a dead export is deleted rather than carried as deprecated surface. Migration: drop anyas Partial<ServiceImpl>cast orServiceImplannotation at anaddServicecall site and pass the implementation directly. -
An unhandled request error is now logged by the kernel (X11-2) (M70f). The fallback
500path previously discarded the error and logged nothing even withLoggerPluginregistered; it now reports the message and stack throughCAPABILITIES.LOGGER(guarded so a missing or broken logger degrades silently). The response body stays opaque — the message is not disclosed to the client. -
A raw
Errorin log metadata no longer serializes to{}(X2-5) (M70f). The console and pino loggers normalize anyErrorvalue in merged metadata throughserializeErrorbefore redaction, so any call site is safe on those two loggers, and the two known raw-Errorcall sites (events-plugin’s handler failure anderrorHandler’scause) serialize explicitly, so those stay correct on a third-partyILoggertoo. A third-party logger does not normalize, so a NEW call site handing it a rawErrorcan still render{}— passserializeError(err)there. -
A gRPC handler error is now logged (X7-5) (M70f).
grpc-pluginwraps each application handler so a thrown or rejected error is logged aterrorlevel with the procedure name and a serialized error, then rethrown — the masked wire response is unchanged. The logger is resolved at call time, so a logger registered afterGrpcPluginis still seen.GrpcPluginOptionsgainsinterceptors?: readonly unknown[], threaded intocreateConnectRouter(which previously dropped the argument its own facade declared). -
Breaking (behaviour): a route that names its path now beats a catch-all, in either registration order (M70g, X5-1/F1). The kernel’s tie-break ranked candidates by static-segment count, and a
*segment was COUNTED AS STATIC — soGET /*tied withGET /openapi.jsonand whichever registered first simply won.ReactRouterPluginmounts its SSR catch-all atPLUGIN_PRIORITY.NORMAL(500) whileOpenApiPluginregisters atOPENAPI(700), deliberately last so it can document every route, so every full-stack application silently lost/openapi.jsonand/docsto an SSR 404 page, with no error anywhere; the same shape removed those endpoints under a root-mountedStaticPlugin.*is now its own segment kind and the ranking is: more static segments, then FEWER wildcards, then earliest registration. That also corrects an inversion —/a/*outranked/a/:idin both orders. Migration: an application relying on a catch-all registered first to shadow a later single-segment route now serves the later route. The shadowed route was unreachable by accident rather than by configuration, so the change is toward what the developer wrote; a route that must not be reachable should not be registered. Known limit: the rule compares counts rather than positions, so/a/*loses to/:x/bon/a/b. -
Breaking (message): the duplicate-route refusal now names the plugin that registered the route first (M70g, X5-6/X4-4).
Route 'GET /*' is already registered.becameRoute 'GET /*' is already registered by plugin 'react-router'., or… by the application.for a route the application registered itself. The old message named the pattern and the SECOND claimant, which the stack trace already carried; the missing half was who held it. Migration: a test asserting the exact old string needs the suffix; the message is otherwise unchanged. -
Breaking (behaviour):
setu generateno longer lists a hand-registered module in the generated barrel (M70g, X4-4/F2). The seam scanner admits any file matching a family’s suffix and exports, so a developer’s ownsrc/controllers/admin.routes.ts, wired by hand fromsetu.config.ts, was swept into the CLI-owned barrel by an UNRELATEDsetu generate route report— and since M68 refuses a duplicateMETHOD path, the application stopped booting, with an error naming two files the developer had not touched, from a command that reported success. A candidate whose symbol already appears insetu.config.tsis now left out of the barrel and reported — for a barrel that registers something, which excludes the functionalsrc/services/index.tsre-export, where that symbol is the developer consuming the barrel exactly as its header documents. A file the barrel claims for the first time is reported as adopted, once, at the moment it happens. Migration: none for generated artifacts, whose symbols never appear insetu.config.ts. A project that deliberately wired a conventionally-named module by hand AND relied on the barrel registering it too was already failing to boot. -
Breaking (behaviour):
setu generate health-indicatorrefuses a name an installed plugin claims (M70g, A1).setu g health-indicator databasein a project withDatabasePluginwrote a file, type-checked, and then threwDuplicate health indicator name: "database"atapp.start(). Every plugin that registers an indicator claims a name, and fifteen of them claim exactly the ones reached for first —database,cache,storage,session,events,mail,audit, and more. The command now refuses before writing, naming the plugin. Migration: choose a qualified name (billing-schemarather thandatabase), or remove the plugin. -
Breaking (behaviour): the gRPC health bridge maps
degraded → NOT_SERVING(M70c, X7-8). It previously mappeddegraded → SERVING, so a degraded process answeredSERVINGongrpc.health.v1.Health/Checkwhile the health plugin’s/readyalready returned503— the two health faces of one process disagreed, and gRPC clients kept load-balancing onto a replica HTTP had taken out of rotation. Migration: a client that relied onSERVINGwhile a process is degraded now seesNOT_SERVING; that is the agreement the framework’s own default already produced on the HTTP side.up → SERVINGanddown → NOT_SERVINGare unchanged. -
Breaking (behaviour): a service-discovery indicator reports
down, notup, when its backend was never reached (M70c, X10-3). The old comment claimeddownwas “unreachable by construction”, but a provider that never resolved (e.g. a Kubernetes API whose TLS the runtime rejects) reportedupforever. The indicator now composeseverResolved(set on the first successful read, never by a stale-cache serve) with the provider’s reachability probe: never-resolved-and-unreachable isdown, a stale cache or a just-unreachable warm cache isdegraded, otherwiseup. -
Health indicators are projected to
{ status, data }(M70c, X3-7). An indicator’s undeclared fields were previously echoed verbatim into/health— a returneddetailssat beside the built-ins’data. The health service now projects every result to the documented shape. -
Breaking (behaviour): an unhandled non-
HttpError500 no longer carries its own message.maskInternalErrorsdefaults totrue, so the body’sdetail/messagebecomes the status title. Migration: nothing, if you were reading the message for diagnostics — it is still logged, in full, with its cause chain, exactly as before. SetmaskInternalErrors: falseto restore the previous body verbatim. A deliberately thrownHttpError(a message the developer chose for the caller) and every 4xx are never masked. Where amaskInternalErrors: trueerror would previously have carried astackunderincludeStackTrace: true, it no longer does — a stack begins with the message that was just masked, so masking wins over that option. -
Breaking (behaviour):
containson the Prisma adapter is now connector-aware, and is refused on SQLite. Prisma emits a bareLIKEwith noESCAPEclause, so the value was matched as a pattern: a search for%returned every row in the table and a search for50% offmatched rows that do not contain it. The value is now escaped onpostgresql/postgres,mysql,sqlserverandcockroachdb(whoseLIKEdefaults its escape character to backslash), passed through unchanged onmongodb(whosecontainsis a$regexmatch, where%and_are already literal), and refused onsqlitewithUnsupportedFilterOperatorError, because no escaping is expressible there through Prisma’s filter API. Migration: a Prisma + SQLite application usingcontainsmust switch to a raw query or the Drizzle/Memory adapter — it was returning wrong rows before, silently. If the adapter cannot detect your connector it throws the same error naming the newprovideroption; passprovider: '<connector>'in the adapter options. -
Breaking (cache keys): a tenant-aware application’s cache keys change shape, because the tenant segment is now part of them. The effect is a one-time cold cache on upgrade, not a correctness problem — expect a miss spike rather than investigating one. Keys in an application that resolves no tenant are byte-identical to before.
-
MultiTenancyPlugin({ required: true })no longer rejects the operational probes. It answered400on/live,/ready,/health,/metrics,/openapi.jsonand/docs, which carry no tenant header — so a required-tenant deployment never passed the readiness probe in the Kubernetes manifests the CLI itself generates. Those six paths are now exempt by default. Migration: if your application serves its own business routes on any of those paths and wants them tenant-gated, pass an explicitexcludelist (or[]to exempt nothing). -
Breaking (generated layout):
src/routes/is merged intosrc/controllers/. A scaffolded project had two directories for HTTP endpoints, wired by two mechanisms, andsetu generate routeandsetu generate controllereach owned one — so “where does an endpoint go?” had two answers and the seam scanner could adopt a hand-written file from the other. There is now one directory and one barrel carrying both shapes.To migrate an existing project: move everything under
src/routes/intosrc/controllers/, deletesrc/routes/, update the import insetu.config.tsfrom./src/routes/index.tsto./src/controllers/index.ts, and run anysetu generateto regenerate the barrel. An un-migrated project degrades loudly rather than silently — the scanner reports each file it skipped and why. -
Breaking (generated output):
setu generate module’s barrel re-exports the service module rather than the stub symbol. It emittedexport { listWidget } from './widget.service.ts', so replacing the generated stub — the obvious next step — broke the barrel withTS2305, and the barrel is not reachable fromdeno check main.ts setu.config.ts, so it stayed broken through a full green run of every gate. It is nowexport * from …. The generated test and route module still import the function by name, which is correct — they exercise and serve it. Existing projects need no change; regenerating replaces the named re-export. -
Breaking (runtime host seams):
DenoHost,NodeHost,BunHost,NodeModulesandBunModulesgained required members. These are public type exports whose own JSDoc advertises injection as the supported way to test an adapter, so a consumer holding a hand-written fake now failsdeno check:DenoHostneedsbuild: { os: string }andaddSignalListener,NodeHostandBunHostneedonSignal, and both*Modules.procneedon. Add those members to the fake — the three fixtures in this repository needed exactly that.IRuntimeServices.onSignalitself is optional and source-compatible; only the injectable host seams are affected. -
Breaking (generated output): a generated health indicator’s shape now follows the project’s generator mode. A class-based project keeps today’s class; every other project — which since M65 includes
restandmicroservice— getsexport const <name>Indicator: IHealthIndicator. An indicator generated before this therefore no longer matches what its barrel imports: it is dropped from the barrel, its health check stops running, andsetu generatereports the file by name. Rename its export to the one reported, or delete it and regenerate. (The report used to say “regenerate it”, which could not be followed — the file is not CLI-owned, so the overwrite check refused. It now names both routes out.) -
Breaking (generated output): a CLI-generated registration artifact no longer registers until it exports a factory (M70d). The CLI-owned barrel no longer constructs the artifact with
new X()— it references an exported factory by name, andsetu generatenow emits that factory (create<Pascal>…()) in the artifact file itself. An artifact generated before this change exports no factory, so the seam scanner rejects it: it is left out of the regenerated barrel, not registered, andsetu generatereports the file by name. Migration: add the two-line factory the new schematic emits —export function create<Pascal>…(): <Pascal>… { return new <Pascal>…(); }— or delete the file and re-run the schematic. -
The
class-basedtemplate now emitsDiPlugin({ autoRegister: true })(M70d, E3).autoRegisterdefaults tofalse, and both the container’s external resolver and its registry fallback are gated on it — so a bareDiPlugin()could not resolve any@Inject(CAPABILITIES.X)at startup, and the entire plugin ecosystem was unreachable from a scaffolded class-based service. Migration: none for new projects; a hand-writtensetu.config.tsthat relies on the container NOT falling back to the registry should passautoRegister: falseexplicitly. -
The documented install lines and the generated
installtask pass--min-dep-age 0, matching theminimumDependencyAgethe scaffolded manifest already set. -
A generated
full-stackproject can be started by following its own README: it emitsinstall/buildtasks andstartdepends onbuild, and it declaresnodeModulesDirsocheck:appresolves on a cold checkout. -
The generated full-stack config resolver carries a return-type annotation, which restores excess-property checking. TypeScript does not apply it to an object literal returned from a contextually-typed callback, so a misspelled starter arm previously type-checked, booted, and reported nothing.
-
Cloudflare Workers projects:
deno task startserves instead of binding nothing and exiting0, post-responsewaitUntilwork is no longer dropped, and the emittedwrangler.tomldocuments all the binding types most projects reach for. -
A
--transport rabbitmqworkspace passes its owndeno fmt --check. Generated Markdown prose and plugin arguments are wrapped programmatically rather than hand-wrapped around whichever value the author happened to interpolate. -
Kubernetes probes generated for a workspace member select
httpGetonly when that member’s template actually serves a health endpoint, andtcpSocketotherwise. -
IGrpcService.availableis now unconditionallytrue. It reported whether the HTTP adapter implementedsetRpcHandler; dispatch no longer depends on any adapter capability. -
A non-conformant WebSocket upgrade carrying a body is refused
400by the kernel rather than failing inside the runtime’s own upgrade call with a runtime-specific message. RFC 6455 forbids a body on the handshake. -
WebSocket upgrade detection moved from the HTTP adapter into
WebSocketService’s own router, which is where a routing failure can still be logged. Behaviour on the wire is unchanged. -
Node’s raw
upgradelistener now refuses an unroutable upgrade with the status the pipeline produced (404for a path with no WebSocket route) rather than a fixed400.
Fixed
-
Every doc site showed
authMiddleware()registered at a priority its own architecture table does not grant it (M70n, X3-1).ARCHITECTURE.md§10 reserves 300 for authentication, but a bareapp.middleware.add(authMiddleware())takes the kernel default of 500 — AFTER every band in that table, so a hand-written global guard in any documented band rejected valid credentials while route guards worked. The README, both JSDoc examples andPUBLIC_API.mdnow write the explicit{ priority: 300 }, and §10 states that its priorities are conventional bands, not self-registrations, with 500 outside the table’s range. -
The backplane’s documented partition guarantee was wrong for the first ~11 seconds (M70n, X3-3). ioredis’s default offline queue holds publishes during a short partition and delivers them LATE (measured ~5.9 s) — which an application told “frames during a partition are missed” will not defend against — until the
maxRetriesPerRequestbudget exhausts and drops them with a warn per frame. Both READMEs andPUBLIC_API.mddocument the actual shape; neither ioredis default is reachable through plugin options (inject a client pair to change it). -
The websocket README nominated a cookie as the first credential to verify in
onOpen— and a cookie cannot be verified there (M70n, X3-5).ISessionService.from(ctx)needs the request context an upgrade bypasses, and no open-from-headers seam exists. The README now says what works: a signed query parameter or subprotocol nonce, with the composition gap tracked insmoke/DEFECTS.md. -
room()/channel()are get-or-create, and nothing public said so (M70n, X3-8). A presence endpoint readingsizefor caller-supplied names created one room/channel per distinct name, reclaimed only on some other connection’s disconnect. Both READMEs andPUBLIC_API.mddocument the semantics. -
A hand-written
RouterContextKeyliteral silently returns its default (M70n, X5-7) — keys match by identity and the declaring module reliably exists twice in a framework-mode build. The react-router README now carries thecontextKeyForwarning (and its loader example, which read a nonexistentcontext.services, actually compiles). -
resilience.wrap()rebuilt per request silently disables the circuit breaker (M70n, X7-9). Retry and timeout keep working, so the broken shape looks identical to the working one. The resilience README andPUBLIC_API.mdsay to hoist the wrapped call to module or plugin scope. -
SessionPlugin({ csrf: {} })— the registration every doc shows — could not accept a JSON mutation (M70n, X4-5):csrf.headerNamehad no default, so the token was readable only from a urlencoded body. It defaults to'x-csrf-token'(the name the package’s own JSDoc example already used); an explicit name still wins. -
everyjobs armed their timer for a full interval instead of the delay to the grid boundary (M70l, X10-2).every()computed a grid-alignednextRunAtMsbut armed the timer for the wholeintervalMs, so an off-grid registration ran the job a full interval LATER than the boundary it was aligned to — defeating the grid alignment (and the slot agreement it exists for) until the re-arm after the first fire. The timer is now armed forMath.max(0, nextRunAtMs - now)at registration and on every re-arm, so each fire lands at the boundary it was aligned to. -
delayjobs no longer deduplicated across replicas (M70l, X10-2). The fire slot fordelayentries was keyed onnextRunAtMs, which for a delay isnow + delayMsand therefore carries per-replica startup skew: two replicas registering the same one-shot delay 700 ms apart computed different slot keys and BOTH ran the handler. The slot is now claimed at REGISTRATION, keyed on the job name (skew-independent), and released when the entry leaves the registry — on fire,remove(), or TTL expiry — so a re-registration under the same name after the delay fired gets a fresh slot and still fires (the regression the name-keying previously guarded against stays guarded).cronandeverykeep their fire-time, grid-aligned slot keys, which are skew-independent by construction. -
RabbitMqBrokertreated any user queue namedrr.inbox.*as the private RPC reply inbox (M70l, X10-1). The reply inbox’s transience was detected by pattern-matching queue names against the internalrr.inbox.prefix, butSubscribeOptions.queuehas no reserved-prefix restriction — a legitimate consumer group named e.g.rr.inbox.orderswas declared{ exclusive: true, autoDelete: true }and non-durable, so a second instance’s use of it was refused. The inbox is now marked transient by a flag on the internal subscribe call at the point it is created, and a usersubscribe({ queue: 'rr.inbox.orders' })is declared as a normal durable competing-consumer group queue. -
Every documented example of reading a validated request read the WRONG
ctx.statekey (M70m, found in verification).validateBodyand friends store undervalidated:${target}—validated:body,validated:query, … — whilepackages/validation-plugin/README.md, that package’s module JSDoc (which is what jsr.io renders as its package page),ARCHITECTURE.mdand fivePUBLIC_API.mdexamples all wrotevalidatedBody/validatedQuery/validatedParams. A route following any of them receivedundefinedand answered with an empty body — validation itself worked, so nothing failed loudly. 11 sites corrected; the code’s key is released behaviour and is unchanged. Every test in the package already used the real key, which is exactly why no gate could see it. -
An upload’s
maxSizedid not bound what the middleware parsed (M70k, X8-3). The buffering bound wasMath.max(maxSize * 2, 50 MB)under a comment reading “cap at 50 MB”, which made 50 MB a floor: a 1 KB per-file limit multipart-parsed a 40 MB body before rejecting it, and a 100 MB limit raised the bound to 200 MB and delivered a 60 MB body to the handler unchecked. It is nowmin(maxSize * 2 + framing, maxBodyBytes)— a real cap. Note what this does and does not do: it bounds the PARSE and the per-part copies, not the initial read, because the HTTP adapter buffers the whole body before any middleware runs andIRequestexposes no body stream. -
taskTimeoutMs: 0leaked a pool slot permanently (M70k, X8-7). A worker that ended its own thread raised no host event, so the task timeout was the only thing that ever settled its task — and0, a documented and reasonable choice for long CPU-bound work, removed it. On asize: 1pool one self-terminated worker wedged the pool forever. The pool now settles the task withWorkerExitErrorand frees the slot wherever the runtime reports the exit (Node, Bun). On Deno it cannot, so the pool reportsexitDetection: falsein its health payload and warns once atregister()rather than leaving the developer to discover the wedge. -
A dead-lettered job was invisible through every surface the framework offered (M70k, X8-4). See the three additions above; together they answer “this job is being abandoned”, “how often is this happening”, and “how many are sitting there right now”.
-
The
localstorage provider could not work in a scaffolded project (M70k, X8-9). The generatedstarttask requests--allow-readand not--allow-write, so every upload failed while/healthreportedstorage: up— the M70c liveness probe callsstat, a READ, which the granted permission satisfies. Three defects had to be understood before the one-flag cause was visible. The provider now fails at startup naming the flag, its health probe reflects writability, andsetu add storageprints the note. -
The storage README’s Uploads example was broken three ways (M70k, X8-8) —
maxFileSize(the option ismaxSize, and the compiler’s suggestionmaxFilesmeans something else),file.contentType(the field ismimeType), and agetUploadedFile(ctx, 'avatar')the middleware’s own fieldname filter guaranteed would returnundefined. The same example sat inPUBLIC_API.md. None of it was catchable because the doc-fence gate covered tendocs/guides and no package README; it now covers the three READMEs this milestone rewrote. -
The queue README documented two options that do not exist (M70k) —
regionandqueues. SQS configuration lives undersqs, and RabbitMQ derives its queue names fromprefix. Found by the new README fence gate. -
IDatabaseService.query()was inoperative on the Drizzle adapter (M70j, X12-2). The adapter calledexecute({ sql, params }), a shape no Drizzle driver accepts, so every raw query failed with the internalTypeError: query.getSQL is not a function— a method on the capability’s own interface with no working path on that adapter at all. It now builds a real DrizzleSQLfrom the statement and its parameters: the text is emitted verbatim for an ascending-placeholder statement ($1…on PostgreSQL,?on MySQL and SQLite) and every value is bound, never interpolated. A placeholder count that disagrees withparams, a gap in the$Nsequence, or both placeholder styles in one statement is refused before the driver is reached, because a mis-bound parameter is silent. On PostgreSQL a jsonb?/?|/?&operator is indistinguishable from a placeholder, so such a statement is refused or fails at the database rather than being mis-bound — write it with$Nplaceholders. The defect survived because the unit fake accepted any argument; the proof is now the real Drizzle SQL generator, and that fake refuses a non-SQLWrapperexactly as a driver does. -
logQueries: truesilently dropped a portablecountfilter (M70j). The service’s logging wrapper declaredcount(where)and calledds.count(where), discarding the second parameterIDataSource.count(where, filter?)defines — sorepo.count({ filter })answered a different number with query logging on than with it off. Every existing test built the service without logging, which is why it survived. The wrapper now spreads the data source it wraps before overriding the six required methods, so a member the contract does not require cannot be dropped the same way again. -
X10-3 — a service-discovery indicator that never reached its backend reported
up(M70c). See Changed. -
X7-8 — the gRPC health bridge disagreed with
/readyabout a degraded process (M70c). See Changed. -
X3-7 — a health indicator’s undeclared fields leaked into
/health(M70c). See Changed. -
S3Provideris unusable against MinIO/R2/B2 with a customendpoint(M70c). The client config never setforcePathStyle, so the AWS SDK used virtual-hosted-style addressing (<bucket>.<endpoint>) against the custom host and every request failed (400 MalformedXML onput, 404 NoSuchBucket onget). A customendpointnow forces path-style (<endpoint>/<bucket>/<key>), which is what the documented “R2 and MinIO viaendpoint” promise requires; AWS (noendpoint) is unchanged. -
The Redis backplane reachability probe reported
falseforever against a healthy Redis (M70c).RedisBackplanecapturedclient.pingand called it unbound; ioredis’sping()readsthis.options, so the call threwTypeError: Cannot read properties of undefined (reading 'options'), swallowed intofalse— the indicator reporteddegraded/reachable: falsefor a healthy backplane, inverting the milestone’s purpose. The probe now callsping.call(client)(the patternRedisStreamsBrokeralready used). -
RedisQueue.isHealthy()reportedfalseforever against a healthy Redis (M70c). The same unboundping()defect in the queue adapter’s probe; fixed withping.call(client). -
RabbitMqQueuecrashed the host process on a real backend outage (M70c, surfaced by the §3.7 real-outage suite). Two defects no fake-based test could construct:disconnect()threwIllegalOperationError: Channel closedwhen the channel/connection had already been torn down by the fault (both closes are now best-effort), andconnect()installed the connection’s'error'fault listener only AFTERcreateChannel()— a socket reset during channel creation (the port is open before the AMQP handshake is ready after a restart) was an unhandled'error'event that killed the process. The listener is now installed before the channel is created, and the channel gets its own'error'listener (amqplib emitserroron every channel when the connection resets). -
X11-1 — the SDK’s default
fetchreceiver broke the client in a browser (M70e). The default transport stored the bare globalfetchon a private field and called it asthis.#fetch(...), so in a browser its receiver was theHttpClientinstance and the first request died withTypeError: Illegal invocationbefore any network I/O. The default now resolvesglobalThis.fetchat call time with the global as its receiver, so the default works in a browser unchanged — and aglobalThis.fetchinstalled after construction (a mocking library or polyfill) is honoured rather than shadowed by a value captured at construction. An injectedfetchis always used as-is. No configuration change is required; the fix is in the default. -
X7-3 —
@setu-ts/grpc-plugincould not load on Node or Bun (M70e). The Connect/Protobuf-ES specifiers reachedimport()through a constant map, so JSR’s static npm-compatibility rewrite never saw them and the published artifact shippednpm:verbatim — unresolvable on Node and Bun. The default importer now calls four literalimport('npm:…')expressions (one per module, including the two/protocoland/wktsubpaths), so the rewrite reaches every one. The injectable seam is reshaped rather than removed:loadConnectModulenow takes a partial record of per-module zero-argument importers (Partial<ConnectImporters>) instead of one specifier-takingModuleImporter, so each of the four failure branches is still drivable in isolation. BothModuleImporteranddefaultImporterare gone; neither was exported from the package barrel, so no consumer could reference them. Migration: none — the published package now loads on Node and Bun; the Connect/Protobuf-ES packages must be installed with the host runtime’s package manager (the README names thedeno add/npm i/bun addcommands). -
Telemetry outcome reporting was silent in the standard plugin configuration (M70e). The reporter reads
ctx.loggerat call time, butTelemetryPlugin(priority 30) declared no dependency on the logger, so the kernel registered it beforeLoggerPlugin(priority 100) and every outcome line was dropped — the documented “reported through the plugin’s logger” behaviour was unreachable in the standard configuration.TelemetryPluginnow declaresCAPABILITIES.LOGGERinoptionalDependencies, so the kernel orders the logger provider first; the edge is optional, so an app without a logger plugin still boots with no lines emitted. A kernel-level e2e test with the realLoggerPluginpins both directions. Migration: none — the standard configuration now reports as documented. -
A non-cloneable worker task input no longer kills the host process (M45b, X8-2).
@setu-ts/worker-pool-plugindocuments that task inputs travel by structured clone, so passing a function or a class instance is a documented misuse that should reject the returned promise — and it did, but only when a worker happened to be idle. When the task was queued first, the dispatch ran from inside a workermessagecallback with no promise to reject into, so theDataCloneErrorescaped as an uncaught exception and took the whole process down, losing the results of every other in-flight task with it. A pool ofsize: 1with one task already running is an entirely ordinary state under load.TaskPool.dispatchnow catches, rejects that task alone, and keeps the worker — which never received the message — so the pool carries on and the freed slot takes the next queued task instead of stalling behind the bad one. Both paths now behave identically. No application change is required.
Deprecated
IHttpAdapter.setRpcHandler?— the kernel resolvesIGrpcServicefrom the service registry and dispatches after the pipeline, so nothing calls this. All four first-party adapters accept it as a no-op. To be removed in the next major release.RpcInterceptorStore(@setu-ts/runtime),GrpcUnavailableErrorandGrpcService.createFetchHandler(@setu-ts/grpc-plugin) — all reachable only through the retired pre-pipeline seam. Retained as published surface; nothing throws or installs them.
Known limitations
- On Deno,
WorkerPoolPlugin({ taskTimeoutMs: 0 })disables crash detection for a self-terminated worker (X8-7). Node and Bun report a worker’s exit, so the pool settles the task withWorkerExitErrorand frees the slot regardless of the timeout (see Fixed). Deno emits no event at all for a worker that ends its own thread, soIWorkerHost.reportsExit?is omitted there, the timeout remains the only thing that settles such a task, and0leaves thatrun()unsettled with its slot held — which wedges asize: 1pool permanently. The plugin reportsexitDetection: falsein its health payload and warns once atregister(), but set a timeout on any Deno pool whose task module can callself.close().
0.1.0-alpha.8 — 2026-08-14
A release about what the generator actually produces, and what the database adapters actually
ran. Building a three-service monorepo on the published alpha.7 packages found a scaffold that
could not be installed on release day, could not answer its own /health probe, could not
type-check its own frontend routes, and failed deno fmt --check on 62 of the 74 files the CLI had
just written — every one of them past the gate that type-checks generated output and stops there.
The gate now formats, lints, installs, type-checks and boots each template and requests what the
project advertises.
Generation itself changed shape: decorators are no longer the default. --template rest and
--template microservice register no DecoratorPlugin, setu generate reads the style from the
target project’s own manifest, and the decorated-plus-DI composition moved to one opt-in,
--template class-based (the old --template nest and setu new --di are refused with a message
naming it).
The breaking changes ride together, and every one of them is a compile or startup error rather
than a silent behavior shift: a Drizzle instance must now be wrapped in createDrizzleDatabase(),
a Prisma client must be constructed by the application and injected, the kernel refuses a duplicate
METHOD path instead of letting the second registration overwrite the first, and a class
implementing IRepository without extending BaseRepository must now implement findOne. The
database work is the reason: both ORM adapters shipped paths that could not work against a real
server — Drizzle filtered, sorted and paginated whole tables in JavaScript and addressed writes with
a fabricated column, and Prisma’s lazy client construction passed an option v7 rejects outright.
Both now execute their drivers, and repositories gained a portable filter expression tree plus
findOne so a search no longer has to drop to raw query().
All 47 packages move as one version, because the CLI stamps its own version as the dependency range
for every project it scaffolds. Installs still need an explicit version
(jsr:@setu-ts/kernel@^0.1.0-alpha.8) — JSR does not point latest at a prerelease — and Deno
refuses dependencies younger than 24 hours unless you pass --min-dep-age 0.
Added
-
Typed native Drizzle query access.
getDrizzleDatabase(service, configured)returns the exact configured instance, andgetDrizzleTransaction(uow, configured)returns a derivedDrizzleTransaction<typeof drizzleDb>— Drizzle’s callback-scoped native transaction for anIUnitOfWork. Both preserve the application’s schema inference for joins and aggregations, which the single-entity repository contract cannot express, and the transaction form shares one commit/rollback boundary with repository writes. Both take the opaque configuration built bycreateDrizzleDatabase(see Changed), so the result type is derived from the application’s own database rather than from an unchecked type argument. Promise-aware SQLite Proxy/libsql-shaped instances withoutexecute()now connect for repositories and typed builders; rawquery()alone rejects with a descriptive limitation. The exportedUnitOfWorkconstructor accepts an optional adapter-type third argument while preserving the released two-argument call shape. -
ConfigPluginOptions.envFileOptional. Whentrue, a path inenvFilePaththat does not exist is skipped instead of throwing. The default isfalse, so nothing about an existing application changes. Only ABSENCE is tolerated — a file that exists and cannot be read still throws, which the implementation establishes with astatprobe rather than by interpreting a runtime-specific error code. -
Scaffolded projects generate their configuration.
setu newandsetu generate appemit a gitignored dotenv file beside a tracked<path>.exampleand wireConfigPlugin({ envFilePath, envFileOptional: true });--env-file <path>chooses the path. A template that requires a value names it in both files —full-stackemitsSESSION_SECRET, with a development value in the ignored file and an empty one in the committed example. -
setu workspace ports --reallocatereassigns every member to a currently bindable port and rewrites the manifest, discovery maps, Compose and Kubernetes artifacts together. Workspace creation and member allocation now probe a port before claiming it. -
setu generate app --depends-on <member>, repeatable, records startup prerequisites insetu.workspace.json. The generated rootdevrunner starts prerequisites first and waits for each one’s/readyendpoint before starting its dependents, naming a cycle or a stalled prerequisite and terminating started children on failure. -
RestStarterOptions.serviceDiscovery, inherited by the microservice and full-stack tiers. Supplied → oneServiceDiscoveryPluginis registered; omitted → the plugin list is unchanged. This is what lets afull-stackworkspace member consume the discovery map the CLI already generated for it, through the starter factory rather than a hand-written registration. -
Portable repository filters and
findOne.FindOptionsandCountOptionsgain an optionalfilterexpression tree — comparison leaves (eq,contains,gt,gte,lt,lte,in) composed withand/or— conjoined with the existing equalitywheremap, which is unchanged. Memory, Prisma, Drizzle and D1 each translate it natively rather than filtering in JavaScript, so a search no longer has to drop to rawquery().FilterExpression,FilterComparisonandFilterOperatorare exported from@setu-ts/commonand re-exported from@setu-ts/database-plugin.IRepository.findOne(options?)returns the first match ornullthrough the same one-source path asfindAll. -
RouteInfo.owner.app.router.listRoutes()now reports the name of the plugin whoseregister()created each route, and leaves it absent for a route added directly by application code — so middleware can derive the paths the plugins own instead of hand-listing them. -
AuthPluginOptions.rbacis optional. A JWT-only application suppliesjwtalone; AuthPlugin then registersjwtandauthenticationand neither creates nor advertises theauthorizationcapability. Supplyingrbacis unchanged in every respect. -
@Ctx()for decorated controller handlers. The decorator-plugin now injects the activeIRequestContextinto a decorated handler, so it can configurectx.response(including status and headers) or return a streaming response without leaving the class-based routing API. -
Startup warnings for two silent decorator misconfigurations. When a logger is registered,
DecoratorPlugin.register()now warns about a class listed incontrollersthat carries no@Controllermetadata (which registers no routes, so every path 404s — usually caused by two copies of the package in one process), and about a custom parameter that no registered resolver can satisfy (which reaches the handler asundefined). Both warn rather than throw, so no working application changes behavior.
Changed
-
A Drizzle instance must now be wrapped in
createDrizzleDatabase()before it is injected.DatabaseAdapterOptions.drizzleInstancechanged fromunknownto the opaqueDrizzleDatabaseIdentity, so an unwrapped instance is a compile error, andconnect()also rejects one at runtime naming the requirement. This is breaking for every existingtype: 'drizzle'configuration.Migration — wrap the instance and pass a bridge that calls its own
transaction:DatabasePlugin({ type: 'drizzle', options: { drizzleInstance: createDrizzleDatabase(db, (database, work) => database.transaction(work)), drizzleTables: { User: users }, }, });The bridge is application-supplied rather than inferred because a driver whose
transactioncallback is synchronous (better-sqlite3) commits before any awaited unit-of-work runs, so accepting one would report atomicity the database never provided. Those drivers are refused bycreateDrizzleDatabase’s types, which is why the requirement is a compile error rather than a runtime surprise. -
The kernel refuses a duplicate route instead of silently replacing it. Registering the same method and path twice — from a plugin, a group, or application code — now throws
Route '<METHOD> <path>' is already registered.at registration time. Previously the second registration overwrote the first in the router’s entry map, so one of the two handlers became permanently unreachable with no diagnostic: asetu g route todosin a project that already had a@Controller('/todos')shadowed one of them at random depending on load order.This is a breaking change for an application that registers duplicates today, and it surfaces at
app.start()rather than at the first request. Migration: remove the duplicate registration, or give one of the two routes a distinct path. Distinct methods on one path, and distinct path patterns, are unaffected. -
IRepositorygains a requiredfindOnemember. Classes extendingBaseRepositoryinherit the implementation and need no change. A class implementingIRepositorydirectly — without extendingBaseRepository— must now implementfindOne, which is a compile error until it does.IDataSource.countgained an optional secondfilterparameter, which existing implementations satisfy unchanged. -
containsnow carries an explicitESCAPEclause on the Drizzle adapter. The predicate is built with Drizzle’ssqltag asLIKE ? ESCAPE '\'rather than a barelike(). Escaping the pattern alone relies on the dialect’s default escape character, which PostgreSQL and MySQL define as a backslash but SQLite does not define at all — so on a SQLite-backed instance a search whose value contained%,_or\would have matched nothing.DrizzleOperators(not exported from the package barrel) accordingly requiressqlin place oflike. -
Scaffolded REST-derived templates now answer errors as RFC 9457 Problem Details. The inline
errorHandler()the CLI emits passes{ format: 'rfc9457' }, matching what the starter factories already did.@setu-ts/exceptionsitself is untouched:errorHandler()still defaults to the'default'format. -
The Prisma and Drizzle database adapters now execute their drivers, and both require the application to own the client. Both shipped with paths that could not work against a real server, so each is a breaking configuration change rather than a tuning option.
PrismaAdapterno longer constructs a client. Prisma v7 generates its client into an application-selected output path, which a JSR package cannot locate, and the removed lazyimport('npm:@prisma/client')path passed the legacydatasourcesconstructor option that v7 rejects outright.options.prismaClientis now required and is validated atconnect().Migration: generate the client in the application, construct it, and inject it —
DatabasePlugin({ type: 'prisma', options: { prismaClient } }).options.urlis still accepted for source compatibility but is no longer a working Prisma connection mechanism; an adapter configured without a client now fails at startup with a message naming the requirement, where it previously failed at the first query.DrizzleAdapternow translates every repository operation into real Drizzle builder calls against real columns. It previously selected whole tables and filtered, ordered, paginated and projected them in JavaScript, and passed a fabricated{ column: 'id' }object toeqfor writes — soupdateanddeleteaddressed nothing.options.drizzleTablesis now required, each registered table must carry anidcolumn, and every field named bywhere,orderByorselectmust be a real column on it.drizzle-ormmust be installed alongside the injected instance; the previous silent fallback to placeholder operators is gone, since it produced expressions no driver could execute.create,updateanddeleteread their result from the driver’sRETURNINGclause and throw descriptively on a dialect without it, rather than echoing the input back as if it had been persisted.Migration: pass a table registry beside the instance —
DatabasePlugin({ type: 'drizzle', options: { drizzleInstance: createDrizzleDatabase(db, (database, work) => database.transaction(work)), drizzleTables: { User: users } } }). -
Generated projects are functional by default, and decorators plus DI are one opt-in.
--template restand--template microserviceno longer registerDecoratorPlugin, andsetu generatenow derives its output style from the packages the target project actually holds: a project with@setu-ts/decorator-plugingets decorated classes, and one without it getsctx-first routes and plain exported functions.setu generate moduleis ungated as a result — in a functional project it writes a service plus asrc/routes/<name>.routes.tsmodule the managed routes barrel already registers, so the module servesGET /<name>andPOST /<name>(a real201) with no edit tosetu.config.ts. Decorated write handlers now take@Ctx(), which is how they set that status.setu generate serviceemits a plain exported function plus asrc/services/index.tsre-export barrel — a convenience, not a registration: nothing imports it for you, because no plugin option takes a list of functions.Nothing about an existing project changes. Style is read from its manifest, so a project scaffolded with decorators keeps generating decorated classes, including one that holds
DecoratorPluginwithoutDiPlugin.
Fixed
-
A stock scaffold now installs, boots, answers its probes, type-checks and formats clean. Four defects, all reproduced against the published
alpha.7packages by building a three-service workspace, and all four passed the drift check that type-checks generated output:deno installon a pristine project failed outright on release day —Could not find version of '@setu-ts/common' that matches '^…' … newer than the specified minimum dependency age. Deno 2.9 refuses a dependency published in the last 24 hours andsetu newpins projects to the CLI’s own just-published version, so the window was guaranteed. The generated root now setsminimumDependencyAge.GET /healthon a stock--template restproject answered 500 — the path its own generated Kubernetes probes point at. HealthPlugin’sselfIndicatorreadsruntime.hostname(), which Deno gates behind--allow-sys, and the generatedstarttask never asked for it. The per-templatedenoPermissionsseam already existed and simply had no entry.- Every
.tsxroute in a--template full-stackproject faileddeno checkwith 79TS2686. Deno’s default JSX transform is the CLASSIC one, so a React template must declarejsxitself and this one did not. (This entry originally said that declaring anycompilerOptionsreplaces Deno’s ownreact-jsxdefault, making the emittedexperimentalDecoratorsthe cause. That mechanism is false — measured and corrected later; see the M63 entry ofCLAUDE.md. The fix below is unaffected and correct.) Compiler options are now per template, andfull-stackgained thecheck:apptask that reaches route modulesdeno check main.tsnever sees. - A fresh workspace failed
deno fmt --checkon 62 of the 74 files the CLI itself wrote: nofmtconfiguration was emitted at all, and with one added the.tsxemitters still disagreed. Generated imports are now sorted and wrapped the waydeno fmtdoes, and emitted JSX is single-quoted.
The gate that keeps them fixed formats, lints, installs, type-checks and boots every template, then requests what the project advertises — deliberately without a blanket permission grant, since a permission the generated task forgot to ask for is unobservable under
-A. -
IRepository.count()on the Drizzle adapter no longer drags the whole match set over the wire. It selected every matching row and measured the resulting array’s length in JavaScript, so counting a million-row table transferred a million rows. It now selects drizzle-orm’scount(*)aggregate and reads the single row the database returns. Every fake-backed test passed either way; the guarded real-Drizzle proof now asserts the emitted SQL containscount(*)and names no columns. -
A functional project no longer reports its own services as stale.
setu generatescans each generated family and refuses to list a file that does not export what the barrel would import, reporting it so the artifact is never silently unwired. Theservicefamily has two shapes and the scan used only the class one, so in a functional project everysetu generateprintedSkipped src/services/x.service.ts: it does not export XServicefollowed byRegenerate it to bring it up to date— for a file the CLI had just written, naming a barrel that does not exist there, with advice that loops (the regenerated file is identical). The scan now selects the spec matching the project’s own style.
Removed
-
--template nestwas renamed to--template class-based. The composition is unchanged — the REST set plusDecoratorPluginandDiPlugin, with a decorated controller and an injected service written for you. The framework has a class-based mode, not a NestJS mode, and the name said otherwise. The old name is refused with a message naming the new one rather than falling into the generic unknown-template error. -
setu new --diwas removed, and is refused with a message pointing at--template class-based. Decorators and dependency injection were independently selectable, which made four compositions out of one axis; two of them — decorators without a container, and a container with nothing decorated to construct — are the incoherent middle.--template full-stackconsequently has no DI opt-in from the CLI at all;FullStackStarterOptions.diis still there for an application composing the starter itself.Migration:
--template rest --diand--template nest --diboth become--template class-based; a bare--dihas no direct equivalent, and a project wanting a container without decorators should registerDiPlugin()in its ownsetu.config.ts.
0.1.0-alpha.7 — 2026-08-12
A deployment release. A generated workspace now emits the artifacts that ship it — a
parameterized Dockerfile, a Compose stack carrying every member plus the broker its transport needs,
and a Deployment and Service per member, all regenerated whenever a member is added. This repository
gained the same for itself: one image that builds any example, a Helm chart with its rendered
manifests committed beside it, and a deployment guide that finally writes down the RBAC the
kubernetes service-discovery provider has always needed. A workspace is also no longer Deno-only
(--runtime node|bun builds one on npm workspaces), an existing single-service project can become
one with setu adopt, members can share code through libs/, and two more transports carry a
workspace’s internal traffic.
Deploying for real is what found the defect that would have undermined all of it: nothing handled
SIGTERM. A generated main.ts installed no signal handler, so docker stop and every pod
eviction killed the process outright — measured at exit 143 after 1 ms, with app.stop() never run:
no drain, no service-discovery deregistration, no database or broker disconnect, and a
terminationGracePeriodSeconds that did nothing. Fixed in the generator and in every example here.
All 47 packages move as one version, because the CLI stamps its own version as the dependency range
for every project it scaffolds. Installs still need an explicit version
(jsr:@setu-ts/kernel@^0.1.0-alpha.7) — JSR does not point latest at a prerelease — and Deno
refuses dependencies younger than 24 hours unless you pass --min-dep-age 0.
Fixed
- A scaffolded project now survives
SIGTERM. A generatedmain.tsinstalled no signal handler, sodocker stopand every pod eviction killed it: measured at exit 143 after 1 ms, withapp.stop()never run — no drain, no service-discovery deregistration, no database or broker disconnect. M39 found and fixed this in this repository’s own examples and documented the pattern as recommended; the generator kept emitting the defect.main.tsnow catchesSIGTERMandSIGINT—Deno.addSignalListener(Windows-guarded, where it throws) orprocess.onfor Node and Bun, nothing on Cloudflare Workers, which has no process. Both npm targets declare the type package theirprocessreference needs. setu new --template full-stackserved a blank home page.app/routes.tswraps twoflatRoutesgroups inlayout()calls and neither had an_index, so/matched a layout with no child:<Outlet />rendered nothing and the server answered 200 with an empty<body>— not a 404, not an error. Measured on a scaffolded project:/productsand/loginrendered while/returned 2761 bytes of shell and no visible text. M37c found and fixed exactly this inapps/full-stack; the template kept emitting it, because every check requested/productsand/loginexplicitly and nothing ever requested/.- Install snippets named versions the workspace no longer shipped. Six of them, with
packages/sdk/README.mdtwo releases behind — and a package README is the first thing a new user runs, rendered on jsr.io. Nothing could see it:release:verifyreads manifests and cross-package specifiers, not prose, and a stale-but-real version still resolves, so the command works and installs something old.deno task check:docsnow compares every@setu-tsspecifier in markdown against the shipping version, exempting the changelog and the release runbook, whose old versions are a record; it found two more on its first run. Also corrected:packages/cli/README.mdstill called the framework Hono Enterprise, the name dropped inv0.1.0-alpha.5, and threegithub.com/setu-ts/hono-enterpriselinks 404’d — the repository issetu-ts/setu-ts.
Added
-
Container and Kubernetes artifacts for a generated workspace.
setu generate appemitsdocker/Dockerfile(one parameterized image per member),.dockerignore(at the workspace root, where Docker reads it — without one the host’snode_modulesis copied over the one the image installed),docker/compose.yaml(every member plus the transport’s broker) andk8s/members.yaml(a Deployment and a Service per member), all regenerated for the whole workspace whenever a member is added. M39 owns this repository’s own deployment objects; nothing produced any for a user’s project. -
Containerization and Kubernetes orchestration for the framework itself (M39). The framework could be served on four runtimes and found by an orchestrator, but nothing here showed how to ship it to one: a single collector config was the only deployment artifact in the repository. Now
docker/Dockerfilebuilds any example through one file (--build-arg APP=<name>, rather than fifteen near-copies),docker/Dockerfile.compiledoffers adeno compile→ distroless variant,docker/compose.yamlruns the stack, andk8s/chart/is a Helm chart — Deployment, Service, Ingress, ConfigMap/Secret projection, HPA, PodDisruptionBudget, and the ServiceAccount + RBAC — with its rendered manifests committed beside it ink8s/manifests/anddeno task check:deployfailing on any drift between the two.docs/deployment.mdis the guide, and it documents the thing that was written down nowhere: the exact Role thekubernetesservice-discovery provider needs to read EndpointSlices. Two things a reader might expect are corrected there rather than left implied —scratchis impossible, because the compiled binary is dynamically linked against glibc, and the distroless win is 44.9 MB against 52.4 MB rather than an order of magnitude, since the binary embeds the whole Deno runtime. The reason to prefer it is the absent shell, not the size.The build context must be the repository root: an example’s
deno.jsonmaps only its direct dependencies, so@setu-ts/commonreacheskernelthrough the root workspace, and an image built without the root manifest fails resolution against JSR instead. Three defects only a real cluster surfaced are fixed in what ships:runAsNonRootrefuses a non-numeric image user (both images declare numeric UIDs), anemptyDirat/deno-dirmasks the build-time module cache and makes every pod re-resolve from jsr.io at startup, andhelm templatedefaults.Release.Namespace, so the RoleBinding named a ServiceAccount in the wrong namespace and granted nothing while applying cleanly. -
--transport pubsuband--transport service-bus. Both were previously refused because each needs a value no scaffold can invent. Every transport with a connection value now reads it from the environment with a local fallback, and for these two that fallback is the vendor’s own local-emulator setting — so a scaffolded workspace runs against an emulator unconfigured and against the real service with one variable set. -
setu generate library <name>. Shared code as a workspace member underlibs/, importable by every sibling as@<scope>/<name>with no import-map entry anywhere. -
setu adopt. Converts an existing single-service project into a workspace holding it as the first member. It moves only the files the CLI emits, so.git, CI configuration anddeno.lockstay at the repository root. -
setu generate app --port <n>. A member can be given a specific port; one another member already binds is refused. -
--template full-stackas a workspace member. Its Vite build needsnode_modules, which only the workspace root may enable, so the root gainsnodeModulesDirwhen such a member arrives — and not before, because with it set an ordinary member’s firstdeno checkmaterialises every npm package the framework lazily imports. -
Node and Bun workspaces.
setu new --workspace --runtime node|bunbuilds a monorepo on npm workspaces instead of a Deno one — the framework claims runtime independence, and only the monorepo was Deno-only. The runtime is recorded insetu.workspace.json(absent meansdeno, so nothing existing changes) and every later command reads it back: root manifest shape, environment reads in generated source, library manifest and test runner, base image, and install and run commands. Cloudflare Workers is refused, because each Worker is its own deploy unit. -
A proto toolchain for
--transport grpcmembers. An example proto, bothbufmanifests and aproto:gentask. Both the compiler and the codegen plugin run through Deno’s npm compatibility, so nothing needsbuforprotocon a PATH.
Changed
- A workspace transport’s connection value is an environment read, not a literal. A generated
member’s
MessagingPluginwiring wasurl: 'redis://127.0.0.1:6379', which is unreachable from inside a container — two Compose services do not share a loopback interface, so the member dialled its own container. It is nowDeno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'. - The generated discovery map reads each sibling’s host from
<MEMBER>_HOST, falling back to127.0.0.1, for the same reason: inside a container loopback is the container itself, so a fixed address had every member dial ITSELF on its sibling’s port. The generated Compose stack and Kubernetes objects set those variables to the service names. - A new workspace’s root declares both member globs (
./apps/*and./libs/*) at creation, so neither a service nor a library ever rewrites it. A workspace created earlier gets the second glob added when its first library arrives.
0.1.0-alpha.6 — 2026-08-11
A generator release. Eleven of the fourteen artifacts setu generate emits now reach a
registration site with no edit to a file you own — the other three have a stated reason they have
none. Decorators and dependency injection became independent choices, a repository can hold more
than one deployable service, and Cloudflare Workers gained the last capability it was missing.
Alongside that: a documentation hub with nine curated guides, RFC 9457 Problem Details, and an
OpenAPI document derived from the guards that enforce authentication.
All 47 packages move as one version, because the CLI stamps its own version as the dependency range
for every project it scaffolds. Installs still need an explicit version
(jsr:@setu-ts/kernel@^0.1.0-alpha.6) — JSR does not point latest at a prerelease — and Deno
refuses dependencies younger than 24 hours unless you pass --min-dep-age 0.
One behavior change to already-generated code: setu generate controller emitted a controller
that answered 500 on every request, in every release since the CLI first shipped in
v0.1.0-alpha.2. The fix changes the shape of what it emits — see Changed below for the
migration.
Added
-
Workers-native messaging: the last edge capability gap (M59).
cloudflare-pluginalready servedQUEUE,CACHE,STORAGE,DATABASEandREALTIME_BACKPLANEon Cloudflare Workers.CAPABILITIES.MESSAGINGwas the one token it could not: all tenmessaging-pluginbrokers need a socket or a socket-bound SDK. A newmessagingarm serves it from the platform itself.app.register( CloudflarePlugin({ env, messaging: { binding: 'MESSAGES', rpc: { binding: 'REPLY_INBOX' } }, }), ); // Consuming is a MODULE export — no plugin option can declare one. export default { fetch: app.fetch, queue: createMessagingHandler(app) };publishis a Queues producer call;subscriberegisters into a dispatch table thequeueexport drives, matchingInMemoryBroker’s fan-out and round-robin group semantics. Two limits are documented rather than papered over: Cloudflare allows exactly one active consumer per queue, so cross-service fan-out needs one queue per consumer, and a publish nobody subscribed to is acked rather than retried — retrying ordinary pub/sub would dead-letter every fire-and-forget message.request/respondship behind the opt-inrpcarm. A queue reaches its one consumer Worker and never the caller, so the reply travels through a Durable Object the caller holds a WebSocket to while its request is in flight (ReplyInboxObjectCore, which the application exports as its own DO class). Without the arm both throw, naming the binding to add. A queue carrying RPC must setmax_batch_timeout = 0: the platform default of 5s alone exhausts the default reply budget.CloudflareRequestTimeoutErrorandCloudflareRemoteHandlerErrormirrormessaging-plugin’s two RPC errors as distinct classes, because §2.2 forbids a plugin importing another plugin. Exactly one provider ofCAPABILITIES.MESSAGINGcan be registered, so which to catch is never ambiguous. -
setu new --template microservice --runtime cloudflare-workers(M59). The template refused that target unconditionally. The refusal was right aboutMessagingPluginandQueuePluginneeding raw sockets and wrong about the capabilities, which the platform serves itself. A new declarativeTemplateDefinition.runtimeSwapsreplaces those two withCloudflarePluginon Workers only, and contributes thequeuemodule export, the Durable Object class, and the wrangler stanzas — includingmax_batch_timeout = 0. The other three runtimes are byte-identical. Because Cloudflare invokes ONEqueueexport for every consumed queue, the emitted handler routes on the queue name and both queues get a consumer: one handler for both would feed the messaging broker its job batches, and an unconsumed producer discards everyIQueue.add()silently.TemplateDefinition.unsupportedand its refusal branch are removed:microserviceheld the last entry, so both became unreachable. CLI-internal, never a published export. -
Monorepos: one repository, many deployable services (M62). The CLI had no workspace concept at all, so a second service meant
setu new other --dir .— a fully independent project with its own manifest, its own lockfile, and no knowledge of its sibling. The sharp edge was discovery: the microservice template wiresServiceDiscoveryPlugin({ provider: 'static', services: {} })with a deliberately EMPTY map, because a sample entry would have named a dead port, so every caller’s map was hand-edited in every service and nothing propagated a new name.setu new acme --workspace # the root, no member yet cd acme setu generate app orders --template microservice # apps/orders, port 3000 setu generate app billing --template microservice # apps/billing, port 3001 deno task dev # runs every memberA workspace is a Deno workspace whose root declares members by GLOB (
"workspace": ["./apps/*"]), so adding a service creates a directory and rewrites no manifest — no file you own is ever edited. Each member is an ordinary scaffolded project with its own framework pins, because plugin detection reads one directory’s manifest and never walks up; two members may install different plugin sets.Adding a service registers it with its callers. Every member carries a CLI-owned
src/discovery/services.ts, regenerated for all members on eachsetu generate app, exportingSERVICE_PORT(its own) andSERVICE_ENDPOINTS(every sibling). The member’smain.tsbinds the former and itssetu.config.tshands the latter toServiceDiscoveryPlugin, so the port a member binds and the port its siblings dial are one datum anddiscovery.resolveUrl('billing')works from any sibling with no configuration. The map is the LOCAL development topology; a deployed one comes from a real backend (consul,kubernetes,dns).The workspace chooses how its services talk.
--transport http|grpc|memory|redis|rabbitmq| nats|kafkais recorded insetu.workspace.jsonand inherited by every member, because services can only meet on a bus they share;generate apprefuses the flag and names the workspace-level one.httpstays the default, so an upgrade changes nothing. This closes a silent failure the workspace itself made reachable: the microservice template registersMessagingPlugin(), whose default broker is in-process, so two generated services publishing and subscribing on one topic exchanged nothing while both reported success.--transport tcpis refused with an explanation rather than aliased to HTTP — there is no raw-TCP transport here.Refusals rather than silent surprises:
generate appoutside a workspace namessetu new <name> --workspace; a duplicate member names the directory it already has; a non-Deno--runtimenames the standalone alternative;--template full-stackis refused because its Vite build needsnodeModulesDir, which Deno accepts only in a workspace ROOT; andnew --workspacerefuses--templatebecause a root registers no plugins. -
Decorators and DI are independently selectable in the generator (M61). AI_GUIDELINES states that decorators are optional, DI is optional, and that no feature requires either — but the CLI offered one coarse control. No template gave you neither and refused
g controller/g module;rest/microservicegave decorators without a container; onlynestgave both, along with a worked NestJS-style example you may not have wanted.setu new --diaddsDiPluginto any template, so a container is now a choice of its own:setu new app --di # a container, no decorators setu new app --template rest --di # decorators and a container setu new app --template nest --di # a no-op — nest already registers DiPluginIt changes the composition, never the generated source:
DecoratorPluginbranches on the container’s presence, so the same@Injectableclass works either way and what changes is the lifecycle it gets. On--template full-stackthe flag reaches the starter’s owndiarm rather than a plugin wiring, because a starter-composed template owns its whole plugin set. Adding it to a template that already registersDiPluginis deliberately a no-op — the kernel refuses a duplicate plugin name atstart(), so a second registration would scaffold a project that type-checks and then cannot boot. -
setu generate routeis now a first-class decorator-free path. A project scaffolded with no template registers the runtime plugin alone, sog routeis the only HTTP handler it can generate — and it used to land unwired: the schematic wrotesrc/routes/<name>.routes.tsand asrc/routes/index.tsbarrel while the generatedsetu.config.tsimported neither, so the route answered404until you edited the config by hand. The no-template path is now a seam host for the three families that need no plugin (route,middleware,plugin), so a generated route, middleware or plugin is wired from scaffold time exactly as it is under--template rest.Existing projects are unaffected — nothing rewrites a scaffolded
setu.config.ts. Each barrel’s header states the two lines to add; add them once and every later generate is wired. -
Generated code is now wired (M60).
setu generateemitted fourteen artifacts and exactly one of them — the M58 domain module — reached a registration site. The other thirteen compiled and did nothing:g serviceemitted a class nothing constructed,g health-indicatoran indicator nothing registered,g command-handlera handler no bus dispatched to. Eleven now reach a registration site with no edit to a file you own, and three are documented as having none.Each wired schematic emits its artifact plus a CLI-owned
index.tsseam barrel for its family, and therest,microserviceandnesttemplates scaffold asetu.config.tsthat already imports every barrel they can consume. See PUBLIC_API “Generated code is wired” for the per-schematic table.setu new shop --template microservice setu g health-indicator external-api --dir shop # appears in GET /health setu g metric orders-placed --dir shop # appears in GET /metrics at boot setu g command-handler create-user --dir shop # the command bus dispatches to itguard,jobandmigrationare deliberately unwired, and their emitted JSDoc now names the real call instead of implying a site is waiting: a guard belongs on one route (a global one would answer401for/health), a job’s transport is a choice betweenqueue.processand the scheduler that the artifact cannot make for you, and nothing in the framework reads migration files — there is nosetu db:migrate. -
CqrsPluginOptions.commandHandlers/.queryHandlers, andEventsPluginOptions.handlers— declarative handler registration, as{ type, handler }pairs. Pure additions: omitting them behaves exactly as before. The events option subscribes through the same exportedsubscribeHandlera caller would use by hand, so the two routes cannot diverge. Needed becauseIApplicationexposes no lifecycle hook, so application code has no phase in which to reach a bus that does not exist until its plugin has registered.CommandHandlerRegistration,QueryHandlerRegistrationandEventHandlerRegistrationare exported alongside them. -
NamedMetricConfigis exported from@setu-ts/metrics-plugin.MetricsPluginOptions.customMetricsis typed as an array of it, so without the export that option could take an inline literal but a caller could not declare its own array in a variable. -
CqrsPluginandEventsPluginjoinsetu new --template microservice. Both are in-memory and construct with no configuration, so the tier’s rule that a scaffolded plugin needs no credentials holds, and neither needs a socket so the Cloudflare Workers refusal is unchanged. They are also the only host a scaffolded project can have forg command-handler,g query-handlerandg event-handler, all three of which were gated on plugins no template installed. -
A documentation hub, and gates that keep it true (M38). Nine curated guides under
docs/— getting started, plugin architecture, the plugin catalog, the programmatic API, decorators, writing custom plugins, migrating from NestJS and from Fastify, the examples index, and runtime deployment — plus a reproducibledeno docAPI-site generator (deno task docs:api). Every package README now links to its ownPUBLIC_API.mdsection.The guides are mechanically checked rather than trusted: committed fixtures representing all nine are type-checked against the workspace, a Markdown gate validates the package catalog and every cross-file anchor, and a JSDoc lint ratchet freezes the measured diagnostic count so documentation debt can only be paid down, never added to. A below-baseline run fails and names the constant to lower. No package source, manifest export, capability token, or plugin option changed.
PUBLIC_API.mdsection anchors now carry their package name, which breaks external deep links (#storageis now#storage-setu-tsstorage-plugin).
Fixed
-
Every Cloudflare Worker misdetected its own runtime as
node(found while booting a CLI-scaffolded Workers project in M59).detectRuntime()testednavigator.userAgent.includes('cloudflare')— lowercase — and workerd reports'Cloudflare-Workers', so the check never matched and detection fell through to'node'on every real deployment. That answer selects the runtime adapter, so a Worker built throughRuntimePlugin()ran the Node adapter on Cloudflare, and thecloudflarehealth indicator reporteddegradedwith a misleading detail. It also silently disabled everyruntime.platform() === 'cloudflare-workers'guard, includingmessaging-plugin’s cloud gate — so Pub/Sub and Service Bus attempted their gRPC/AMQP SDK load instead of failing with the namedCloudBrokerUnavailableError.The comparison is now case-insensitive. No test caught this because the unit fakes sent
'cloudflare-workers/v1'and'cloudflare', strings the platform never sends — a test double that violated the real contract, so the suite tested the double. The fakes now use the real string, andapps/cloudflareassertsdetectRuntime()against real workerd in its smoke, which is the only place the platform sends its own user agent. Both were verified to fail without the fix. -
A mistyped Queues binding now fails at
register(), not at the first send (M59).BindingRegistry.queue()cast its binding unvalidated, so a missing[[queues.producers]]stanza or a name typo let an application boot clean, reportupfrom thecloudflarehealth indicator, and fail on the firstadd()with a bareTypeErrorpointing at nothing. A newisQueueProducerguard closes the last hole in that family — the same defect M52c fixed on D1 and M52d on Durable Objects. Behaviour change for anyone whose queue binding was already wrong: the failure now arrives at startup, naming the binding. -
setu new --runtime cloudflare-workersproduced a project that could not be built or deployed.wranglerbundlessrc/index.tswith esbuild, which resolves neitherjsr:specifiers nor a Deno import map — and the Workers target declared its framework packages only indeno.json, emitting nopackage.jsonand no.npmrc. So the flow the CLI itself prints,npm install && npx wrangler dev, failed with oneCould not resolve "@setu-ts/…"per package. There was nothing to install.Workers projects now also emit
package.json(the npm-compat@jsr/…dependencies, pluswranglerpinned indevDependenciesanddev/deployscripts) and.npmrc. Verified against real workerd throughwrangler dev: a scaffolded project serves/,/health,/metrics, and every generated route, controller and module. The Deno target deliberately still gets nopackage.json— that would switch it to node_modules resolution.Existing Workers projects are not rewritten. Add a
package.jsondeclaring the same@setu-ts/*packages yourdeno.jsonlists, using theirnpm:@jsr/setu-ts__<name>form, plus an.npmrccontaining@jsr:registry=https://npm.jsr.io. -
setu new --runtime nodecould not run any decorated code. Generated Node projects started withnode --experimental-strip-types main.ts, and Node’s built-in TypeScript support erases types without transforming code — so a legacy decorator was a bareSyntaxError: Invalid or unexpected token, and the constructor parameter propertysetu generate moduleemits wasERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. In practice a scaffolded Node project booted until the firstsetu generate service,generate controllerorgenerate module, andsetu new --template nest --runtime nodenever booted at all. Deno, Bun and Cloudflare Workers were unaffected.Node projects now declare
tsxindevDependenciesand start withtsx main.ts, which reads theexperimentalDecoratorsthe generatedtsconfig.jsonalready sets.--experimental-transform-typeswas evaluated and rejected: it handles the parameter property but still refuses the decorator, because it does not enableexperimentalDecorators. No other target carries the dependency — Bun compiles TypeScript outright, and Deno and Workers never invoke it.Existing Node projects are not rewritten. To pick this up, add
tsxto yourdevDependenciesand change thestartscript fromnode --experimental-strip-types main.tstotsx main.ts.
Changed
-
The
controllerandmodulegate refusals now namesetu generate routeas the decorator-free alternative. The gate itself is unchanged (those schematics emit@Controller, so an ungated project would get source whose own import cannot resolve), but refusing with only “install@setu-ts/decorator-plugin” read as though decorators were required to serve HTTP, which is the opposite of what the framework promises.The "controller" schematic requires @setu-ts/decorator-plugin, which is not installed in /path/to/app. Install it, then run this command again. Or run `setu generate route user-profile` — it registers handlers on the router API, so it needs no decorators. -
setu generate pluginnow writessrc/plugins/<name>.plugin.ts, notsrc/plugins/<name>.ts. The seam barrel is regenerated from a directory scan, and a suffix of.tswould admit any module you hand-wrote in that folder — the barrel would then import a<Pascal>Pluginsymbol you never wrote, and your project would fail to compile naming a file you never generated. Existing generated files are untouched; only new generates take the new path. -
setu generate serviceemits an@Injectablewhen@setu-ts/decorator-pluginis installed, registered under the token<name>-serviceand listed insrc/services/index.ts. Without that package the output is unchanged, byte for byte, and the schematic stays ungated — so it keeps working in a project with no plugins at all. -
An artifact generated before its family gained a second export is skipped and reported, rather than listed in a barrel that cannot compile.
middlewaregained a<SCREAMING>_MIDDLEWARE_PRIORITYconstant andmetrica<SCREAMING>_METRICdeclaration in this release; an artifact generated earlier has the right filename and lacks that export, so a barrel regenerated over it named a symbol the file did not have and the project stopped compiling — from a command that reported success. The scan now admits a file only when it exports everything the barrel will name, prints what it skipped and why, and tells you to regenerate. The same rule keeps a hand-written module in a scanned directory out of the barrel. -
setu generaterefuses a name that would collide with an existing artifact (exit1), naming the conflict and the consequence.route,controllerandmoduleall mount/<name>, and the kernel’s router keys routes by method and path — so a duplicate silently overwrites and one artifact becomes unreachable.serviceandmoduleboth register@Injectable({ token: '<name>-service' }), and the decorator plugin keeps the first class under a token — so the wrong service would be injected, which was observed as a500on every request to the affected module. Both checks apply only whendecorator-pluginis installed, since neither collision can exist without it. -
Fixed:
setu generate controlleremitted a controller that answered 500 on every request.DecoratorPluginbuilds a handler’s argument list from parameter metadata alone and never passes the request context positionally, so the emittedlist(ctx: IRequestContext)receivedundefinedand threw on the firstctx.response. Handlers now take only decorated parameters and return plain values, which the plugin serializes as JSON;createtakes@Body(). The201on create is gone rather than faked — a decorated handler cannot set a status code, so a handler that needs the context belongs onapp.router.get(...)(setu generate route).Regenerate any controller produced by an earlier release, or drop its
ctxparameter and return a plain value. Shipped alongside the module schematic below because it is the same package and the same one-line class of defect. -
setu generate module <name>scaffolds a whole domain sub-module and wires it in (M58), instead of requiringg controller+g serviceplus a hand edit ofsetu.config.ts. Emits an@Injectableservice, a@Controllerinjecting it by token, a service test, a per-module barrel, and a regenerated aggregate barrel atsrc/modules/index.tsexportingMODULE_CONTROLLERS/MODULE_SERVICES. Therest,microserviceandnesttemplates now scaffold asetu.config.tsthat already imports both and passes them toDecoratorPlugin, so nothing the developer owns is ever edited by the CLI.setu new shop --template rest setu g module orders --dir shop # wired; no edit to setu.config.tsGated on
@setu-ts/decorator-plugin, likeg controller.--template full-stackis not a host: its layering isroutes → features → servicesand it has nosrc/modules/concept. A project scaffolded before this release adds the barrel import once; every laterg moduleis automatic.A directory counts as a module only when it holds both canonical files, so an unrelated folder under
src/modules/is skipped instead of producing a barrel that imports files which do not exist. The host templates declare@std/testingand@std/expect(adeno.jsonimport on Deno/Workers, annpm:@jsr/std__*alias on Node/Bun), so the emitted test runs with no further setup.GeneratedFilegains an optionalmanagedflag andSchematicOptionsan optionalmoduleslist. Both are additive — existing custom schematics compile unchanged. A managed file is exempt from the overwrite refusal, which previously covered every path without exception; onlysrc/modules/index.tsis managed today, and the exemption is per file rather than a--forceflag so a mistypedg servicestill cannot clobber hand-written work. -
The OpenAPI document can be derived from the guards that enforce authentication (M57), instead of requiring every route to declare a requirement a second time.
@setu-ts/commongains aSECURITY_METADATAsymbol, aRouteSecurityMetadatatype, and the purewithSecurityMetadata/securityMetadataOfhelpers; every guard@setu-ts/auth-pluginships is branded with them, and@setu-ts/openapi-pluginreads the brand off a route’s middleware.app.register( OpenApiPlugin({ securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } }, deriveSecurity: { scheme: 'bearerAuth' }, }), ); app.router.get('/todos/:id', { middleware: [requireAuth()], handler }); // → requires bearerAuth app.router.post('/login', { middleware: [publicRoute()], handler }); // → publicOpt-in and non-breaking. Without
deriveSecuritynothing is derived and the document is byte-identical, and a requirement declared onschema.securityalways wins over a derived one. The brand is symbol-keyed and non-enumerable, so guard identity and behaviour are unchanged;Symbol.foris used so two copies ofcommonin one process resolve the same key.Three limits are documented rather than left to discovery: only route-level middleware is inspected (
app.middleware.add()is invisible to a route, which is correct forauthMiddleware()— it populates the principal and never rejects); roles and permissions cannot be expressed, because an OpenAPI requirement names a scheme and none can be inferred from'admin'; and the scheme name is configured rather than inferred, with an undeclared name refused atregister(). -
RouteSchema.securityand a document-levelsecurityoption describe which operations need authentication.@setu-ts/openapi-pluginacceptedsecuritySchemesand emitted them undercomponents, but nothing ever declared a requirement —OpenApiOperation.securityexisted in the generator’s types with no assignment anywhere — so no operation was marked protected and generated clients had no signal that a route needed a token.RouteSchemain@setu-ts/commongains an optionalsecurity, alongside thetagsandsummaryit already carried, plus a new exportedSecurityRequirementtype. The addition is optional, so existing routes and existing implementors are unaffected.app.register( OpenApiPlugin({ securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, }, security: [{ bearerAuth: [] }], // document-level default }), ); app.router.post('/login', { schema: { security: [] }, handler }); // explicitly publicAn empty
securityarray is meaningful and is not the same as omitting the field: per the OpenAPI specification it declares the operation public, which is how a route opts out of the document-level default. Omitting the field leaves the operation inheriting it. Declaring this enforces nothing — authentication is still enforced by middleware and guards; this describes the route for documentation and client generation. -
OpenApiPluginOptions.excludekeeps operational endpoints out of the document. Paths are matched exactly against the fully-resolved router pattern — router-style (/todos/:id, not the OpenAPI template/todos/{id}) and including anyrouter.group()prefix — and every method on a matched path is omitted. -
@Publicreaches the OpenAPI document. A decorated route marked public is now documented with an emptysecurityarray, so it opts out of a document-level requirement. Without this the opt-out was reachable only from a programmaticschema, and a decorated login route would have been documented as requiring the token it issues.@Roles/@Permissionsare deliberately not mapped: a role is not a security scheme, and no declared scheme can be inferred from one. -
A
securityrequirement naming an undeclared scheme is refused atregister(), naming the offending scheme and the declared ones. Emitting it produced a document that is invalid per the specification — Swagger UI renders a lock on every operation with no Authorize button to satisfy it, and strict validators and client generators reject it — while the spec endpoint still answered200, so nothing downstream could detect it.
Fixed
-
The OpenAPI document no longer lists its own delivery endpoints.
GET /openapi.jsonandGET /docswere generated as API operations, so every consumer of the spec — Swagger UI readers and generated clients alike — was handed the documentation machinery as part of the API. Both are now excluded automatically, honoringendpoint/specEndpointwhen they are customized. The routes are still served; only the document entries are gone. -
Path parameters are typed as strings instead of rendering as
any. A path parameter with no entry in the route’sparamsschema was emitted asschema: {}, which OpenAPI reads as “any type” — Swagger UI rendered an untyped box and client generators producedunknownarguments. Every path segment arrives as a string, so an undescribed path parameter now defaults to{ type: 'string' }. A declaredparamsschema still wins, per parameter.
Changed
-
Problem Details move from RFC 7807 to RFC 9457 (M56). RFC 7807 was obsoleted by RFC 9457 in July 2023, and the framework advertised the withdrawn specification in two packages, a public format alias, an exported symbol in each, the three starters, and every documentation site.
@setu-ts/exceptionsand@setu-ts/validation-plugineach gain an'rfc9457'format arm and anrfc9457Formatterexport.'rfc7807'andrfc7807Formatterare deprecated, not removed (AI_GUIDELINES §9.2), and are scheduled for removal in v1.0.0.RFC 9457 changed very little on the wire — Appendix D lists three changes, none touching the five core members or the
application/problem+jsonmedia type — so the bodies were already structurally valid. One 7807-era habit did need correcting, and that is the only behavior change here.⚠️ Breaking:
typeis nowabout:blankfor status-only problems.@setu-ts/exceptionspreviously minted a URI from the status code for every error (https://setu-ts.dev/errors/404), which identifies nothing thestatusmember does not already carry. RFC 9457 §4.2 registersabout:blankfor precisely that case, and that is what the'rfc9457'format now emits. Clients matching ontypeto distinguish errors should readstatusinstead. The one error carrying an extension member,validationError(), keeps a concrete type URI —https://setu-ts.dev/errors/validation, the same URI@setu-ts/validation-pluginemits for the same problem type.// Before After { "type": "https://setu-ts.dev/errors/404", { "type": "about:blank", "title": "Not Found", "title": "Not Found", "status": 404, "status": 404, "detail": "User 42 does not exist" } "detail": "User 42 does not exist" }Two escape hatches, in order of preference: read
status, which is what it is for; or keep the deprecatedformat: 'rfc7807', which is unchanged and still emits the status-derived URI — a deprecated symbol must not silently change behavior (§9.4).@setu-ts/validation-pluginhas no wire change at all: itstypewas always a semantic URI rather than a status-derived one, sorfc7807Formatterthere is a deprecated alias bound to the same object and the emitted body is byte-identical.The three starters (
rest,microservice,full-stack) now composeerrorHandlerwithformat: 'rfc9457', so an application built on one of them picks up the newtypeon upgrade. Applications wiringerrorHandlerthemselves are unaffected until they change the format string.
Fixed
-
The Problem Details media type survives a second formatter. Both packages keyed
application/problem+jsonoff a single formatter reference, so that passing a formatter directly (format: rfc9457Formatter) agreed with passing the alias (format: 'rfc9457'). Adding a second formatter to that check without generalizing it would have served a Problem Details body asapplication/json— which generic problem-details clients ignore — while the string alias tested fine. The check is now a membership test over every Problem Details formatter, covered by tests that drive each spelling by reference. -
ARCHITECTURE.mddocumented atypeURI the code never emitted. The Problem Details example showedhttps://setu-ts.dev/errors/not-found; the formatter emittedhttps://setu-ts.dev/errors/404. Corrected along with the rest of the section.
0.1.0-alpha.5 — 2026-08-08
This release renames the project and moves every package to a new JSR scope. It is the first
release published under @setu-ts; the @hono-enterprise packages are archived and receive no
further versions. Because the scope changes, every consumer must update their imports and their
manifest — there is no upgrade path that leaves specifiers untouched.
Two things about a prerelease under a brand-new scope, both of which will otherwise surprise you:
JSR does not point latest at a prerelease, so every install instruction must carry an explicit
version (jsr:@setu-ts/kernel@^0.1.0-alpha.5); and Deno refuses dependencies younger than 24 hours
unless you pass --min-dep-age 0, which affects the maintainer verifying the release rather than
ordinary users.
The project is renamed from Hono Enterprise to Setu-TS, and every package moves to a new JSR
scope. The old name asserted an association with the Hono project that does not exist: this
framework is not built, endorsed, or maintained by the Hono team, and its actual use of Hono is one
file — the kernel’s router delegates matching to jsr:@hono/hono. That dependency is unchanged and
unaffected. The rename removes the false signal, nothing else.
⚠️ Breaking 1 of 5: every import specifier changes.
@hono-enterprise/<pkg>becomes@setu-ts/<pkg>, published under the@setu-tsJSR scope. On the npm compatibility path,@jsr/hono-enterprise__<pkg>becomes@jsr/setu-ts__<pkg>. This is a find-and-replace across your imports and your manifest; no API changes with it. The@hono-enterprisepackages are archived on JSR — hidden from search and closed to new versions, but every published version stays installable and existing pinned builds keep resolving. They are deliberately not yanked: yanking signals a defective release and would break range resolution, and these versions are superseded rather than broken.
⚠️ Breaking 2 of 5: existing session cookies are invalidated. The HKDF
infoparameters behind@setu-ts/session-plugincarried the old project name, so the derived encryption, signing, and key-id values all change. Every previously issued cookie fails to open and is treated as absent, signing users out once. No configuration changes and no key rotation is required — this happens on deploy and does not repeat.
⚠️ Breaking 3 of 5: the realtime backplane default topic changes.
DEFAULT_TOPICmoves fromhono-enterprise.realtimetosetu-ts.realtime, so replicas on either side of the upgrade do not see each other’s frames. Restart all replicas together rather than rolling them, or pin the old value explicitly via thetopicoption to upgrade in stages. This mirrors the alpha.3 request-reply wire change; if you do not use the backplane, nothing here applies to you.
⚠️ Breaking 4 of 5: RFC 7807
typeURIs change. The error-document base moves fromhttps://hono-enterprise.dev/errorstohttps://setu-ts.dev/errors, affecting every Problem Details body from@setu-ts/exceptionsand@setu-ts/validation-plugin. Clients matching on thetypefield need updating. Per RFC 7807 these URIs are identifiers and are not required to resolve.
⚠️ Breaking 5 of 5: the CLI binary is renamed.
honoebecomessetu—setu new,setu generate, and so on. Reinstall the CLI to pick up the new executable name; scaffolded projects are otherwise unchanged apart from the scope in their generated manifests.
Two further identifiers change with the rename and are noted for completeness rather than as
breaking: the exported SESSION_STATE_KEY is now setu-ts:session, and the GitHub repository moved
to setu-ts/setu-ts. Released sections below this one deliberately retain the @hono-enterprise
names, because they record what those releases actually shipped.
Added
-
@setu-ts/static-plugin— static file serving as a capability (M55). RegistersIStaticFilesunder a newCAPABILITIES.STATIC_FILEStoken and mounts one handler on bothGETandHEAD.The framework previously had exactly one static file server, inside
react-router-plugin, written for content-hashed SSR bundles: an unconditionalimmutableCache-Controlon every response, no directory-index resolution, no conditional requests, and a whole-file read into memory. That handler is unchanged and still correct for its job; this package is for everything else.Ships configurable
cacheControl(a string, or a function receiving the root-relative path, defaulting to immutable-for-hashed andmust-revalidateotherwise),indexandfallbackas separate options, conditional requests, single-range206/416,.br/.gzsidecar negotiation, astatic-fileshealth indicator, and streaming for files abovemaxBufferBytes.The SPA
fallbackfires only whenAcceptincludestext/html. Without that guard a missing.jsreturns the HTML shell under a JavaScript content type, which browsers surface as an opaque syntax error.ETagis strong ("<size>-<mtimeMs>") when the runtime reports anmtime, and degrades to a weak size-only validator when it does not. This is load-bearing rather than cosmetic:If-RangeMUST be ignored for a weak validator (RFC 9110 §13.1.5), so a weak ETag makes every interrupted download restart from byte zero.size+mtimeis what nginx and Apache emit as strong for static files.On Cloudflare Workers
runtime.fsis absent, so the plugin registers its capability, reportsdegraded, and mounts no route. Use Workers Assets or R2 via@setu-ts/cloudflare-pluginthere. -
IFileSystem.readStream?(path, { start, end })in@setu-ts/common— optional and additive, so no existing implementor breaks and every current caller is untouched.endis inclusive, matching bothnode:fsand theRangewire format, so no off-by-one translation exists. Implemented by the Node, Deno, and Bun runtime adapters and omitted on Workers, where callers degrade to a whole-file read exactly as they already do forrealPath. -
contentTypeFor,isLexicallyContained,assertRealPathContainedin@setu-ts/common— pure helpers shared bystatic-pluginandreact-router-plugin, which now delegates to them. Its emitted headers are unchanged and pinned by a regression test. -
@Optionalconstructor-parameter decorator in@setu-ts/decorator-plugin. Pairs with@Injecton the same parameter (either order) and injectsundefinedwhen that token has no provider, so a class can depend on a capability the application may not have registered without the author hand-writing a container lookup.It means the dependency is absent, not that construction may fail: a token that IS provided is resolved normally, so a circular dependency or a throwing factory still surfaces instead of becoming
undefined. Both construction paths honor it identically — the DI container when one is registered, the kernel’s service registry otherwise.Three misuses are refused rather than silently misinjected:
@Optionalwith no@Injecton the same parameter,@Optionalcombined with the deprecated class-level@Inject(...)list, and@Optionalon a method parameter.On the container path a class carrying
@Optionalnow registers as auseFactoryprovider instead ofuseClass, becauseClassProvider.injectis a bare token list with nowhere to record optionality; its ownscopeis still honored, but its dependencies resolve from the registering container rather than the resolving scope. Classes without@Optionalare unchanged. Nocommoncontract change and no new capability token. -
apps/full-stack— a runnable React Router 8 SSR example (M37c). The framework’s full-stack story shipped in three places — the SSR plugin, the full-stack starter, andhonoe new --template full-stack— and none of them had an application a reader could run. This one is served by the kernel throughreact-router-plugin, composed throughcreateFullStackAppFromConfig, and itssmoketask asserts that an SSR-rendered page contains rows written through the database capability — evidence thatpopulateLoadContextbridges the kernel’s service registry into a React Router loader, rather than that a server started. It then signs in through a<Form>, so the session and its synchronizer CSRF token round-trip too.Its routes are
/(a landing page that reports session state),/productsand/login.The example also makes the framework’s distinguishing claim executable:
test/removal.test.tsasserts that none oflib/{session,csrf,sse,kv,service-logger}.server.tsexists, and thatapp/config/services.server.tsholds no module-level cache — because the kernel’s service registry is that cache.The frontend build runs for real in CI, with no Node toolchain.
deno installplus the@react-router/devCLI run the identical Vite build under Deno’s own npm support (measured: ~4 s install, <1 s build), so noServerBuildfixture is committed andfull-stackis deliberately not inALLOW_SKIP. No published package changed; the frontend build remains an app-level, build-time concern outside every published dependency graph (AI_GUIDELINES §12.2). -
@setu-ts/messaging-plugin— GCP Pub/Sub (GcpPubSubBroker) and Azure Service Bus (ServiceBusBroker) backends implementingIMessageBrokerwith request-reply over a shared reply topic + per-instance subscription.MessagingPluginOptionsis now a discriminated union onbrokerwith a'custom'arm (inject anyIMessageBroker) and a default memory arm soMessagingPlugin()/MessagingPlugin({})remain valid.MessagingBrokerTypewidened to 8 literals. Both cloud brokers throwCloudBrokerUnavailableErroron Cloudflare Workers. Verified against the vendors’ own local emulators — Google’s Pub/Sub emulator and Microsoft’s Service Bus emulator — covering publish/subscribe over real gRPC/AMQP, ack/nack settlement producing genuine redelivery, receiver teardown, and on Pub/Sub the RPC reply subscription’s create/delete cycle. Seedocs/messaging-emulators.md. Service Bus RPC is unverified: that emulator supports no management operations, so the per-instance reply subscription cannot be created there — the suite asserts the refusal surfacesReplyInboxUnavailableErrorinstead. Neither backend has run against a live cloud account. -
@setu-ts/queue-plugin— SQSSqsQueueadapter (QueueAdapterseam, wrapped byQueueService) with per-name queue URLs, receipt-handle bookkeeping,ApproximateReceiveCountattempt ladder, visibility-timeout backoff, and dead-letter ordering.SnsPublisherfor SNS fan-out.QueueAdapterTypewidened to include'sqs'.QueueBackendUnavailableErrorthrown on Cloudflare Workers. The SQS adapter is verified against ElasticMQ in CI (that suite drivesSqsQueuedirectly; theQueuePlugin→QueueServicewiring is covered separately bysqs-arm-integration.test.tsover a contract-honouring transport fake). SNS is fake-driven.
Changed
- Node and Bun compatibility is now verified on every pull request, retiring the known limitation
recorded in
0.1.0-alpha.1. TheNode compatibilityandBun compatibilityCI jobs were placeholders blocked on the first JSR publish; they now install the published packages through JSR’s npm compatibility layer and runcompat/compat.test.mjs. All 46 published packages are installed and imported on each runtime — a package whose ESM output or transitive dependency does not resolve there is broken for every consumer and nothing else in CI would see it — and the suite then boots a kernel application, resolves a capability, and serves a request over a real socket. The expected package list is derived from the Deno workspace, so a new package that is never added to the compat suite fails the job rather than quietly shrinking coverage. The suite tracks the latest published release rather thanHEAD, because Node and Bun cannot resolve this repo’sjsr:andnpm:specifiers from source. - ⚠️ Breaking 1 of 1:
MessagingPluginOptionsis now a discriminated union. A caller holding a widened variable (e.g.let opts: MessagingPluginOptions = getOptions()) must narrow before passing to the factory. Single-arm literals,MessagingPlugin(),MessagingPlugin({}), and the factory’s own= {}default are unaffected.MessagingBrokerTypeincludes'pubsub','service-bus', and'custom'. @setu-ts/queue-plugin— the INTERNALQueueAdapterseam gained aclaimTokenargument onack/requeue/deadLetter, andStoredJoban optionalclaimToken?. It identifies one delivery, so an adapter can refuse a settle belonging to a superseded one — SQS needs this because a requeued message returns with a newReceiptHandle. Adapters without a transport-level claim (memory, redis, rabbitmq) accept and ignore it. Neither type is barrel-exported, so no published surface changes; listed because it alters a contract shared by every adapter.
Fixed
-
@setu-ts/queue-plugin— the SQS backend settled nothing throughQueuePlugin. The job runner passed the job id where the adapter expected theclaimTokenminted byreserve, so everyack/requeue/deadLetterfailed its own claim check and returned without calling SQS: a processed job was never deleted and redelivered after each visibility timeout, forever. Adapter tests and the ElasticMQ e2e passed because both settle the adapter directly, supplying the token the real caller did not. Now covered through a real kernel application. -
@setu-ts/messaging-plugin—ServiceBusBrokerleaked an AMQP receiver link perunsubscribe(). Teardown closed only the subscriber handle returned byreceiver.subscribe(...)and never the receiver itself; it also closed the most recently opened receiver rather than the one being unsubscribed, so cancelling one of two subscriptions on the same topic stopped the wrong delivery. -
@setu-ts/messaging-plugin—GcpPubSubBrokerandServiceBusBrokerconstructed standalone with neither credentials nor an injected transport now fail atconnect()naming the missing option, instead of building a client on an emptyprojectId/ connection string and failing later inside the SDK. -
Redis-backed cache, queue, and messaging plugins now create ioredis clients with
lazyConnect. Their explicit startupconnect()call no longer fails because ioredis connected eagerly during construction. -
@setu-ts/queue-plugin—RedisQueue.reserve()now sends the mandatoryLIMITkeyword in theZRANGEBYSCOREcommand, so reserve works against a real Redis server. Previously the call sent positional offset/count arguments without the keyword, which caused Redis to returnERR syntax erroron every reserve attempt. -
@setu-ts/messaging-plugin—RedisStreamsBrokernow hands a timer handle back toclearIntervalexactly assetIntervalreturned it. It previously stored the handle as anumber, andTimerHandleis deliberately opaque (unknown), so a runtime returning an object-shaped handle had it coerced toNaN— making the cancel a silent no-op and leaking a poll loop that kept issuing commands afterunsubscribe()anddisconnect(). The bundled Node, Deno, and Bun runtimes were unaffected, because their handles happen to coerce to a numeric id; a customIRuntimeServiceswhose handle does not coerce leaked outright.
0.1.0-alpha.4 — 2026-08-04
The largest release so far: eight packages publish for the first time, bringing the scope to 46.
The three starters (rest-starter, microservice-starter, full-stack-starter) ship at last,
along with session-plugin, service-discovery-plugin, grpc-plugin, graphql-plugin, and
cloudflare-plugin. Every other package is version-bumped so the scope stays on one version — the
CLI requires this, because honoe new stamps generated projects with its own version as the range
for every package it wires.
Two changes can alter behavior you already depend on. Both are narrow, and neither is a capability regression, but the first is silent at compile time.
⚠️ Breaking 1 of 2:
@CookieandparseCookiesreturn different values. Cookie values are now percent-decoded, one layer of RFC 6265 quoting is stripped, and a repeated cookie name resolves to the first occurrence rather than the last. Nothing stops compiling, so this changes at runtime rather than at build time — if you were decoding values yourself after callingparseCookies, remove that step, because double-decoding will corrupt any value containing a literal%. Each difference is a defect fix; see Changed for why they are being corrected during0.1.xrather than frozen.
⚠️ Breaking 2 of 2:
IGraphqlServicegains a requiredsubscribemethod. Source-compatible for every caller and breaking only for anyone who implements the interface. The framework’s ownGraphqlServiceis the only implementor in this repository, so if you have not written your own GraphQL service, nothing here applies to you.
Cloudflare is now a first-class target rather than merely a runtime that boots. Four milestones
add KV, R2, D1, Queues, Cron Triggers, the Cache API, and Durable Objects behind typed accessors
that name a missing binding instead of handing you undefined — and they fixed the reason
runtime.env was empty on Workers, which had left ConfigPlugin and the secrets EnvProvider
reading nothing on the edge.
Alongside that: gRPC, Connect, and gRPC-Web co-served on the same port as ordinary routes; a GraphQL plugin with subscriptions over both WebSocket and SSE; service discovery over Consul, Kubernetes, DNS-SRV and static configuration, with load balancing and outlier ejection; cookie sessions with form CSRF; and the starter and template work that makes all of it composable in one call.
Added
-
GraphQL subscriptions, batching, and persisted queries (Milestone 51b).
@hono-enterprise/graphql-plugingains two subscription transports — thegraphql-transport-wsprotocol over the OPTIONALCAPABILITIES.WEBSOCKET, and GraphQL-over-SSE in distinct-connections mode over M42’sIResponse.stream(), which needs no other plugin — plus request batching, Automatic Persisted Queries with server-side hash verification, and custom scalar resolvers in the schema-first arm.Every new behaviour is opt-in.
subscriptions,apq, andmaxBatchSizeall default to off, so an application that upgrades without changing its options registers exactly the routes it did before and answers byte-identically. In particular the HTTP endpoint still refuses a subscription with400 SUBSCRIPTIONS_NOT_SUPPORTED_OVER_HTTP; subscriptions are reachable only on the dedicated WebSocket and SSE routes, which default to the endpoint path plus/wsand/stream.A subscription is declared in the schema-first resolver map as
{ subscribe, resolve? }— the new exportedSubscriptionResolverarm onResolverMap. APQ verifies that a submitted hash matches the submitted document before persisting it, so a sharedICacheStorecannot be poisoned with a document under another client’s hash; a mismatch answersPERSISTED_QUERY_HASH_MISMATCH. Resolver errors raised inside a live subscription are masked by the samemaskInternalErrorspath the HTTP transport uses.Three
@hono-enterprise/commonwidenings:GraphqlRequestParams.extensions(read by APQ),IGraphqlService.subscribewithGraphqlSubscriptionOutcome/GraphqlOperationContext/GraphqlConnectionInfo, andWebSocketRouteOptions.heartbeat(below).
Changed
-
IGraphqlServicegains a requiredsubscribemethod (Milestone 51b) — source-compatible for every CALLER, and breaking for anyone who implements the interface. The framework’s ownGraphqlServiceis the only implementor in this repository. An external implementation adds:subscribe( params: GraphqlRequestParams, context?: GraphqlOperationContext, ): Promise<GraphqlSubscriptionOutcome>;The second parameter is deliberately NOT an
IRequestContext: a WebSocket connection has none, and reusingexecute’s parameter is what would hand a subscription resolver an empty service registry. -
WebSocketRouteOptions.heartbeat(Milestone 51b). A route may now opt out ofwebsocket-plugin’s shared heartbeat sweep withheartbeat: false, which excludes its connections from both the payload send and the idle eviction. Defaults totrue, so no existing route changes. This exists because the sweeper sends a raw text frame to every connection on every route: agraphql-transport-wsclient that receives one must close with4400, soWebSocketPlugin({ heartbeatMs })would otherwise have broken every GraphQL subscription in the application. The GraphQL WS route claims the opt-out and runs its own protocolping/pong. -
Durable Objects: a realtime backplane and a distributed lock (Milestone 52d).
@hono-enterprise/cloudflare-plugingains adurableObjectarm registeringDurableObjectBackplaneunder the committedCAPABILITIES.REALTIME_BACKPLANE, sowebsocket-pluginandsse-pluginreach clients on other replicas with no application change — andDurableObjectLock, which structurally satisfiesscheduler-plugin’sIDistributedLockand is handed toSchedulerPlugin({ distributedLock: { lock } })(an injected lock wins outright;enabled: trueis not required). Nocommonchange and no new capability token: both contracts were already committed. Register either this arm orRealtimeBackplanePlugin, never both — the kernel rejects two providers of one token.Both need a Durable Object class the application exports, plus a wrangler stanza; the package ships the behaviour as two plain cores (
RealtimeBackplaneObjectCore,DistributedLockObjectCore) that the exported class delegates to. A mixin taking the base class would read better but cannot be typed withoutany, and delegation additionally keepscloudflare:workers— unresolvable off a Worker toolchain — out of the package.Two platform facts shaped the implementation rather than being worked around. Sockets are accepted with
ctx.acceptWebSocket, the hibernation API, which lets the runtime evict the object and re-run its constructor while connections stay open; the fan-out core therefore holds zero in-memory state and treatsgetWebSockets()as the only membership, because aSetin a field would empty itself on the first hibernation while every non-hibernating test still passed. And a Worker isolate cannot be relied on to hold a long-lived outbound WebSocket, so the socket opens lazily and reopens after any failure; the guarantee is stated rather than overstated — a subscription lives exactly as long as the isolate holding the members it serves, and since those members are client sockets in the same isolate, losing one loses both together. The lock persists its holder in the object’s storage, never a field, because an object is evicted after 70–140 seconds idle; correctness comes from the platform’s input gate (“while a storage operation is executing, no events shall be delivered to the object”), which makes the read-compare-write atomic with no transaction. A non-2xx from the lock object throws rather than reporting “not acquired”, since a 404 means the binding names the wrong class and folding that into contention would silently disable every scheduled job.Also closes the last hole in the binding-guard family:
BindingRegistry.durableObjectcast its binding unvalidated, so a missingdurable_objectsstanza or a mistypedclass_namelet an application boot clean and fail on the firstidFromNamewith a bareTypeError— the defect M52c’s review found on D1. Adds the exportedisDurableObjectNamespaceguard and constructor validation. Verified against real workerd viawrangler dev(12/12 checks), which also settled the design question the milestone could not answer from docs: a plain Durable Object class withoutextends DurableObjectis accepted, so the delegation design is correct and not merely convenient. Not verified against a deployed Worker — CI holds no Cloudflare account.
Fixed
-
A listen-only replica received nothing from a realtime backplane.
IRealtimeBackplane.connect()had exactly one caller —RealtimeBackplanePlugin.register()— andwebsocket-plugin/sse-pluginrelied on the provider having connected before they subscribed.subscribe()registers a handler; it does not open a transport. Any provider that cannot connect at registration therefore left every replica that only listens silently receiving nothing, which a Cloudflare Durable Object backplane is the first transport to hit: a Worker runsregister()at module scope, where the platform forbids the I/Oconnect()performs.Both consumers now open the transport on first local use, inside a request context on every runtime —
WebSocketServicewhen a connection joins its first room,SseServicewhen a client connects. The call is fire-and-forget so an upgrade never waits on the transport, idempotent per the committed contract, and retried on the next join if it fails. Applications registeringRealtimeBackplanePluginare unaffected: its provider still connects at registration, and the extra call is a no-op. -
Cloudflare D1 as a first-class database backend, and the
commondata-access promotion that made it possible (Milestone 52c). The seam a database backend implements wasIDatabaseAdapter, declared inside@hono-enterprise/database-pluginand never exported, whilecommonshipped only the lifecycle-shapedIOrmAdapter— so a backend living in any other package was literally inexpressible, because AI_GUIDELINES §2.2 forbids one plugin importing another.@hono-enterprise/commonnow exportsIDatabaseAdapter,IAdapterTransaction,IDataSource,NormalizedQueryandOrderDirection. The promoted port is the old shape plus one member — a non-transactionalcreateDataSource(entity)— and that addition is the substance of the change: the plugin previously reached each adapter’s data-source factory by casting to the concrete class, which is what actually kept the seam closed. That cast is gone, all three built-in adapters carrycreateDataSource, andcreateDataSourceForEntityis deprecated, not removed (§9.2).DatabasePluginOptionsis now a union discriminated ontypewith a'custom'arm requiring anadapter, so registering an external backend without one is a compile error rather than a startup throw; every existing registration compiles unchanged.DataSourceis retained as a deprecated alias ofIDataSource. The promotion also repairs a latent public-API defect: the barrel exportedDataSource, whosefindAllparameter isNormalizedQuery, whileNormalizedQueryitself was not exported — no consumer could name the type.@hono-enterprise/cloudflare-plugingainsD1Adapter(plusD1AdapterOptions,D1EntityMapping), constructed by the application from its D1 binding and handed toDatabasePlugin({ type: 'custom', adapter })— theKvSessionStoreprecedent, since those plugin options are read before any application exists. D1 has no interactive transaction: it rejectsBEGIN TRANSACTIONoutright, andbatch()is its only unit of atomicity.beginTransaction()therefore buffers every write and flushes the whole buffer as onebatch()at commit;rollback()discards it and sends nothing. Atomicity is genuine, and the two costs are documented and tested rather than left to discovery: there is no read-your-own-writes inside a transaction (reads run against committed state), and an in-transactioncreate()requires an explicit primary key, throwingCloudflareUnsupportedErrorwhen absent — a deferredINSERTcannot report a generated key to a caller that awaitscreate()before the flush. Outside a transactioncreate()usesRETURNING *and returns the real persisted row. Values are always bound (?N); identifiers cannot be, so table and column names are validated against[A-Za-z_][A-Za-z0-9_]*and double-quoted, and every builder refuses a statement that would exceed D1’s documented 100-bound-parameter limit. Not verified against live D1 — CI holds no Cloudflare account — though the whole surface is driven against a real SQLite engine, the engine D1 runs, including batch rollback. -
Cloudflare Queues, Cron Triggers, and the Cache API in
@hono-enterprise/cloudflare-plugin(Milestone 52b) — the three platform features that need a module-level handler export from the application’s Worker rather than anything reachable throughfetch. Nocommonchange and no new capability token.WorkersQueuesatisfies the committedIQueueover a Queues producer binding, opt-in through aqueuearm and registered underCAPABILITIES.QUEUE(orqueue.<name>); the job’s name and id travel in a{ v, name, id, data, maxAttempts? }envelope, because a Cloudflare message body is arbitrary JSON carrying neither andproducer.send()resolves tovoid, so the idaddreturns is the id the processor sees asjob.id.createQueueHandler(app)builds thequeueexport. A message whose body is not a readable envelope, or whose name has no processor, is retried rather than acked — acking would discard it permanently and silently, the failure a queue exists to prevent — andAddJobOptions.maxAttemptsis enforced at dispatch, since Cloudflare’smax_retriesis queue-wide configuration rather than per message.addRecurringthrows, naming Cron Triggers as the platform’s own mechanism. Cron Triggers ship asWorkersCronpluscreateScheduledHandler(cron), and deliberately do not registerCAPABILITIES.SCHEDULER: ofIScheduler’s eight methods onlycronis expressible on Workers —everyanddelayarm timers across an isolate eviction (the same reasonscheduler-plugincannot run there),pause/resume/removeneed state that does not survive an invocation, andgetNextRunis owned by thewrangler.toml[triggers]block. An implementation where six of eight methods throw would violate Liskov substitution, so a small honest surface was chosen instead. An expression is matched againstScheduledController.cronexactly, andexpressions()exists so an application can assert its own coverage againstwrangler.toml, which no code in the process can read.cacheApiMiddlewarecaches responses incaches.default. It is a different layer fromcache-plugin’scacheMiddlewareand composes with it, so it reports underX-Cache-Apirather thanX-Cache. The platform’s own refusals — non-GET, status 206,Vary: *, and an unclearedSet-Cookie— are checked first through the pure exportedassessCacheabilityrather than discovered from a thrownput; the 206 andVary: *rules are unconditional, because an operator may legitimately configurecacheableStatuses: [200, 206]and only the explicit rule then stops the platform throwing. The write ridesICloudflareBindings.waitUntilwhen the plugin is registered and is awaited inline when it is not, so it is never simply abandoned; with no cache handle at all the middleware passes through rather than throwing, so an application composed for several targets still serves off Workers. A HIT is replayed withIResponse.stream, so a cached response of any size reaches the client unbuffered — which meansapp.inject()cannot read it and cached routes are tested withapp.fetch.caches.defaultis per-datacenter: a latency optimisation, not a shared store. D1 as a database backend moved to Milestone 52c (it needs theIDatabaseAdapterseam promoted fromdatabase-pluginintocommon, plus reconcilingITransactionwith D1’s batch-only atomicity) and Durable Objects to Milestone 52d (both the realtime backplane and the distributed lock need the application to export a DO class, and Durable Objects expose no pub/sub primitive, so a backplane means each replica holding a WebSocket to the object). -
@hono-enterprise/cloudflare-plugin(Milestone 52) — a new package registeringICloudflareBindingsunder a newCAPABILITIES.CLOUDFLAREtoken. The framework has served traffic on Workers since the Hono migration but could not reach a single platform binding; this publishes them as one typed accessor (kv,r2,d1,queue,service,durableObject,get<T>,vars,waitUntil), and optionally serves the committed cache and storage capabilities from KV and R2. Zero npm dependencies, and nothing in the package importscloudflare:workers— the application passesenv(andwaitUntil) in, which keeps the package type-checkable on every runtime.KvCacheStorereconcilesICacheStore’s unbounded TTL with KV’s 60-secondexpirationTtlfloor by carrying a logical deadline inside the value, so a 5-second entry expires in 5 seconds rather than surviving a minute. The decoder reports three outcomes rather than two — live, this store’s expired entry, and neither — so a read never deletes a key the store does not own and a deliberately cachednullsurvives;clear()additionally requires a key prefix, because the binding has no bulk delete and an unprefixed sweep would remove foreign keys.R2Storageimplements the optionalgetStream, heads beforedeleteso its committedPromise<boolean>is honest, and throws fromgetSignedUrl— the R2 Workers binding has no presign operation.KvSessionStoreis constructed by the application and handed toSessionPlugin({ store }), since that option is read before any application exists. No binding I/O happens at registration, where the platform forbids it, and thecloudflarehealth indicator performs none either. -
splitWorkerEnvandSplitWorkerEnvin@hono-enterprise/common(Milestone 52) — the pure partition of a Workersenvrecord into string variables and object bindings. Incommonbecause bothruntimeandcloudflare-pluginneed the identical rule and no plugin may import another. -
@hono-enterprise/service-discovery-plugin(Milestone 50) — a new package registering anIServiceDiscoveryunder a newCAPABILITIES.SERVICE_DISCOVERYtoken, so an application can turn a logical service name into a reachable address. Five provider arms —'static','consul','kubernetes'(EndpointSlices),'dns'(SRVand address records), and'custom'— behind oneDiscoveryProviderport, with the option type a union discriminated onproviderso a missing per-arm credential is a compile error rather than a startup throw. Zero npm dependencies: the HTTP providers run on web-standardfetchand the DNS provider on the new optionalIRuntimeServices.dns. Adds a monotonic-clock read-through cache with per-service in-flight coalescing and stale-on-failure; push-basedwatch()over Consul blocking queries (both documented index hazards handled — a backwards index resets to zero, an index of0becomes1to avoid busy-looping older servers) and Kubernetes watch streams (used as a change signal rather than a delta log, with410 Goneresync); three balancing strategies overIRuntimeServices.randomBytes; and outlier ejection with a panic-threshold cap and an all-ejected fallback. Ejection is deliberately not a second circuit breaker:wrapbreaks a call site, ejection removes a pool member while the call site stays open. -
IServiceDiscovery,ServiceInstance,PickOptions,LoadBalanceStrategy,ServiceOutcomeandCAPABILITIES.SERVICE_DISCOVERYin@hono-enterprise/common(Milestone 50) — the contract a consumer types the resolved capability as, without importing the plugin. -
IRuntimeServices.dns?: IDnsResolverandSrvRecordin@hono-enterprise/common, withcreateNodeDnsResolver(Node + Bun, overnode:dns/promises) andcreateDenoDnsResolver(overDeno.resolveDns) exported from@hono-enterprise/runtime(Milestone 50). Purely additive, following thefs?/workers?precedent; Cloudflare Workers omits the key entirely, since its network access isfetch, which resolves names internally and exposes no lookup surface.SrvRecord.hostis a normalized name on purpose — Deno spells the fieldtarget, Node spells itname, and passing either through unchanged would type-check on both runtimes while producingundefinedhostnames on one. -
ILifecycleApi.onStoppingin@hono-enterprise/commonand@hono-enterprise/kernel(Milestone 50) — a new lifecycle phase running at the very start ofstop(), before the application begins refusing new requests and before the socket closes. It is the only hook that fires while the application is still serving normally, which is what makes it correct for deregistering from a service registry: doing that inonShutdownleaves callers routed at a closed port for up to one health-check interval on every rolling deploy. Listed under Added rather than Changed because no existing behavior moves —Application.#doStop()skips the phase entirely when no hook is registered, sostop()is byte-for-byte unchanged for every application that does not opt in. (Awaiting an already-resolved promise instead would still defer when the shutting-down flag flips, handing a 404 to a request that used to get a 503 — a pre-existing kernel test caught exactly that.) -
honoe new --template full-stack(Milestone 36c) — scaffolds a React Router 8 SSR application: theroutes → features → services → modelslayering,flatRoutes_app/_authlayout groups, the~/*alias, the.server.tsconvention, one worked feature, and the Vite build files. What it deliberately does not emit is as important as what it does: nolib/session.server.ts,lib/csrf.server.ts,lib/sse.server.ts,lib/kv.server.tsorlib/service-logger.server.ts, because those are the session, SSE, secrets and logger capabilities, reached through the service registry the SSR plugin attaches to every request. The session reaches loaders through a context key the application declares andpopulateLoadContextfills, so no plugin imports another. Every runtime target is supported; Cloudflare Workers omitsassetsDirand leaves assets to the platform binding. This is the only template that composes through a starter rather than inline wiring — its plugin set is twenty-two, and a generated file a human is meant to edit should not open with twenty-two imports they did not choose. -
contextKeyForin@hono-enterprise/react-router-plugin(Milestone 36c) — creates React Router context keys by name, memoised, so the same name always yields the same object. Keys are matched by identity, and in a framework-mode application the module declaring them exists twice: Vite inlines application modules into the server build, while the runtime loadshonoe.config.tsfrom source. Two hand-written{ defaultValue }literals then match nothing, and every read silently returns the default — a session that is alwaysnull, a CSRF token that is always empty, with no error raised. Requires the server build to treat@hono-enterprise/*as external (environments.ssr.build.rollupOptions.external), which thefull-stacktemplate configures. TheserverBuildPathJSDoc now also states that the path must be absolute: the loader doesawait import(serverBuildPath), so a relative specifier resolves against the plugin’s own module and can never find the application’s build. -
createFullStackAppFromConfigin@hono-enterprise/full-stack-starter(Milestone 36c) —(build: (config: IConfig) => FullStackStarterOptions, options?: FromConfigOptions) => Promise<IKernelApplication>, whereFromConfigOptionscarriesconfig(loading options) andenv.envis required on Cloudflare Workers, where bindings arrive per request rather than process-wide, so without it the application composes from an empty configuration and fails on every request; thefull-stacktemplate threads the handler’senvthrough automatically. Plugin options must be decided before the plugins are constructed, which is beforeConfigPluginhas registered anything; this loads configuration once, hands the snapshot to the resolver, and passes that same object into the application, so the values the composition branched on are the values handlers read. It applies to every option uniformly, which is why no plugin option carries aurlFromConfig-style config-key field — such a field would need its value at the same impossible moment. Secrets remain out of reach by construction: they are served by a plugin that exists only after registration. -
loadConfigandConfigPluginOptions.instancein@hono-enterprise/config-plugin(Milestone 36c) —loadConfig(runtime, options?)is the same implementationConfigPluginregisters, reachable without an application;instanceregisters a supplied snapshot verbatim, reading nothing from the environment.ConfigPlugin.registernow delegates toloadConfig, so merging, expansion, and validation cannot drift between the two paths. -
createRuntimeServicesin@hono-enterprise/runtime(Milestone 36c) — buildsIRuntimeServicesfor the detected platform without an application. The barrel previously exporteddetectRuntimeand four per-platform factories but nothing joining them, so the platform → adapter map was unreachable outsideRuntimePlugin.register; that method now delegates here, leaving one implementation behind two entry points.RuntimeAdapterFactoriesandCreateRuntimeServicesOptionsare exported alongside it. -
Gated
sessionarm on the three starters (Milestone 36c) —RestStarterOptions.sessionregistersSessionPlugin, inherited by the microservice and full-stack tiers. M48 shipped after the starters, so no tier could previously register a session at all. Gated because the plugin throws duringregister()without an adequate secret; no default changes. -
Parameter-level
@Injectin@hono-enterprise/decorator-plugin(Milestone 36b) —Injectnow works on a constructor parameter as well as on the class, binding one token to that argument by position, which is the form a developer arriving from NestJS expects:constructor(@Inject(CAPABILITIES.DATABASE) private db: IDatabase) {}. The class-level positional list is deprecated, not removed (AI_GUIDELINES §9.2) and keeps working for the whole0.xline. A token is still always required — inferring it from the parameter’s type needsemitDecoratorMetadata, which Deno does not support — so three ambiguous cases throw at startup rather than misinjecting silently: mixing the two forms on one class, leaving a constructor parameter undecorated below the last injected one, and applying@Injectto a method parameter. -
Gated
realtimeanddiarms on the three starters (Milestone 36b) —realtimegroupswebsocket,sse, andbackplanesub-arms;diaddsDiPlugin. Added toRestStarterOptions, so the microservice and full-stack tiers inherit them. No default changes: with no arm supplied the plugin set of all three tiers is byte-identical to the previous release. Supplyingdidoes change how decorated services are constructed (DecoratorPluginswitches to its container path), which is why it is opt-in.RealtimeArmis exported from all three starter barrels. -
honoe new --template nest(Milestone 36b) — the REST plugin set plusDiPlugin, an@Injectableservice, and a@Controllerusing parameter-level@Inject. Emits inline wiring like the other templates, and refuses no runtime target. -
Wiring.args,TemplateDefinition.localImports, andTemplateDefinition.filesin@hono-enterprise/cli— the template contract could previously express neither a plugin call argument nor an extra emitted source file. All three are optional and every existing template renders byte-identically (argsabsent →Symbol()). -
@hono-enterprise/session-plugin(Milestone 48) — cookie-backed sessions and session-backed form CSRF, registering anISessionServiceunder the newCAPABILITIES.SESSIONtoken. The default is a self-contained encrypted cookie (AES-256-GCM under an HKDF-SHA256 derived key, entirely throughruntime.subtle), so the package has zero npm dependencies and works on Cloudflare Workers. Settingstore('memory','cache', or a customISessionStore) moves the payload server-side and leaves an opaque id in the cookie, which is what makes immediate revocation possible. Secret rotation goes through a key list — index 0 seals, every entry opens — with an HKDF-derived non-secretkidin the envelope so opening is a lookup rather than trial decryption. ShipsgetSession,getCsrfToken,verifyCsrfToken,csrfFormMiddleware,sessionMiddleware, both stores, and four error types. Note thatmode: 'sign'protects integrity only and leaves the payload readable by the client;'encrypt'is the default so that choice is never accidental. -
parseCookie/serializeCookie/CookieAttributesin@hono-enterprise/common— the framework’s single cookie codec. It lives incommonbecause the session plugin and the decorator plugin’s@Cookieboth need it and no plugin may import another (theencodeFrameDataprecedent). -
ISessionService/ISession/ISessionStore/SessionDatacontracts andCAPABILITIES.SESSIONin@hono-enterprise/common. NoIRequestwidening was needed: the session middleware parks the session inctx.state, so acookiesfield with no consumer was declined. -
scalingNoticeoption onWebSocketPluginOptionsandSsePluginOptions(boolean, defaulttrue) — setfalseto silence the startup notice described below, for a deployment where you have decided single-replica fan-out is correct and do not want the line on every boot. It suppresses the message only: room and channel delivery are identical either way, and the notice never appears once a backplane is registered.
Changed
-
runtime.envis now populated on Cloudflare Workers (Milestone 52).RuntimePluginandcreateRuntimeServicesgain anenvoption; passing the Worker’senvmakesConfigPluginand the secretsEnvProviderwork on the edge, where previously they read an empty record. Only string entries reachruntime.env, which is contracted as a string record — object bindings are filtered out rather than stringified to[object Object]. Behaviour on Deno, Node, and Bun is unchanged; the option is ignored there. -
honoe new --runtime cloudflare-workers(Milestone 52) now threadsenvfrom thefetchhandler intocreateApp(env)and rendersRuntimePlugin({ env })on that target, bumps the scaffoldedcompatibility_dateto2025-09-01(import { waitUntil } from 'cloudflare:workers'shipped 2025-08-08, so the previous2024-09-23could not import it), and emits commented[[kv_namespaces]]/[[r2_buckets]]stanzas inwrangler.toml. Generated output for the Deno, Node, and Bun targets is unchanged. -
DecoratorPluginnow prefers the DI container for any class registered in it, with or without@Injectable.instantiate()required service metadata before consulting the container, so a@Controller— which carries no@Injectable— took the service-registry path even in an application withDiPlugin, where its dependencies live in the container and not the registry, and construction failed outright with “No service registered for capability”. The guard contradicted the function’s own documented behavior. Reachable before this release only for a controller whose constructor took arguments; parameter-level@Injectmakes that composition ordinary, which is how it surfaced. -
decorator-plugin’s exportedparseCookiesnow delegates tocommon’sparseCookie, which changes its output in three cases. The signature is unchanged and no call site needs editing, but the values it returns can differ, so read this if you use@Cookieor callparseCookiesdirectly. Each difference is a defect fix rather than a preference:- Values are percent-decoded. A cookie written by any standards-compliant server (including
this framework’s own
serializeCookie) was previously returned still-encoded —@Cookie('x')handed youa%20bwhere the value wasa b. If you were decoding the result yourself, remove that step; double-decoding will now corrupt a value containing a literal%. - One layer of RFC 6265 quoting is stripped, so
sid="abc"yieldsabcrather than"abc". - A repeated cookie name resolves to the first occurrence, not the last. Browsers send the most specific cookie first, so the first is the one that was meant.
The alternative was two cookie parsers in the tree, which AI_GUIDELINES §11.1 forbids. Shipping the correction during
0.1.xpre-release rather than freezing the defect follows the precedent of the Milestone 14d wire change and the Milestone 30b FCM replacement. - Values are percent-decoded. A cookie written by any standards-compliant server (including
this framework’s own
-
websocket-pluginandsse-pluginnow say at startup that rooms and channels are process-local when no realtime backplane is registered — oneinfoline naming the limitation, the plugin that lifts it, and the transport it needs ('redis'or'messaging'; the backplane plugin’s default'memory'transport is a single-process bus, so registering it bare would silence the notice without fanning anything out). Cross-replica fan-out has shipped since0.1.0-alpha.3, but a single-replica app and a three-replica app behave identically right up to the point where two thirds of your clients silently stop receiving broadcasts, with no error raised anywhere. Both READMEs gain a Scaling beyond one replica section for the same reason. If you run a single replica the line is informational and safe to ignore; registering a backplane under theREALTIME_BACKPLANEtoken removes it.
Fixed
-
DatabasePlugin({ options: { logQueries: true } })threw on every repository call whenever a real logger was registered (found in Milestone 52c).resolveLoggerextractedlogger.debuginto a local and invoked it detached, sothiswasundefinedat the call. Both loggerslogger-pluginships —ConsoleLoggerandPinoLogger— implementdebugin terms of a private#field, and a private-field access on an unbound method throwsTypeError, so the documentedlogQueriesoption could not be used at all withLoggerPluginpresent. Every existing test injected a plain-object logger, where a detached method works fine, which is exactly why no gate saw it.cache-plugincarries a regression test for the identical bug;database-pluginnow has one too, driving the realConsoleLoggerthrough a running kernel application. -
Every application failed to boot on Cloudflare Workers —
packages/kernel’s request-context factory built its never-abortingctx.signalsentinel from a module-scopenew AbortController(). workerd refuses that withDisallowed operation called within global scope, because anAbortControlleris bound to an I/O context, so the isolate threw at import time and no handler ever ran. Introduced withIRequestContext.signalin Milestone 42 and invisible to every gate: the whole suite runs on Deno, where a module-scope controller is legal, and the Workers path had only ever been exercised throughapp.fetchunder Deno rather than under the real runtime. Found by driving the framework underwrangler dev(workerd) for the first time. The sentinel is now constructed per request; caching one lazily would not have been a fix either, since workerd then refuses to use a controller created for one request on behalf of another. A regression test pins that two contexts never share a fallback signal — it fails against the previous code. -
@hono-enterprise/cloudflare-pluginqueue reporting reaches a logger registered after the plugin (Milestone 52b) —WorkersQueueOptions.loggeris a thunk rather than anILogger, for the reasonresolveWaitUntilalready takes one:ctx.loggerresolves lazily through a Proxy that answersundefineduntil a logger is registered, and a capability may be registered imperatively with noprovidesdeclaration for the resolver to order against. Capturing the value duringregister()would silence every dispatch report in an application whose logger registers later. -
honoe newnow refuses a project plan containing the same path twice (Milestone 36c). The overwrite check probes the filesystem, so it could not see a duplicate inside one plan: both files were written and the last silently won. A template emittingdeno.jsonwould have overwritten the framework manifest with no warning. -
The CLI drift gate resolved starter packages to the wrong directory (Milestone 36c). It mapped
@hono-enterprise/<name>topackages/<name>, but the three starters live underpackages/starters/, so any template importing one could not be type-checked. It also rewrote every import-map entry, mangling a template’s project-local alias (~/) into a package path. -
websocket-plugin’s README no longer claims cross-replica fan-out is unimplemented. It stated “fan-out across replicas is a follow-up milestone; today two instances behind a load balancer do not share rooms”, which stopped being true whenrealtime-backplane-pluginshipped in0.1.0-alpha.3. -
sse-plugin’s README named a method that does not exist. Its named-channels example calledchannel.broadcast(...); the committedSseChannelcontract exposespublish(...)and nobroadcast, so the snippet would not compile. -
realtime-backplane-plugin:RedisBackplane.connect()no longer leaks a connection on a failed open, and is safe to call concurrently. The connected guard was only set after both connections had been constructed, so two overlapping calls each built their own pair — and if the second construction threw, the first connection was already live with nothing holding a reference to close it. The open is now memoized, so overlapping callers join one attempt and none of them returns beforeSUBSCRIBEhas actually landed; a failed attempt quits whatever it built, removes its own listener from injected clients but does not close them (they belong to the caller), and clears the memo so a later call retries. Aclose()arriving mid-open now wins as well: the open retires whatever it built instead of publishing two live connections onto a backplane that has already shut down, which is what a shutdown during startup would otherwise strand.RealtimeBackplanePlugincallsconnect()exactly once andclose()only fromonClose, so no application behavior changes — this closes the seam for callers driving the transport directly.
Known limitations
The Cloudflare surface has not been verified against a deployed Worker. CI holds no Cloudflare
account, so nothing in the pipeline reaches the platform. It was driven against real workerd via
wrangler dev during development — Queues with a real MessageBatch, a real ScheduledController,
caches.default, KV, R2, D1, and both Durable Object classes including a real WebSocketPair
upgrade and the storage input gate — and that harness is what caught a kernel defect that broke
every application on Workers (fixed here). But those runs were manual and the harness is not
committed, so treat the edge story as well-exercised rather than continuously gated, and verify
against your own account before you depend on it.
FCM push still has not been exercised against live FCM, unchanged from 0.1.0-alpha.3. The HTTP
v1 request is asserted field by field and its RS256 assertion is signed and verified with real Web
Crypto, but no test reaches Google.
D1 transactions are deferred batches, with two consequences worth knowing before you use them.
D1 rejects BEGIN TRANSACTION outright and batch() is its only unit of atomicity, so writes
buffer until commit and flush as one batch. Inside a transaction there is no
read-your-own-writes — reads see committed state — and create() requires an explicit primary
key, because a deferred insert cannot report a generated key to a caller awaiting it before the
flush. Both are documented in PUBLIC_API.md and covered by tests; neither applies outside a
transaction.
All 46 packages are live on JSR at 0.1.0-alpha.4, published by CI from the tag.
Verified after publishing by querying every package on the registry, then installing kernel and
runtime from JSR into a throwaway directory — not the workspace, whose import map resolves locally
and would mask a broken published dependency — and serving a request (200 {"ok":true}). common
resolved transitively at 0.1.0-alpha.4, which is the only real evidence that the cross-package
specifier bump landed inside the published tarballs: a dry run resolves those from the workspace and
so cannot show it.
Every package also carries a description and runtime-compat flags for the first time. Neither
setting lives in a published version — deno publish never touches them — so all 46 pages had shown
an empty description and “Compatibility unknown” through four releases. They are now set from
scripts/jsr-metadata.ts and reapplied by deno task release:set-metadata, which is idempotent and
refuses to run when a published package has no entry.
Installing
deno add jsr:@hono-enterprise/kernel@^0.1.0-alpha.4
deno install -g -A -n honoe jsr:@hono-enterprise/cli@^0.1.0-alpha.4/main
Within 24 hours of a release, Deno’s minimum-dependency-age policy refuses the version unless you
pass --min-dep-age 0.
0.1.0-alpha.3 — 2026-07-30
Two breaking changes ship in this release. Both are narrow, but you meet them in production rather than in this file, so they are stated here in full and again under Changed.
⚠️ Breaking 1 of 2: brokered request-reply changes on the wire.
request()/respond()move from<topic>to a derivedrr.req.<topic>channel, so a responder running0.1.0-alpha.2and a caller running this version will not talk to each other. RPC callers and responders must be restarted together, not rolled one at a time. Fire-and-forgetpublish/subscribeare unaffected, as is every other plugin. If you do not userequest/respond, nothing here applies to you.
⚠️ Breaking 2 of 2: the FCM push channel takes service-account credentials.
FcmProviderOptions.serverKeyis replaced by{ projectId, clientEmail, privateKey }, so existing config stops compiling. That is deliberate rather than a deprecation:serverKeyaddressed the legacy endpoint Google switched off in 2024, so every push sent through it already failed. A compile error is the only honest signal. If you do not configure apushchannel, nothing here applies to you.
Kafka gains request-reply, and five of the known limitations recorded against 0.1.0-alpha.1 are
closed. Every entry below was a real capability gap rather than a documentation problem, so each
is fixed in code; the alpha.1 list annotates them in place rather than deleting them, because that
section records what was true of that release.
Kafka was not the reason Kafka lacked request-reply: the shared request-reply core minted its own
inbox topic and imposed it on every broker, which only works where topics are cheap and
per-instance-addressable. Brokers now supply their own reply inbox, so Kafka can read a shared reply
topic under a per-instance consumer group instead. The same seam is where a future native AMQP
replyTo or NATS JetStream reply-subject transport would plug in. Two defects in the M14c
implementation are fixed alongside it, both consequences of RPC sharing a topic with ordinary
pub/sub.
Alongside that, WebSocket rooms and SSE channels gain cross-replica fan-out, feature-flags-plugin
gains a LaunchDarkly provider, and resilience-plugin timeouts finally cancel the work they bound.
Added
-
Kafka now supports brokered request-reply.
KafkaBroker.request/respondpreviously rejected outright; all five brokers are now reply-capable. Replies travel on a shared reply topic — the newreplyTopicoption, default'messaging.replies'— read by a consumer group unique to each broker instance, so delivery is exclusive to the caller rather than load-balanced across the shared default group. The reply topic must already exist:IKafkaFactoryexposes no admin surface, so the broker creates no topics. Every instance receives every reply and discards those it did not originate; give a high-traffic service its ownreplyTopicto bound that fan-out. -
Each broker now supplies its own reply inbox through an internal seam, rather than having a topic string imposed on it by the shared request-reply core. The four brokers that were already reply-capable pass a shared helper and are behaviourally unchanged.
-
notification-pluginpush delivery works again, on FCM HTTP v1.FcmProvidernow posts to/v1/projects/{projectId}/messages:sendwith an OAuth2 bearer token minted from a service account: it signs an RS256 JWT assertion withruntime.subtleand caches the token until shortly before expiry, so a send costs one request in the steady state. Zero npm dependencies and Workers-portable, like the other HTTP providers. A newFcmTokenSourceexport lets you source tokens elsewhere (a GCP metadata server, a key-holding broker) instead of from a local key. -
@hono-enterprise/realtime-backplane-plugin— cross-replica fan-out for WebSocket rooms and SSE channels. It registers anIRealtimeBackplaneunder the newCAPABILITIES.REALTIME_BACKPLANEtoken, whichwebsocket-pluginandsse-pluginresolve optionally — so adding the plugin is the entire change needed to makews.room('lobby')andsse.channel('news')reach clients on other replicas, and removing it restores in-process behavior with no application change. Four transports:'memory'(the default, and a real single-process bus rather than a no-op),'messaging'(over whatever broker is registered underCAPABILITIES.MESSAGING, reusing all five existing brokers with no new dependency),'redis'(pub/sub over an inject-or-lazyioredis), and'custom'.Correction, made in
0.1.0-alpha.4. “Adding the plugin is the entire change needed” is wrong, and the sentence is left in place because this section records what the release said.RealtimeBackplanePlugin()defaults totransport: 'memory', a single-process bus, so registering it bare fans nothing out across replicas. You must also choose'redis'or'messaging'. The memory-default caveat did appear two sentences later, but the headline is what a reader acts on. The same looseness was corrected in the plugin log messages and both plugin READMEs by PR #102; this was the last remaining copy. -
A LaunchDarkly provider for
@hono-enterprise/feature-flags-plugin(provider: 'launchdarkly'), plus an optionalIFeatureFlags.isEnabledAsyncfor callers that can await an answer carrying no cold-context caveat. -
Real cancellation in
@hono-enterprise/resilience-plugin:wraphands the protected call anAbortSignal, and the returned callable accepts an optional caller-owned one. -
@hono-enterprise/sdk— the client SDK publishes for the first time. Together with the realtime backplane above, that brings the published total to 38 packages. A portable, zero-npm-dependency HTTP client for consuming a Hono Enterprise API from a browser or a server:createClient()returns anIHttpClientwith onerequest<TResponse, TBody>()method, plus bearer and API-key request-interceptor factories, request/response interceptors, retry with fixed/exponential backoff honoring a delta-secondsRetry-After, a rolling-window circuit breaker, and a sliding-window rate limiter. Both the transport (fetch) and time (IClientTiming) are injectable seams, so nothing needs a network or a real clock to test. It registers no plugin and resolves no capability token — its only in-repo import is type-level from@hono-enterprise/common, which re-exportsRetryPolicy,CircuitBreakerPolicy, andBackoffStrategythrough the SDK barrel so consumers need not depend oncommondirectly.generateOpenApiClient(document, options?)is a pure function turning an OpenAPI 3.1 document into type-checked TypeScript client source; it throwsOpenApiCodegenErrorwith the offending path and method rather than emitting a client that misbehaves or will not compile.
Changed
-
BREAKING:
FcmProviderOptions.serverKeyis replaced by service-account fields. The push channel now takes{ projectId, clientEmail, privateKey }(or atokenSource) instead ofserverKey. This is not a deprecation:serverKeyaddressed an endpoint Google switched off in 2024, so every send through it already failed. Existing config becomes a compile error, which is the intended signal.// Before — never reached a live endpoint push: { provider: 'fcm', options: { serverKey: config.get('FCM_SERVER_KEY') } } // After — values come from the service-account JSON push: { provider: 'fcm', options: { projectId: config.get('FCM_PROJECT_ID'), clientEmail: config.get('FCM_CLIENT_EMAIL'), privateKey: config.get('FCM_PRIVATE_KEY'), }, }A
pushchannel using the default signer now needsCAPABILITIES.RUNTIME(for Web Crypto and the clock) and throws duringregisterwithout it, rather than failing on the first notification. -
BREAKING (wire format): request-reply traffic moved to a derived channel.
request(topic, …)now publishes to, andrespond(topic, …)subscribes to,rr.req.<topic>instead of<topic>. A0.1.0-alpha.2responder and a later requester do not interoperate — during an upgrade, restart RPC responders and callers together rather than rolling them one at a time. Fire-and-forgetpublish/subscribeare unaffected. -
IResilienceService.wrapandICircuitBreaker.executewidened.wrap<T>now takes aResilientCall<T>((signal: AbortSignal) => Promise<T>) and returns aHardenedCall<T>((signal?: AbortSignal) => Promise<T>). Source-compatible for callers — a zero-argument() => Promise<T>is still accepted andawait guarded()still works — but breaking for implementors, becausefnsits in a contravariant position, so an object literal declaringwrap<T>(fn: () => Promise<T>)no longer satisfies the interface. Implementors add the parameter. -
websocket-pluginandsse-pluginregister()are now async, awaiting the optional backplane subscription. The kernel already awaited an asyncregister, so applications are unaffected; a test callingplugin.register(ctx)directly must now await it.
Fixed
- Request envelopes leaked into plain subscribers. A responder shared the raw topic with
pub/sub, so a
subscribe('orders', …)handler received the rawrr-requestenvelope instead of the payload. Separate channels fix this at the routing layer. - A responder could swallow a competing consumer’s message. Where a responder and an ordinary subscriber shared a topic and a queue (competing-consumer delivery), the responder consumed its share of the round-robin and its envelope guard discarded anything that was not a request — the message vanished with no signal. Fan-out subscribers were unaffected.
- The reply inbox subscribed without a queue name, so on a broker that falls back to a shared consumer group (Kafka) replies could be delivered to a different instance than the caller, which then discarded them by correlation-id lookup — surfacing as an unexplained timeout. Each inbox now claims its own queue.
- Resilience timeouts cancel the work they bound.
timeoutraced the protected call against a timer and left it running; it now aborts the call’s signal with the sameTimeoutErrorinstance it rejects with, so a call that forwards the signal to its I/O genuinely stops. Retry stops looping on abort and wakes its backoff early — that sleep also no longer leaks a timer handle on every attempt — and a bulkhead waiter cancelled while queued leaves the queue and never runs its call. - Six package pages on jsr.io now show their README.
cli,feature-flags-plugin,multi-tenancy-plugin,openapi-plugin,queue-plugin, andstorage-pluginrendered a one-line blurb instead in0.1.0-alpha.2. Not a packaging fault —/README.mdwas in every published tarball. JSR’sreadmeSourcedefaults tojsdocand falls back to README.md only when the entrypoint’s module doc has no description; deno_doc drops prose that follows a tag, so a block opening with@modulehas no description and the README renders, while one whose description comes first and ends with@modulereplaces the whole page. Those six were the only description-first entrypoints.deno task release:verifynow enforces@module-first, because nothing else sees this: the README ships in the tarball, so every gate anddeno publish --dry-runstay green and the loss shows up only on jsr.io.
Deprecated
MessagingNotSupportedError— no broker throws it now that Kafka implements request-reply. The export is retained soinstanceofchecks written againstalpha.1/alpha.2keep compiling, and will be removed in the next major. Nothing replaces it; delete the branch.
Notes
One real-time limitation remains, documented rather than silently approximated: Room.size /
SseChannel.size report local membership. A cluster-wide count is inherently asynchronous — it
needs a scatter-gather across replicas — so it cannot satisfy the synchronous committed size
getter and wants a separate async method. That is a later milestone.
RoomBroadcastOptions.except is honored cluster-wide: connection IDs come from runtime.uuid()
and are globally unique, so the frame carries the excluded ID and every replica skips it.
A call that ignores its AbortSignal still runs to completion; cancellation is cooperative, and the
widened JSDoc says so.
FCM push has not been exercised against live FCM. The HTTP v1 rewrite is asserted field by field
— request URL, headers, and body shape — and its RS256 assertion is signed and verified with real
Web Crypto, but no test reaches Google: CI holds no Firebase project. The endpoint and auth scheme
follow Google’s documented HTTP v1 contract, and the previous serverKey path was provably dead, so
this is strictly an improvement — but if you depend on push, verify it against your own project
before you rely on it, and please report what you find.
All 38 packages are live on JSR at 0.1.0-alpha.3.
Verified after publishing by querying every package on the registry, then installing kernel and
runtime from JSR into a throwaway directory — not the workspace, whose import map resolves locally
and would mask a broken published dependency — and serving a request (200 {"ok":true}). common
resolved transitively at 0.1.0-alpha.3, which is the only real evidence that the cross-package
specifier bump landed inside the published tarballs: a dry run resolves those from the workspace and
so cannot show it.
The six package pages that shipped 0.1.0-alpha.2 without a visible README were re-checked and now
render theirs. The same check run against their 0.1.0-alpha.2 pages still finds no README content,
so it distinguishes the two rather than passing vacuously.
Installing
deno add jsr:@hono-enterprise/kernel@^0.1.0-alpha.3
deno install -g -A -n honoe jsr:@hono-enterprise/cli@^0.1.0-alpha.3/main
Within 24 hours of a release, Deno’s minimum-dependency-age policy refuses the version unless you
pass --min-dep-age 0.
0.1.0-alpha.2 — 2026-07-28
Adds the CLI. @hono-enterprise/cli publishes for the first time, bringing the total to 36
packages. Every other package is version-bumped so the scope stays on one version — the CLI needs
this, because honoe new stamps generated projects with its OWN version as the range for kernel,
runtime, common, and every template plugin. A CLI at alpha.2 alongside a framework at
alpha.1 would scaffold projects pinning versions that do not exist.
Added
@hono-enterprise/cli— thehonoecommand: project scaffolding (honoe new, with--template rest|microserviceand--runtime deno|node|bun|cloudflare-workers), 13 plugin-aware code-generation schematics, custom schematics, and dispatch of plugin-registered commands (honoe commands,honoe db:migrate).
Fixed
- Package READMEs linked
PUBLIC_API.mdrelatively (../../PUBLIC_API.md). JSR resolves a README’s relative links againstjsr.io/@hono-enterprise/, so every such link 400’d with “package name must contain only lowercase ascii alphanumeric characters and hyphens”. All 44 relative links across 28 package READMEs now use absolute GitHub URLs. ICliApi’s JSDoc described a contract with no consumer; the CLI now reads it.
All 36 packages are live on JSR at 0.1.0-alpha.2.
Verified after publishing by installing honoe from JSR into a clean directory — not the workspace,
whose import map resolves locally — scaffolding a rest project with it, generating a controller,
type-checking the result against the published packages, then starting it and serving / (200),
/health (status: up), and /metrics.
The release pipeline, which had never worked
This was the first release published by CI. 0.1.0-alpha.1 went out by hand from a terminal,
because the tag-triggered workflow failed on every attempt. Three separate causes, each only visible
by running it:
- The publish step lacked
--allow-env.publish-packages.tsreadsJSR_TOKENat startup (left unset in CI so the runner’s OIDC identity authenticates instead) and died before touching a package. The root cause was duplication:deno.json’srelease:publishtask always carried the right permissions, but the workflow inlined its owndeno runand that copy drifted. The workflow now calls the task. - It also lacked
--allow-net. The already-published check that makes a resumed release idempotent fetches jsr.io — and it is skipped under--dry-run, so a passing dry run proves nothing about a real run. - No package was linked to the GitHub repository. JSR accepts a GitHub Actions OIDC identity
only for a package it knows belongs to the repo; without the link,
deno publishuploads and then fails withactorNotAuthorized. Token-based publishing does not need the link, which is why0.1.0-alpha.1never surfaced it.deno task release:link-reposnow does all 36 through the API.
None of the three published anything, so the tag stayed re-runnable throughout.
Installing
deno add jsr:@hono-enterprise/kernel@^0.1.0-alpha.2
deno install -g -A -n honoe jsr:@hono-enterprise/cli@^0.1.0-alpha.2/main
Within 24 hours of a release, Deno’s minimum-dependency-age policy refuses the version unless you
pass --min-dep-age 0.
0.1.0-alpha.1 — 2026-07-26
First public prerelease. The framework’s kernel, runtime layer, and 30 plugins are implemented
and tested; they publish to JSR under the @hono-enterprise scope.
This is an alpha. The public API is not frozen, and breaking changes may land in any subsequent prerelease without a major-version bump. Do not use it in production.
All 35 packages are live on JSR at 0.1.0-alpha.1.
Verified after publishing by installing kernel, runtime, metrics-plugin, and
telemetry-plugin from JSR into a clean project — not the workspace, whose import map resolves
locally — starting an application, serving a request (200), and scraping the /metrics endpoint.
The release took two attempts. A JSR scope may create only 20 new packages per rolling 7-day window
by default; the first run created 20 and stopped. JSR raised the quota to 40 on request, and the
remaining 15 followed. Both halves carry the same version, and the publish order guarantees common
and kernel land before anything that depends on them, so the intermediate state was never
inconsistent.
Installing a prerelease
JSR does not tag a prerelease as latest, so every specifier must be version-pinned:
deno add jsr:@hono-enterprise/kernel@^0.1.0-alpha.1
A bare deno add jsr:@hono-enterprise/kernel fails with “has only pre-release versions
available”. Within 24 hours of a release, Deno’s minimum-dependency-age policy additionally refuses
the version unless you pass --min-dep-age 0.
All packages in this release
35 packages, all at 0.1.0-alpha.1:
Core — common, kernel, runtime, exceptions, testing
Request path — logger-plugin, config-plugin, validation-plugin, http-security-plugin,
auth-plugin
Data — database-plugin, cache-plugin, storage-plugin, multi-tenancy-plugin
Messaging & work — events-plugin, cqrs-plugin, messaging-plugin, queue-plugin,
scheduler-plugin, worker-pool-plugin
Real-time — sse-plugin, websocket-plugin, react-router-plugin
Operations — metrics-plugin, health-plugin, telemetry-plugin, audit-plugin,
resilience-plugin, secrets-plugin
Delivery — mail-plugin, notification-plugin, feature-flags-plugin
Optional ergonomics — di-plugin, decorator-plugin, openapi-plugin
Deliberately excluded
@hono-enterprise/cli, @hono-enterprise/sdk, and the three starter bundles (rest-starter,
microservice-starter, full-stack-starter) are not part of this release. They are stubs that
export nothing; publishing them would put empty pages on JSR, where versions are immutable. They
ship when their milestones land (the CLI is Milestone 34).
Runtime support
Node.js, Deno, Bun, and Cloudflare Workers, via Hono’s fetch entry point and the runtime’s HTTP
adapters. Individual plugins document their own constraints — SMTP needs raw sockets, worker pools
need real threads, and neither exists on Workers.
Optional heavy dependencies (Prisma, ioredis, amqplib, kafkajs, nodemailer, the OTel SDK, ws, …)
are never hard dependencies. Each is injected through plugin options or imported lazily via an
npm: specifier, so an application only pays for what it configures.
Known limitations
Five entries in this list have since been closed; each is annotated in place rather than deleted, because this section records what was true of this release. See 0.1.0-alpha.3 for the work that closed them.
notification-pluginFCM push is non-functional. It implements the legacy FCMserverKeyAPI, which Google decommissioned in 2024. FCM HTTP v1 with service-account JWT signing is a follow-up. (True of this release. Superseded — see 0.1.0-alpha.3, where the provider moves to HTTP v1 and push delivery works.)- LaunchDarkly is unsupported in
feature-flags-plugin. The LaunchDarkly Node server SDK’svariation/allFlagsStateare async and cannot satisfy the synchronous committedisEnabledcontract. Use the provider’s'custom'arm as a bridge. (True of this release. Superseded — see 0.1.0-alpha.3, which adds a'launchdarkly'provider and an optionalisEnabledAsync.) KafkaBrokerdoes not support request-reply. Kafka’s consumer-group and auto-commit model does not fit the pattern;request()/respond()throwMessagingNotSupportedError. (True of this release. Superseded — see 0.1.0-alpha.3, where Kafka becomes reply-capable; the limitation was in the shared request-reply core, not in Kafka.)- Rooms and channels are in-process.
websocket-pluginrooms andsse-pluginchannels are not shared across replicas; cross-instance fan-out is a later milestone. (True of this release. Superseded — see 0.1.0-alpha.3, which addsrealtime-backplane-plugin.Room.size/SseChannel.sizeremain local-only.) resilience-plugintimeouts do not cancel.timeoutraces the promise; the wrapped function keeps running. (True of this release. Superseded — see 0.1.0-alpha.3, wherewraphands the protected call anAbortSignaland the timeout aborts it.)- Node and Bun compatibility suites have not run. They consume the packages through JSR’s npm compatibility layer and were therefore blocked on this publish — they are unblocked by it, and will run before the first stable release. Milestone 40 owns that verification, alongside benchmarks and the security audit.
Milestones in this release
Milestones 0–33 and 41–46. See ROADMAP.md for scope per milestone and PUBLIC_API.md for the full exported surface.