v0.0.2-alpha.52
Pre-releaseReleased 18 packages at 0.0.2-alpha.52 in lockstep. Every package below ships at this version.
What's changed
Patch Changes
-
#532
4902ef4Thanks @mobeenabdullah! - Give a column added by an edit the constraints and indexes creating the table would have attached: a one-to-one is unique, a relationship is indexed, and a requested index exists. Adding a required relationship to a collection that already has entries is now refused with the steps that work instead of emitting invalid SQL, and removing a relationship drops its foreign key first on MySQL and is refused on SQLite, which cannot drop one without rebuilding the table. -
#526
8bdf575Thanks @mobeenabdullah! - Erase a deleted account's request identifiers from the auth log.Deleting a user already removed their name and email from the activity log while
keeping the record itself. The auth log identifies a person a second way — by the
address they connected from and the client they used — and those survived
untouched. They are now erased on the same deletion, stamped with when, while the
event kind, the actor and target references and the timestamp stay: that is the
security fact a retained trail exists for.Erasure is keyed on the actor. A row naming someone as the TARGET carries the
address of whoever acted on them, so erasing by target would scrub a different
person's data and leave the subject's own in place. Events recorded without an
actor — a failed login, a rejected CSRF — are out of reach by design, since they
are written unattributed precisely so a failure cannot reveal which account was
reached; nothing links them to a person, so no deletion can find them. This
table is pruned onaudit.retention.authMaxAgeMs— 180 days by default — so a
window is what bounds them. A window is a weaker guarantee than an erasure,
which is why the metadata projection below is default-deny: what never enters is
the only thing certain not to persist.Whether each table can be erased is now decided per table. A database can carry
one and not the other, and answering for the pair would let a missing auth log
suppress the activity erasure, leaving behind the names and emails the deletion
exists to remove.Identifiers are also kept out of the auth log's
metadatain the first place. A
NextlyError'slogContextis written for operator triage, and a failed login
puts the attempted email address there; the auth handlers copied that context
into the stored event wholesale. A failure is recorded with no actor precisely
so it cannot reveal which account was reached, so nothing links such a row to a
person and the deletion that erases their other rows can never find it — the
identifier has to not be stored rather than be erased later. Only an allowlisted
set of diagnostic keys is now copied, default-deny, so a key added for logging
cannot silently become a field of the audit trail.Naming a key is not enough on its own, because none of the values are ours to
begin with. AnAuthStrategyis application code and chooses its own failure
reason; an error'scodeaccepts any string, and the two diagnostic codes are
copied straight from it. Each retained value is now checked against a vocabulary
this package controls — a reason it produces, or a code the canonical table
defines — and anything else is dropped. The value still reaches the operator log;
what it no longer does is enter a trail nothing can associate with a subject.The reasons are named in one place that the handlers emitting them now compile
against, so a new reason is a type error until it is listed rather than being
discarded without a diagnostic. Three that the initial-password exchange already
emitted were being discarded that way, leavingpending-token-wrong-challenge, a
stale must-change state, and a missing user indistinguishable from each other in
the trail. All three are recorded again.Upgrading: rows written before this change are not covered. The handlers
previously stored the whole error context, so existing unattributed
login-failedrows can already hold an attempted email address or a user id.
Deletion is keyed on the actor and those rows have none, so nothing reaches them
— the projection applies only to failures recorded from now on.Accounts deleted BEFORE this change are not covered either, for the opposite
reason: their attributed rows still hold the address and client they connected
from, and the erasure added here runs during a deletion — it can never run for
an account that is already gone.actor_user_idcarries no foreign key, so
those rows survive as orphans pointing at nothing.Scrub both once, before or after upgrading:
-- Rows recorded without an actor: the context the handlers used to store -- wholesale, which may name an attempted address. UPDATE audit_log SET metadata = NULL WHERE actor_user_id IS NULL AND metadata IS NOT NULL; -- Rows attributed to accounts that no longer exist: their request identifiers, -- which the deletion that removed them never erased. UPDATE audit_log SET ip_address = NULL, user_agent = NULL WHERE actor_user_id IS NOT NULL AND actor_user_id NOT IN (SELECT id FROM users);
The first discards the diagnostic codes on those rows along with the
identifiers. The second leavesactor_user_idin place — the trail should still
say that the same someone did these things, only not who they were. The event,
its outcome and its timestamp are columns, and neither statement touches them:
that is the security fact the trail exists for.Upgrading, PostgreSQL and MySQL: one required action. If you hardened
audit_logby revoking UPDATE — the posture this package previously documented —
grant it back for the three columns an erasure touches, or deleting a user will
fail and roll back:GRANT UPDATE (ip_address, user_agent, identity_erased_at) ON audit_log TO app_role; GRANT DELETE ON audit_log TO app_role;
Two duties need those grants. Erasing the address and client a deleted account
connected from is an UPDATE, and it runs inside the deletion's transaction, so a
blanket revoke blocks account deletion outright. Pruning rows past their window
is a DELETE, and a role without it fails every pass silently — retention must
never fail the request that offered it — so the table grows unbounded while the
setting reads as enforced. Revoke DELETE only together with
audit: { retention: { authMaxAgeMs: false } }, so the configuration says what
the privileges actually do. Every other column stays immutable. Deployments that
never restricted these grants, and all SQLite deployments, need no action.
Prune the activity and auth trails on a schedule.
This deletes data the first time it runs. Set the windows before you deploy
if you need longer ones.Neither trail has ever actually been pruned.
activity_loghas claimed a 90-day
policy in its own schema comment since it was introduced, but the cleanup that
comment named was never called from anywhere — and could not have worked if it
had been, because it referenced a column that does not resolve and its failure
would have been swallowed. Installs are therefore carrying every row ever
written, while the schema said otherwise.audit_lognever promised anything
and grew unbounded too.Both are pruned now, and the first pass removes everything already past its
window:activity_log— content activity, who changed what — 90 daysaudit_log— sign-ins, password changes, role grants — 180 days
90 for content activity is what the comparable self-hosted CMSes default to, and
180 for auth events is what GitHub and Atlassian Cloud retain: security
questions are asked later than editorial ones, because a compromise is usually
noticed well after the sign-in that caused it.To keep more, configure it before upgrading:
export default defineConfig({ audit: { retention: { activityMaxAgeMs: 365 * 24 * 60 * 60 * 1000, authMaxAgeMs: false, // keep auth history forever }, }, });
Each window is independent, so bounding the high-volume feed while keeping
security history indefinitely is one setting rather than a compromise.
audit: { retention: false }keeps everything, as today.Passes run opportunistically off content writes, at most one per interval,
batched, and never fail the write that offered them. Batching matters on the
first run in particular: an install that has never pruned faces every row it has
ever written, and an unboundedDELETEthere would take a long lock on the
largest table at the worst possible moment.Scheduling is now shared rather than duplicated. The gate, interval and
never-throw wrapper that webhook retention already used are a general mechanism,
so audit retention registers a pass with it instead of introducing a second one.
Each pass is gated on its own key: a single shared marker would let whichever
pass ran first consume the interval for the others, and the busier domain would
starve the rest indefinitely. -
#539
49d44aeThanks @mobeenabdullah! - feat(blocks-react): add the React renderer package boundaryAdds
@nextlyhq/blocks-react, the React/RSC renderer for Nextly block
documents. This change lands the package and its layering guarantees; the
renderer itself follows.The root entry imports no
next/*, no admin code and no CMS runtime, so a
document can be rendered from a plain React app, a test or a script. Everything
Next-coupled lives at the@nextlyhq/blocks-react/nextsubpath, so importing
the renderer never pulls Next into a consumer's module graph. Both rules are
enforced by an allowlist-based import test rather than by convention.PageContextandBlocksDataProviderare also introduced: the seam through
which data, media URLs and entry paths reach a block, so blocks never reach for
a database directly. -
#536
d53bc9fThanks @mobeenabdullah! - A text column keeps the width the builder that created it gave it.A text field that states no width does not have one right answer. Three builders create tables and
they read a width from different keys and read silence differently: the Schema Builder's collection
creator bounds on a short variant, its field-group creator bounds on a declaredmaxLengthand
never looks at a variant, and code-first tables were built with a bounded default. Which rule
applies is a fact about the entity, not about the field.Describing a column without that fact meant guessing, and each place that guessed got it wrong for
at least one builder. On MySQL a field group's short text field was described as unbounded when it
had been created bounded, so a schema preview reported a type change on a column nobody had
touched, and applying it would have rewritten the column. The same guess reached the localization
companion tables, Single identity seeding, and the path that adds a column to a table that already
exists.The builder is now named wherever a column shape becomes DDL, so the width follows the table rather
than being re-derived from the field. Paths that only look a table up to run a query are unaffected:
a declared width is enforced by the database, not by the ORM. -
#514
bffeac4Thanks @mobeenabdullah! - Custom CSS in the page builder can no longer load anything from another origin.
Aurl()carrying a scheme or a host is refused, and the editor says which
declaration went and why, with a remedy that works whichever storage adapter the
media library uses.This closes a way of reading data off the page. A selector that matches only on
a prefix, paired with a URL that fires a request when it matches, spells a value
out one character at a time —input[value^="a"] { background: url(...) },
repeated. Custom CSS is the only surface where an author writes both halves, so that is
where the ban is absolute.Banning it in custom CSS alone would not have closed the channel, because the
two halves need not be written in the same place. A block's background image is
compiled into the same stylesheet, so a remote image there plus a custom
selector that suppresses it conditionally still leaks by the request's ABSENCE,
with no URL in the custom CSS to refuse.So a block's images are restricted the same way, and a site declares the hosts
it loads from. A relative path such as/media/a.pngneeds nothing; anything
carrying a host needs an entry, INCLUDING an absolute URL on your own domain,
exactly asnext/imagealready requires:<PageRenderer document={doc} remotePatterns={[ { protocol: "https", hostname: "cdn.example.com", pathname: "/img/**" }, ]} />
The policy covers every value a block emits, not the properties someone
remembered can fetch:filter: url(…)is a request too, and so is
filter: var(--missing, url(…)), whose URL lives in a fallback the parser
leaves as raw text. A protocol-relative//host/a.pngis refused rather than
resolved against a guess, since the document's protocol is not knowable when the
stylesheet is compiled.BREAKING, and wider than images: every resource a block loads on its own is now
refused until its host is declared. On upgrade, add the hosts below to
remotePatternsor the content stops rendering.block what stops host to declare core/imagethe image wherever your media is served from core/cover,core/slides, flip cardsthe background same core/gallery, the carousels,core/hotspotthe images same core/videothe source and poster your media host core/lottiethe animation the animation's CDN core/embed(URL mode)the iframe e.g. www.youtube.comcore/mapthe iframe www.google.com, or your own tile hostThis includes absolute URLs pointing at your own site: nothing in the compiler
knows what your host is, sohttps://your-site.com/a.pngneeds an entry while
/a.pngneeds none — the same linenext/imagedraws. If your media library
stores absolute URLs, which the cloud storage adapters do, declare your own host.A custom block registered from outside this package applies the policy itself:
itsrenderreceivesremotePatterns, andmediaUrl/cssMediaUrlare
exported for it. The renderer cannot inspect the element a block returns, so a
block that writes a URL into ansrcor an inline background without asking
reaches whatever host it names. The shape is Next.js'simages.remotePatterns, so an entry can
be copied straight across fromnext.config, and the posture matches
next/image— nothing off-origin unless you said so. Matching uses picomatch
with the same optionsnext/imageuses, rather than an approximation of it, so
hostnameandpathnameglobs mean exactly what they already mean in your
next.config.searchis honoured too.Everything the sanitizer removes is now reported rather than dropped silently,
including at-rules it does not support. A rule that disappears with nothing on
screen to explain it reads as a bug in the builder, and the author's own source
still contains the line that did not survive.CSS the sanitizer cannot read through — a rule nested deeper than it follows, or
a fragment it cannot parse — is still removed, but it is now reported as
unchecked rather than as a remote URL. It previously named the whole rule as the
offending address, which sent authors looking for a host their stylesheet never
mentioned. The depth it follows also rose well past real CSS: the old limit
refused valid stylesheets at five levels of nesting, which ordinary compiled CSS
reaches.BREAKING, for anyone calling the sanitizer directly:
sanitizeCustomCssand
sanitizeBlockCssreturn{ css, warnings }rather than a string. They are
re-exported from the package root, so this is a visible change even though the
page builder itself is the only expected caller. Read.csswhere you read the
result before.Also on that surface:
CssWarning["code"]gains"unchecked", which a switch
over the union has to handle, and CSS that fails to parse outright now reports
"unchecked"where it reported"unsafe-value".MAX_RULE_NESTINGand
MAX_VALUE_NESTINGare exported alongside them. -
#528
938898dThanks @mobeenabdullah! -create-nextly-apprecognises the development-diagnostics setting however an existing.env
spells it, and no longer mistakes a different variable for it.A substring test treated
NEXTLY_DEV_DIAGNOSTICS_BACKUP=1as the setting already being present,
so such a project was skipped and never told the real one exists. The check now matches an
assignment at the start of a line, including the commented form and theexport KEY=valueform
dotenv accepts so a file can also be sourced by a shell.The whitespace in that match is confined to the current line. Allowing it to cross newlines made
the scan backtrack across the blank lines an.envis full of, which is quadratic on the common
case of a file that does not contain the key at all. -
#537
a281098Thanks @mobeenabdullah! - The Direct API types a row the way the process sees it: a timestamp is the Date the driver decoded, not the formatted string a REST response carries. Codegen records which fields a collection or single stores in a timestamp column, and the wire types are unchanged.A write returned an undecoded row on the raw-SQL paths, so a created row carried epoch numbers on SQLite where a fetched one carried Dates. Every raw-SQL row now decodes the way a read does.
The media services name the error code they mean rather than leaving the boundary to infer one from a status, so a folder-name clash keeps saying "already exists" instead of "reload".
-
#529
17be415Thanks @mobeenabdullah! -SubmissionDocument.statusnow includes"spam", and gainsspamReason.The stored field has always offered
spam, the admin has a Spam tab and filters its other views
withnot_equals: "spam", the notification hook skips it, and marking something "Not spam" moves
it back tonew. Only the TypeScript type disagreed, so it described a shape the database cannot
produce — narrowing onstatuscould not see the case that actually reaches the UI.The conversions from a stored row to this plugin's document types now live in one module rather
than at six call sites. They are still unchecked assertions, which the module says plainly:
the services layer answers with a loose row and TypeScript has no overlap to verify. Nothing about
runtime behaviour changes; the unchecked step is now in one place a reviewer can find. -
#521
d58130aThanks @mobeenabdullah! - Keep the Schema Builder's DDL generator, the column descriptor and the write path agreeing on which
fields are junction-backed. A field carryingrelationType: "manyToMany"was treated as
junction-backed by the descriptor whatever its type, while the generator emitted a junction table
only for arelationship. Anuploaddeclared many-to-many therefore got a parent column that the
runtime schema and the schema diff did not know about, so the diff proposed dropping it on every
apply.Junction storage is a
relationshipfeature, because that is the only shape the read and write
paths implement, so anuploadcarrying that option keeps its own column and is unaffected: a
single target is a foreign key,hasManyor an array of targets a JSON array of ids. A
relationshipmany-to-many is unchanged — no parent column, one junction table. -
#519
3a1b43bThanks @mobeenabdullah! - One table now decides what an HTTP status means when a failure names no error code.Three tables used to, and they disagreed. The same code-less 401 reached a Direct API caller as
AUTH_REQUIREDand a REST caller asINTERNAL_ERROR; a code-less 429 lost its rate-limit
identity entirely, and with it theRetry-Aftera client needs to back off correctly. The media
service kept a third table that read 409 asDUPLICATEand 422 asBUSINESS_RULE_VIOLATION.A code-less failure now resolves through one shared table for 400, 401, 403, 404, 409, 413, 415,
422, 429, 502 and 503, and anything unrecognised stays an internal error. The producer's own
status is preserved rather than rounded to the code's canonical one.The table is a fallback, not a translation. A status is coarser than a code: 409 covers both
"that name is taken" and "someone else edited this", which need opposite advice. A service that
knows which one it means setscodeand is believed.MediaResponse,DeleteMediaResponse,
FolderContentsResponseand the folder bulk-delete result can carry a code for exactly this
reason, and creating a folder whose name is taken now says so throughDUPLICATErather than
relying on a boundary to guess.A code-less failure never puts its own message on the wire. Those envelopes come from legacy
converters that may store a raw exception's text, so the caller gets the generic sentence for the
derived code and the detail stays in the operator log. A failure that names a code keeps its own
message, which the producer authored to be read.Behaviour changes worth checking if you read error bodies directly: a code-less 401 answers
AUTH_REQUIREDinstead ofINTERNAL_ERROR; a code-less 429 answersRATE_LIMITED; a code-less
422 answersINVALID_INPUT; and through the Direct API a code-less failure's message is now the
generic sentence rather than the service's raw text. -
#538
4f009aeThanks @mobeenabdullah! - A plugin can now hand its own configuration to its own admin components.A plugin's factory runs on the server, where the host builds its config; its
admin components run in the browser. Nothing carried a value between the two, so
a plugin could ship behaviour it had no way to configure.contributes.admin.clientConfig
travels with the rest of the admin metadata, andusePluginClientConfigreads it
back. It is PUBLIC —/api/admin-metaneeds no authentication, so it reaches
anonymous callers and must hold nothing secret — and the serializer refuses
anything that will not survive the trip rather than delivering a mangled copy.The page builder uses it for
remotePatterns. The editor canvas previously
enforced an empty allowlist while the published page enforced the host's, so it
hid images the live page shows.Pass the SAME value to both
pageBuilder({ remotePatterns })and
PageRenderer. They are separate assignments: the plugin option configures the
editor, andPageRendererreads only its own prop. Setting just one is what
produces a mismatch, in whichever direction you set it — a shared constant in
the host is the way to keep them equal. -
#523
f835ca9Thanks @mobeenabdullah! - New apps document the development error-diagnostics opt-in.An error response is deliberately generic — a code, a public message and a request id — and
withholds the log context and the underlying cause so a response cannot disclose driver output,
table names or internal paths. That is right for a deployed app and unhelpful while building,
where the withheld part is exactly what you need.NEXTLY_DEV_DIAGNOSTICS=1adds a_devDiagnosticsfield carrying that detail. It existed
already, and nothing mentioned it, so an author hitting an error had no reason to suspect a flag
would have named the cause.create-nextly-appnow writes it into.envand.env.example
commented out, with an explanation, anddocs/configuration/environment.mdxdescribes it with
a worked example.It is documented rather than enabled: the flag is the second of two independent signals, and the
second exists becauseNODE_ENVis a runtime value a deployment can carry by mistake. A default
shipped in.envwould be true in exactly that case — the one it guards against.Installing into an existing project that already has a configured
.envadds the note too, keyed
on its own absence rather than onDATABASE_URL. -
#541
72c894bThanks @mobeenabdullah! - A timestamp is stored the same way whatever the server timezone is. The raw-SQL write paths bound a JS Date directly, so the driver serialized it with the local offset and a column declared without a time zone kept the local wall clock, while every read interpreted that wall clock as UTC. A row written and read back on a server five hours ahead of UTC came back five hours late. Values are now encoded through the column the same way a Drizzle query encodes them, on PostgreSQL and MySQL; SQLite was unaffected, storing unix seconds, which carry no zone.Rows written before this on a server that was not on UTC keep the wall clock they were given, so a table can hold both conventions until those rows are corrected. Deployments running UTC, which includes every default container image, are unaffected either way.
-
#543
9ccff93Thanks @mobeenabdullah! - Add two editor-shell primitives to the UI kit: a right-click context menu, and resizable panel regions whose split can be dragged or moved from the keyboard. Both are experimental until a first-party plugin uses them. -
#525
6c77f8fThanks @mobeenabdullah! -@nextlyhq/ui's release tags now reach the published types. Every export in the
barrel carried@publicor@experimental, and none of it survived the build:
the declaration bundler flattens each re-export into oneexport { … }clause
and drops the doc comment attached to the export statement, so an editor
hoveringbadgeVariantswas told nothing about its stability. The tags live on
the declarations now, where the bundler keeps them, and 229 of them reach
dist/index.d.tswhere there were none.toastandToasterPropsare re-exported fromsonner, so their declarations
are not ours to annotate; they stay tagged in the barrel only.cnand
uiPreset, which ship from their own subpaths, carry@experimentalnow as
STABILITY.mdalready classified them.Twenty prop types were also promoted to
@public, which is a widening rather
than a change of intent:STABILITY.mdalready guaranteed that a prop type
carries the same stability as its component, and every one of these belonged to
a public component while advertising@experimental— so the published type
withdrew what the component promised, and a plugin could not wrapTabsor
Dialogwithout depending on something labelled unstable. The rule is now
enforced by a test rather than written down.Modal scrims are a theme token. Six components wrote the backdrop inline as
bg-black/80, identical in light and dark and at four different strengths, so
it could be neither themed nor white-labelled and was invisible to every token
check the package has.--nx-overlay(with--nx-overlay-softfor a scrim over
content rather than the page, and--nx-overlay-strongfor one that carries
text directly — a full-screen state screen, an image lightbox and its caption,
where the muted detail line rather than the heading decides the strength: over
a white pagetext-white/60is 2.81:1 on the see-through scrim and 5.66:1 on
the strong one) is defined for both modes and used everywhere,
withbg-overlay/bg-overlay-softutilities in the v4 theme AND in
@nextlyhq/ui/tailwind-preset, so the documented Tailwind v3 path generates
them too. Dialogs, sheets and the command palette now share one backdrop
strength rather than three.
Packages
@nextlyhq/adapter-drizzle@nextlyhq/adapter-mysql@nextlyhq/adapter-postgres@nextlyhq/adapter-sqlite@nextlyhq/admin@nextlyhq/admin-css@nextlyhq/blocks-engine@nextlyhq/blocks-react@nextlyhq/plugin-form-builder@nextlyhq/plugin-page-builder@nextlyhq/plugin-sdk@nextlyhq/plugin-seo@nextlyhq/storage-s3@nextlyhq/storage-uploadthing@nextlyhq/storage-vercel-blob@nextlyhq/uicreate-nextly-appnextly