Repository navigation
v0.21.1
Added
-
rebase upgrademoves every@rebasepro/*package to one release. A
project pins the framework in severalpackage.jsonfiles, andrebase buildcould only warn that they were behind.rebase upgraderewrites every
@rebasepro/*entry independencies,devDependenciesand
optionalDependencies, in everypackage.jsonunder the project, keeping
^and~, then installs with the package manager your lockfile names.
--totakes an exact version (0.21.0, no registry lookup) or a dist-tag
(latest, the default, orcanary).peerDependencies,workspace:,
link:,file:, git and tag specs are left alone and listed with the
reason. Overrides inpnpm-workspace.yamlandpackage.jsonare bumped the
same way. Alink:orfile:override wins over every pin, so it is
reported, and--drop-local-overridesremoves it. Only the version strings
change: indentation, key order and line endings stay as they were.
--dry-runwrites nothing,--no-installskips the install, and--json
prints one document with every change. The build warning about old pins,
andrebase doctor's version-skew fix, now name the command. -
A bundle deploy uploads the project's source, so a platform upgrade can
rebuild it.rebase cloud deploynow packs the source beside every
backend bundle and names it in the deploy. In a git repository that is what
git ls-fileslists from the repository's top level, so.gitignoreis
honoured and a package the project links from elsewhere in the repository
goes too. Alink:orfile:dependency that reaches outside the project's
repository — a project that is its own repository, nested in another it
links packages from — brings the repository holding its target, and the
archive is rooted where they meet;@rebasepro/*links are never followed.node_modules,dist-bundle*,.envrcand every.envand
.env.*file are always left out, committed or not;.env.example,
.env.sampleand.env.templateare kept. TheVITE_*values a production
build of your frontend reads travel with it, and no other variable does. If
the archive is over 100 MB or the upload fails, the deploy goes ahead without
it and says so.--no-sourceskips it for one deploy. A project's owner can
turn platform rebuilds off for good —rebase cloud settings set --platform-rebuilds off, or the project's settings in the console — and then
no source is uploaded, the platform deletes the copy it holds, and upgrades
move the runtime only; rebuilds are on until someone does.
--allow-downgradedeploys a bundle built on an older framework release than
the project runs, which the control plane otherwise refuses as
FRAMEWORK_DOWNGRADE.
Changed
- Entity form fields are 48px tall again. 0.20.0 took them to 32px
(small). The form passeslargeagain, and the field bindings default to
it, so a field nested in a map, an array or a table cell's popup editor is the
same height as the rest. The text stays at 14px.
Removed
RealtimeService.registerDataDriverSubscription,addSubscriptionCallback
andremoveSubscriptionCallback(@rebasepro/server-postgres). They
registered an in-process listener in two steps and left its first read to the
caller, which is how that read came to run unscoped (see Security).
startDataDriverSubscription(id, request, callback)registers the listener
and delivers its first rows. The request carries the subscriber's
authContext.unsubscribe(id)cancels it.
Fixed
-
rebase schema stalereports a generated schema the collections have moved
past. It looked for one thing only: foreign keys named the way an earlier
release derived them. A file missing a table, with a retyped column or with an
enum label the collection had dropped was reported as "Nothing stale", while
everyrebase buildrewrote it. It now compares the file, declaration by
declaration, with what the collections generate, asrebase doctoralready
did. It names what differs, exits non-zero, and regenerates the file under
--fix. When both problems apply, it reports both. -
A realtime listener on Postgres gets its first delivery in order, hears a
deleted row, and keeps its whole query.listenCollectionandlistenOne
fetched their first delivery themselves, outside the realtime service that
serves every refresh after it. Four faults came with that:- A slow first read could arrive after a newer refresh and put the listener
back on the rows from before the change, where it stayed until the next
write. It could also arrive after you had unsubscribed. The first read now
takes the same delivery slot as every refresh.
- A slow first read could arrive after a newer refresh and put the listener
-
listenOnedroppednull. A listener whose row was deleted, or never
existed, heard nothing and kept the last row it had. It now receives
null, as it does on MongoDB. -
listenCollectionpassed on nine of the query's fields and dropped the
rest. A listener with anor(...)group (logical) was handed every row,
on the first delivery and after every change. The whole query is now
stored with the subscription,logical,include,fields,distinct
andsearchExplainincluded. -
A listener on the base driver, with no user, read its first rows as the
server and every refresh as the anonymous user, so its rows changed on the
first write. Both are now read as the anonymous user, as on MongoDB. A
listener on a driver bound to a request, such ascontext.datainside a
collection callback, had its refreshes read as the anonymous user too. It
now reads as that request's user throughout. -
A late subscription error no longer overrides newer data. On Postgres, a
realtime subscription's failed fetch always sent its error, even when a
newer fetch had already delivered fresh rows. Two fetches for one
subscription can overlap, for example the first load and a refresh after a
quick write, and the slower one can finish last. So the client'sonError
fired, and the admin panel showed an error, for a view that was up to date.
A failure from a subscription that had been cancelled, or replaced under the
same id, could also reach the new one. An error is now sent only when
nothing newer has reached the subscriber, the same rule its rows follow. A
subscription whose rows cannot be sent at all, such as a row holding a value
that will not serialise, still gets an error, on both databases. -
An in-process realtime listener hears when a read fails. Server code
that listens withlistenCollection({ onUpdate, onError })orlistenOne
was not told when the read behind the subscription failed. On MongoDB,
neither the first load nor any refresh after a change reachedonError. On
Postgres the first load did, but a failed refresh was only logged, so the
listener kept its last rows as if they were current. Both now callonError
with the error as it was thrown: anafterReadthat throws a
RebaseApiErrorarrives as thatRebaseApiError, not masked the way a
WebSocket frame is. A failure is not reported once newer rows have arrived,
or after you unsubscribe.RealtimeProvider.subscribeToCollectionand
subscribeToOnetake the same optionalonError, and the router used when
a project has several data sources passes it on. -
A realtime subscription on MongoDB reports a failed read. When the fetch
behind a WebSocket subscription failed, the Mongo server logged the error and
sent nothing. That happened on the first load and on every refresh after a
change. The client got neither rows nor an error, so the admin panel's view
stayed loading and the SDK listener'sonErrornever fired. The server now
sends an error for that subscription. A refusal keeps its code and message,
as it does over REST and on Postgres: anafterReadthat throws
new RebaseApiError("…", { status: 403, code: "FORBIDDEN" })reaches
onErrorwith codeFORBIDDENand that message. Any other failure, a 5xx
included, arrives asCould not load data for "<path>". Check server logs for details.with codeINTERNAL_ERROR, and the error itself goes to the log.
A refusal is logged as a warning, not an error. -
Date and read-only fields in the entity form match the other fields. A
date field ignored the form'ssizeand always drew at 48px. In 0.20.0 and
0.21.0, where the form wassmall, that put it 16px above the text fields
beside it. A read-only field had a fixed 48px box and printed its value in
16px text, a size above the 14px of every input. Both now follow the form's
size, and a read-only value is printed at 14px. See also Changed. -
A multi-select is as tall as the fields around it (
@rebasepro/ui).
MultiSelectstood 2px short of aTextFieldorSelectof the samesize
(48px against 50 atlarge), because the field's 1px border was drawn inside
its height rather than around it. A borderedMultiSelectis now the control
height plus the border, like the others. Aninvisibleone, in a table cell,
is unchanged. -
POST /admin/usersleaves delivery to a create hook, and shows the admin
the hook's temporary password. A collection'sauth.onCreateUseror a
backend'sAuthHooks.onAdminCreateUserreplaces Rebase's own delivery: the
hook sends its own invitation or chooses a temporary password, and reports
which. APOSTto the auth collection honoured that.POST /admin/users
ignored it and treated the hook's password as one Rebase had generated. With
email configured, that meant minting a password-reset token and sending
Rebase's own invitation: a second email when the hook had already sent one,
with a link that competes with the password the hook chose. The response then
left that password out, so the admin who has to pass it on never saw it. A
hook that sent its own invitation and returned no password was reported as
invitationSent: false. Both ways of creating a user now go through one
step, so they give the same answer. -
Uploads between 10 MB and 50 MB work.
POST /api/storage/uploadhas its
own body limit, the storage config'smaxFileSize(50 MB by default), and
the docs said it overrode the globalmaxBodySize(10 MB by default). It
never did. The global limit runs first on every route under the base path and
answers413on theContent-Lengthalone, so every non-TUS upload above
10 MB was refused before the storage limit was checked. That includes every
upload from the admin panel. Now the upload route is taken out of the global
limit and meets only its own, whethermaxFileSizeis larger or smaller than
maxBodySize. Every other route keeps the global limit, the rest of
/storageincluded. TUS is unchanged: eachPATCHchunk is still capped by
maxBodySize, and the whole file bymaxFileSize. A proxy in front of the
runtime has its own limit, which still applies. For the Helm chart's, see the
next entry. -
The Helm chart's ingress lets 50 MB uploads through. nginx refuses a
body overproxy-body-sizewith its own HTML 413, before the runtime sees
it, so the chart setsingress.maxBodySizeabove the runtime's limit. It was
12m: above the globalmaxBodySize(10 MB), but not above the 50 MB
maxFileSizethatPOST /storage/uploadnow meets (see the entry above). So
on chart deployments, uploads between 12 and 50 MB still failed, admin-panel
uploads included, and the error was nginx's page instead of the runtime's
JSON. The default is now64m.pnpm check:chartnow refuses a default
that is not above the larger of the two runtime limits. It reads them from
DEFAULT_MAX_FILE_SIZEand from the newRUNTIME_DEFAULT_MAX_BODY_SIZE,
which@rebasepro/serverexports with the rest of the pod contract. A
release that setsingress.maxBodySizeitself keeps its value. If yours is
below50m, raise it. -
A collection-callback veto over the WebSocket is reported as a veto. A
beforeSave,afterSave,beforeDeleteorafterDeletethat throws, or a
beforeDeletethat returnsfalse, is answered over REST as a 400 (403 for
false) with codeCALLBACK_REJECTED, the author's message, and
details.stagenaming the hook. Both WebSocket servers answered the same
refusal asINTERNAL_ERROR, and in production replaced the message with "An
unexpected error occurred". The admin panel writes through the socket, so an
editor whose save or delete a rule refused was told the server had failed,
and never saw the rule's message. The row was still protected. Only the
answer was wrong.The socket now answers with the refusal's own code, message and
details,
as REST does. The same applies to anyRebaseApiErrorthat carries a status.
Realtime subscriptions on Postgres had the same gap: aRebaseApiError4xx
thrown fromafterReadarrived as "Could not load data … Check server logs".
It now keeps its code and message. The client passes a frame'sdetailson
to theRebaseApiErrorit throws, soe.details.stageworks over both
transports.e.statusstaysundefinedfor a socket error, because a frame
is not an HTTP response. Real server faults are still masked in production.
REST and both sockets now decide what counts as a refusal through one
function,declaredErrorAnswerfrom@rebasepro/server. -
Scheduled backups include your users again, and can be restored.
createBackupCronleft therebaseschema out of every dump unless told
otherwise. The aim was to skip Atlas's revision table, but that schema also
holds every user account and the rest of auth, API keys, record history, the
job queue and cron logs, and the functions every generated RLS policy and CDC
trigger calls. So each scheduled dump was missing all of that. It also could
not be restored into an empty database: every collection's generated policies
callrebase.uid()andrebase.roles(), sopg_restorestopped with
schema "rebase" does not exist.rebase db backupexcludes nothing, so the
manual and the scheduled backup captured different databases.The scheduled backup now excludes nothing either, and
excludeSchemasstill
narrows it when you pass one. A scheduled dump made by an earlier version
cannot be trusted, so take a fresh backup after upgrading. -
The Studio Backups panel downloads a backup's roles file. A backup is a
.dumpplus a.globals.sqlsidecar holding the database roles its grants
and RLS policies refer to, andrebase db restoreneeds both. The panel, and
the/api/admin/backupsroutes behind it, only knew about the.dump, so a
backup downloaded from Studio stopped at its firstGRANTwhen restored into
a new Postgres. Each row now has a Roles file button, or says No roles
file when there is none. The listing carries the sidecar's key as
globalsKey, and/downloadserves it. -
FORCE_LOCAL_STORAGE=falseswitched the production storage guard off. In
production alocalstorage backend is dropped unlessFORCE_LOCAL_STORAGE
says a durable volume is mounted, because a container's filesystem is erased
on the next restart or redeploy. The guard tested whether the variable was
set, not whether it was true, so=false— the natural way to say "there
is no volume here" — registered the local backend, and uploads succeeded into
a disk the next redeploy wiped.rebase statusread it the same way and
called that storage ready. Only a spelled yes forces it now, and the guard and
rebase statusshare one reader.A deployment that set
FORCE_LOCAL_STORAGE=falsewhile storing uploads on
local disk was losing them at every redeploy; its uploads now answer501 STORAGE_NOT_CONFIGUREDuntil a bucket is configured, or until
FORCE_LOCAL_STORAGE=truesays a durable volume really is mounted. -
REBASE_MCP_OPEN_REGISTRATION=0left OAuth client registration open. The
switch that turns off dynamic client registration on the MCP authorization
server compared against the literal"false", so0,noandoffleft
/oauth/registerissuing a client ID to anyone who asked. Any spelling of no
closes it now. -
rebase db backupaddressed a MinIO bucket as a hostname. The backup,
restore andbackups listcommands passedforcePathStyle: falsewhenever
S3_FORCE_PATH_STYLEwas unset, overriding the default the runtime's own
storage gets: path-style for a custom endpoint. Unset now leaves that choice
to the endpoint in both, andS3_FORCE_PATH_STYLE__<KEY>=1is no longer read
asfalse. -
Boolean environment variables accept one set of spellings.
true,1,
yesandonmean yes;false,0,noandoffmean no, in any case;
anything else leaves the variable at its default. Seven readers had spelled
this seven ways, soDISABLE_DB_ROLE_SWITCHING=1and
REBASE_EXIT_ON_UNHANDLED_REJECTION=truedid nothing,REBASE_DEBUG=0turned
debug output on in one command and off in another, andCI=0read as a CI
run.DO_NOT_TRACK=falsenow reads as a no instead of a refusal; telemetry
still sends nothing without your explicit opt-in. The variablesloadEnv
validates still accept onlytrue,falseor empty and refuse anything else
at boot, as before. The parser is exported from@rebasepro/typesas
parseEnvBoolean, andverify:docsnow refuses a boolean read that goes
around it. -
A collection's
onResetPasswordhook could not actually reset a
password. The admin panel showed thetemporaryPasswordthe hook returned,
and nothing saved it: the hook's context can hash a password but not store
one, and the route only passed the value on. The documented example did
exactly this, so every reset through it handed the user a password that did
not work, and left the old one working. A returnedtemporaryPasswordis now
the account's new password: Rebase hashes it, saves it and signs the user out
everywhere before showing it to the admin.onAdminResetPasswordfollows the
same rule, so its hook no longer has to write the password throughauthRepo
as well; one that already does gets it written a second time, which is
harmless. The example now generates the password withrandomBytesinstead
ofMath.random. -
A release whose smoke test fails still gets its GitHub Release. The
release job created the GitHub Release, and published to the MCP Registry,
only when every step before it had passed, including a smoke test that
installs the just-published CLI. 0.21.0's smoke test ran six seconds after
the npm publish, before@rebasepro/server-postgres@0.21.0was visible on
npm, so the packages and the tag went out without a GitHub Release. Both
steps now run whenever the packages and the tag did. The smoke test now
waits for every package at the new version rather than only the CLI, and
fails if they never appear. The 0.21.0 GitHub Release has since been created
by hand. -
A refused bundle deploy prints the control plane's reason and remedy. On
the managed path every refusal went to the generic error report, so a bundle
refused at intake printedManaged deploy failed to start (400)and dropped
the hint. It now prints the message, the hint and the code, as a source
deploy already did. AFRAMEWORK_DOWNGRADErefusal also names the two ways
past it:rebase upgrade, or--allow-downgrade. -
A bundle deploy run from a subdirectory registers the project's apps.
rebase cloud deployreadrebase.jsonfrom the working directory, so run
frombackend/it found none there and told the platform the project
declared no apps. It reads the project root's.
Security
-
A guest passed
policy.registered()on MongoDB, and over the Postgres
WebSocket. A guest, a caller from anonymous sign-in, has a real uid. The
guest flag,isAnonymous, is the only thing that tells it from an account,
andpolicy.registered()("signed in, and not a guest") reads it. Several
doors dropped the flag and treated a guest as an account:- On MongoDB, the check that decides a single row ignored the flag. So
GET /api/data/<slug>/<id>,POST,PATCHandDELETElet a guest read
and write rows aregistered()rule withholds, and so did the WebSocket
and the SDK. Lists were filtered correctly.
- On MongoDB, the check that decides a single row ignored the flag. So
-
The MongoDB WebSocket never read the flag at sign-in, whether the client
signed in with the platform's token or through an auth adapter. It served
a guest's lists, reads, writes and subscriptions as an account's. -
A MongoDB in-process listener, such as
listenCollectionorlistenOne
ondriver.withAuth(guest), subscribed without the flag. -
The Postgres WebSocket read the flag from the token, but ran every request
frame (FETCH_*,SAVE,DELETE) as an account's. A policy on
rebase.is_anonymous()did not stop a guest there. A session signed in
through an auth adapter lost the flag everywhere, subscriptions included.
And a client with no session was treated as a guest on request frames
only. Over REST and on subscriptions it was not one, and now it is not
one anywhere.REST on Postgres was never affected. The flag now reaches every door, and
each WebSocket server builds one identity for both its requests and its
subscriptions. If you usepolicy.registered(), orrebase.is_anonymous()
in a policy, with anonymous sign-in enabled, upgrade. -
A MongoDB WebSocket client that never signed in ignored every security
rule. WithrequireAuth: false, a client that connected without signing
in was served by the base driver. On MongoDB that driver applies no
securityRulesat all. So the client read every row of every registered
collection, and could create, update and delete in any of them. REST served the
same caller as the anonymous user and enforced the rules. The socket now
serves such a client as the anonymous user (roles: ["anon"]), as REST
does, for both its requests and its subscriptions. If the socket cannot
scope a frame, the frame now fails. It used to fall back to the base
driver. If you run MongoDB withrequireAuth: false, upgrade. -
On Postgres, a realtime listener on a user's driver got its first rows
without that user's row-level security. This affected server code that
callslistenCollectionorlistenOneon a driver scoped to a user, such as
driver.withAuth(user)or a function'srequireDriver(c). The first
delivery was read on the base driver, the server context, before the user's
identity was attached to the subscription. Only the refreshes after a change
read as the user. So on a connection that bypasses RLS, which is when
rlsUserRoleis set (a superuser, aBYPASSRLSrole, or the owner of the
tables), the first delivery carried every user's rows, andlistenOnehanded
over a row the user may not read. The next refresh dropped them again.A guest's refreshes had a gap of their own. The identity attached to them
left outisAnonymous, so a policy that excludes guests did not exclude them
after a change.The first read now runs in the realtime service, as the subscriber, like
every refresh. The subscriber's identity is part of the subscription from the
start,isAnonymousand the token's claims included, and it is built by the
same function that scopes a request's transaction. The WebSocket
subscriptions that the SDK and the admin panel use were not affected: their
first read was already scoped. So was MongoDB's. If your server code listens
through a user's driver on Postgres, upgrade. -
The MongoDB realtime socket could read and write the auth store. MongoDB
has no row-level security, so on that engine the collection registry is the
whole access model: asecurityRuleis enforced only for a collection the
registry resolves, andMongoDataServicemaps any path to a physical
collection by name. The realtime socket forwardedFETCH_COLLECTION,
FETCH_ONE,SAVE,DELETE,COUNT,CHECK_UNIQUE_FIELDand the
subscribe_*frames to the driver without asking whether the path named a
registered data collection. For a path the registry did not know,
AuthenticatedMongoDriverresolved the collection toundefinedand every
rule check short-circuits open there —authorize(undefined)returnstrue
and the query filter becomes "match all". So any authenticated client (or,
whenrequireAuthis off, any anonymous one) could name the auth collections
as the path and readrebase_usersincluding password hashes, grant itself an
admin role by writingrebase_user_roles, overwrite another account's
password hash, or delete refresh tokens — on the one engine where nothing
behind the driver would stop it. The REST routes were never exposed: they
mount per registered slug and 404 everything else.The socket now refuses, fail-closed, any frame whose path the registry does
not resolve, on every data verb and both subscribe types, with aNOT_FOUND
naming the path — the same answer REST gives an unknown collection. Registered
collections are unaffected, and their row security is unchanged. -
A delete over the WebSocket wrote its own audit record, and could get past
abeforeDeleteveto.DeletePropscarriedrow.values, and both server
drivers took them as the row being deleted: they were whatbeforeDeleteand
afterDeletereceived and what history recorded as the row's final state.
The REST routes filled them from a read of their own. The WebSocketDELETE
handler forwarded the client's frame, so a caller allowed to delete a row
could record anything as its final state — and could get past a
beforeDeletethat refuses on a column, such as the callbacks guide's
if (row.status === "published") throw, by sendingvalues: {}. The
in-process SDK sent{}on everydelete(id), so every delete from a
function, a cron job or a callback recorded an empty row and gave
beforeDeletenothing to judge. The MCPdelete_documenttool did the same.Both drivers now read the row themselves before any callback runs, under the
caller's own scope — on Postgres, inside the delete's transaction, as the
caller's role — and that read is what the callbacks receive and what history
records. It is the read the REST route already made, so a delete over HTTP
sees what it saw before. A row that is not there, or that the caller cannot
read, is a 404 before any callback runs, on every path; over the socket and
the SDK the callbacks used to run first.DeleteProps.rowis{ id, path }
now:valuesis gone from the type and ignored on the wire, so a published
client that still sends it keeps working.The socket's
DELETEalso stopped forwarding the frame'scollection. The
driver merges a caller's collection under the registry's, so a key the
registry does not declare survived, andsoftDelete: { field: "title" }
turned a delete into an update oftitlethat nobeforeSaveand no write
validator saw.One more change came with dropping
deleteMany's own pre-read: a hard
deleteManynow purges rows that are already in the trash, as a hard
single-row delete does. Its old read hid them and answered 404.If a collection has
history: true, or abeforeDeletethat refuses on the
row's contents, and anything deletes through the socket or the SDK, upgrade. -
An admin password reset left the account's existing sessions signed in.
Resetting a user's password from the admin panel, or through
POST /api/admin/users/:uid/reset-passwordandPUT /api/admin/users/:uid,
changed the credential and revoked nothing. That applied whether the admin set
the password directly, emailed a link, or got a temporary one. A stolen
refresh token went on minting access tokens for the rest of its lifetime —
after the one action an administrator takes when an account has been phished.
Only the user's own reset and change-password signed the other sessions out.Every way of setting a password on an existing account now ends its sessions:
the refresh tokens are deleted and, on Postgres, every access token issued
before the reset is refused. That coversuserManagement.updateUserwith a
passwordtoo. The emailed-link mode now ends them when the link is sent
rather than when it is opened, and so does a reset handled by an
onResetPasswordoronAdminResetPasswordhook. -
A database error during an admin reset set a new password. Minting the
reset token shared atrywith sending the email, so a failed write took the
"email failed" path: a fresh password was written over the user's and handed
to the admin as a temporary one. It now answers 500 and leaves the password
alone.