Repository navigation
v0.24.0
Breaking
CLI
-
rebase db pushandrebase db generatewrite their SQL to.rebase/sql/
instead ofdrizzle/. This coversschema.sql,policies.sql,
search.sql,vector.sqlandtriggers.sql. Both commands regenerate these
files from the collections before reading them, so the copy in the
repository was never read, and it showed up in the diff of every collection
change. The new directory ignores itself and is not committed. On the first
run after upgrading, the command deletes the old generated copies in
drizzle/and names them, so you can commit the deletion. A file there that
the generator did not write is kept, and so are the migrations. If one of
your scripts readsdrizzle/policies.sql, point it at
.rebase/sql/policies.sqland runrebase db pushorrebase db generate
first. -
rebase dev --generateis removed, because what it did is now the
default.rebase devregenerates the SDK types ingenerated/sdk/on
start and on every save underconfig/collections/, as it already did for
the Drizzle schema. Before this, the SDK was only regenerated with the flag,
so a fresh clone had no SDK types until someone ranrebase generate-sdk.
Passing--generateor-gnow stops with a message telling you to drop
it, andREBASE_AUTO_GENERATEandREBASE_GENERATEare no longer read. -
A relative
--collectionspath is resolved from the directory you run the
command in, on every command that takes one. This is the rule
generate-sdkanddb backup --outalready followed;schema generate,
db push/generateanddoctorpreviously resolved it frombackend/
instead. The same now applies toschema --outputand
doctor --schema/--sdk. A script written as
--collections ../config/collectionsand run from the project root must
drop the flag (config/collectionsis the default) or write
config/collections; the CLI refuses the old spelling and says exactly
this. New scaffolds' scripts carry no flag.generate-sdknow also accepts
--collections(--collections-dirremains an alias). -
rebase generate-sdkfails on a collection file that does not load,
instead of skipping it with a warning. It now loads every top-level file
inconfig/collections/with the same predicate the runtime boot uses, not
theindex.tsbarrel, so a file left out of the barrel is still typed, and
a broken file stops the command the way it already stops boot. The barrel
is a sort order (plusdefaultSecurityRules), not a registry. -
rebase initandrebase skills install --mcpconfigure your AI agents
to start the project's own@rebasepro/mcp, pinned as a devDependency with
the CLI (pnpm exec rebase-mcp;npx --no rebase-mcpon npm), instead of
npx -y @rebasepro/mcpfrom the registry. A project already pastinit
gets the devDependency added, with a message to install it, the next time
configureAgentsorskills install --mcpruns. -
rebase api-keys createtakes scopes.--scopes data:read:posts,logs:read
names what the key may do,--rolesthe RLS roles it runs as, and
--expires-in <days>or--expires-at <date>when it stops.--full-access
is every scope the service key holds exceptkeys:*.--permissions,
--adminand--expiresare removed and refused by name with their
replacement.rebase api-keys scopeslists every scope this backend knows.
Server & REST
-
createBackupRoutestakesstorageFor(destination), replacing itsstorageoption.listBackupObjects,readBackupBytesandopenBackupStreamthrow for an object destination they have no controller for, instead of returning nothing.BackupScheduleStatus.lastRunexcludes manual runs, and addslastManualRun,disabledInCode,refusedandhistoryError.BackupListingaddslocalDiskOfThisProcess. -
A live schema edit commits the collection and
schema.generated.ts, and
no SQL. The commit also carried the five SQL files, but it wrote them at
the project root while the CLI writes them next to the backend, so a project
could hold two copies that disagreed.SchemaCommitPathsand
DEFAULT_COMMIT_PATHSnow hold onlyschemaFile. -
An upsert whose key belongs to a soft-deleted row is refused with
409 ROW_IN_TRASHon every upsert door (?on_conflict=,/bulk,
/_batch, the SDK, the socket, andrebase.data/context.data). It used
to write the new values into the hidden row and answer201 Created. -
An update that changes a row's key is refused with
400 KEY_IMMUTABLE,
naming the field, on every door including in-processrebase.data. It used
to answer500, and in-process the row had already moved under its new key
when the write threw. -
Strict-Transport-Securityno longer sendsincludeSubDomainsby
default. A deployment that relies on the parent domain's HSTS header to
force HTTPS on a subdomain that cannot itself speak HTTPS yet should set
REBASE_HSTS_INCLUDE_SUBDOMAINS=trueto keep the old header. See the
entry under Security. -
Permissions are scopes: one
resource:actionvocabulary for every
credential. A scope names what a caller may do —data:read,
storage:write,functions:invoke,users:write,schema:write,
database:write,backups:read,cron:write,logs:read,keys:write—
and may name one target:data:read:postsisdata:readonpostsalone.
Every signed-in person holds the data plane (data:*,storage:*,
functions:invoke), because their rows are still decided by
securityRules. The admin plane is held by theadminrole, and by any role
the app declares underauth.roleson the users collection. Every admin
surface now asks for its scope instead of "is admin": users and roles
(users:read/users:write), the schema editors, the RLS audit, the contract
and the private API docs (schema:read, andschema:writeto apply), the
SQL editor and branches on the realtime socket (database:read/
database:write), backups, cron, logs, the development mailbox
(users:write, since a captured message carries a working sign-in link) and
service keys (keys:read/keys:write). A refusal answers403 SCOPE_MISSINGwithdetails.requiredScope. -
API keys hold scopes, not a permission list and an
adminflag.
permissions: [{ collection, operations }]andadmin: trueare gone from
CreateApiKeyRequest,UpdateApiKeyRequestandApiKeyMasked, along with
isOperationAllowed,ApiKeyPermissionandApiKeyOperation. A service key
hasscopesandroles— the RLS roles it runs as besideservice, where
["admin"]reads every row. Keys already stored are converted on boot to
the scopes they held and never more:{ collection: "posts", operations: ["read"] }becomesdata:read:posts,"*"becomes the data plane,
admin: truebecomes theadminrole and the users, schema, backups, cron
and logs scopes. A function grant withoutwritebecomes nothing, since
calling a function is not a read."storage"and"functions"are no
longer names in the collection field, so a collection calledstorageno
longer shares a grant with the files. -
schema-adminis no longer an administrator, and roles are no longer
stored objects.adminis the one built-in role. A project that used
schema-admindeclares it with the scopes it should hold, under
auth.roles.createRole,updateRole,deleteRole,getRoleById,
getUserRoles,RoleRepository,RoleDataandCreateRoleDataare
removed: the Postgres driver's versions saved nothing, and nothing read the
isAdmin,defaultPermissionsandcollectionPermissionsthey carried.
getUserWithRolesreturns role ids.GET /api/admin/roleslistsadminand
the declared roles with their scopes, andBootstrappedAuth.roleServiceis
gone. The MongoDB driver no longer seeds arebase_rolescollection. -
The hosted MCP server's scopes are
data:read,data:writeand
data:delete. Each can be narrowed to collections (data:read:posts),
and the tools reach only the collections granted. Grants made with
mcp:readandmcp:writekeep their reach: they are read asdata:read
and asdata:write data:delete. -
RealtimeSocketLimitsis nowRealtimeSocketOptions. It carries the API-key
resolver beside the limits, as the sixth argument ofinitializeWebsockets.
MCP
- The remote MCP endpoint (
/mcp) takes the SDK's own arguments:where
(wasfilter),orderByas["field","desc"]or"field:desc"(was
orderBy+order), anddata(wasvalues).query_collectionanswers
REST's shape,{ data, meta }(was{ rows, count, truncated }). An
argument a tool does not declare is now refused instead of ignored, and a
limitabove 1000 is refused, as on REST, instead of silently clamped to
Client SDK
- A write made through
context.dataorrebase.data(in a function, hook
or seed) now throwsUNSUPPORTED_OPTIONif it is givenifMatchor
idempotencyKey, instead of silently ignoring them. Neither option means
anything for a write that never leaves the process.
UI kit & plugins
-
ButtonandIconButtonfrom@rebasepro/uiare typed against the
element they actually render. A prop the component does not use,
includingIconButton's oldcolorattribute (which never did anything),
is now a compile error. Fix a call site by dropping the prop; nothing about
what renders changes. -
The insights plugin reads its figures from named sources, over one
comparison period. An insight carried its owndata()callback and a
scorecardmapping, so a figure on the home page and the same figure on a
collection's card were two fetches that could disagree, and the change
beside a figure was whatever precomputed delta the callback returned.
InsightsPluginConfignow takessources, functions that each return one
record of figures and are called with theperiod(from,to,
previousFrom), and an optionalperiod: { days }(30 by default). An
InsightDefinitionnames itssource, itsvalue: { field, format }and
an optionalcomparison: { previous, intent, show }; the plugin works out
the change from the two figures, as a percentage or, with
show: "absolute", a difference. A source is fetched once per signed-in
user however many insights read it. A config whose insight names a source
it does not declare, or whoseperiod.daysis not a whole number of at
least 1, throws when the plugin is created. Gone:ScorecardConfig,
ScorecardFormat(nowInsightFormat, withoutshowSign),
InsightDataResult,InsightsCache,useInsightsData,
InsightWidgetSkeleton, and thedescriptionanddateRangefields — the
row above the figures names the period itself. New:InsightsEngine,
resolvePeriod,useInsightSourceandInsightsRow.
Auth
-
An open realtime socket is re-checked, and closed when its identity has
ended. Before each frame, every 30 seconds for a socket that only listens,
and when its token expires, the socket asks whether the account still
exists, is still signed in and still has its roles. An ended identity gets
AUTH_ERROR(SESSION_ENDEDorTOKEN_EXPIRED) and close code 4001, and a
changed role re-scopes its subscriptions. The SDK reconnects with a fresh
token on its own; a client speaking the socket protocol directly must send a
fresh token inAUTHENTICATEbefore the old one expires. -
Following an email-verification link proves the address, and nothing
else. Without a live session of the account or its password, the link now
removes the password and any identity nobody proved, and ends every
session, as magic links, email codes and password resets already did. This
closes an account pre-hijack (see Security).client.auth.verifyEmail(token, options?)now POSTs, sends the session when signed in, and may throw
PROOF_REQUIRED: pass{ password }to keep the password, or
{ removeUnproven: true }. -
client.auth.signUpresolves{ confirmationRequired, user | null, … },
because withrequireEmailVerificationon there is no session until the
address is confirmed. -
mfa.enroll().recoveryCodesisnullwhen adding a second factor. The
codes are made once, with the first factor;mfa.regenerateRecoveryCodes()
replaces them on request. -
/auth/anonymous/linkand linking a provider to a guest need
registration open (allowRegistration), since both turn a guest into an
account. -
A deleted account's token answers 401
SESSION_REVOKEDon every door
(/meused to answer 404).
Admin (CMS & app)
- A markdown field stores an uploaded image as a storage reference, not a
URL. The editor wrote a private file's signed URL into the text, and its
download token expires (five minutes by default), so the image broke in the
panel and on every site that published the text. The text now holds
rebase-storage:<key>, with?storageId=for a named source, and the
panel's editor, preview and board cards exchange it for a fresh URL when
they render. A site that renders the markdown itself must do the same:
resolveStorageReferences(text, client)from@rebasepro/clientsigns each
object once per render and leaves a reference to a missing object as it is.
Text saved before keeps the URLs it was saved with.
Added
CLI
-
rebase initsets up your AI coding agents. It asks whether to install
the Rebase skills and register the Rebase MCP server, lists the agents it
finds on the machine (~/.claude,~/.cursor,~/.gemini,~/.codex,
~/.kiro, Windsurf's~/.codeium/windsurf), and pre-ticks those. The skills
and each agent's project-level MCP config land before the first commit:
.mcp.json,.cursor/mcp.json,.gemini/settings.json,
.codex/config.toml,.kiro/settings/mcp.jsonor.vscode/mcp.json.
Existing servers and settings in those files are kept, and a file with
comments is left alone. Windsurf reads MCP servers only from a user-level
file, so it gets the skills alone. Under--yesnothing is set up unless you
pass--agent(-a), e.g.rebase init my-app --yes --agent claude,cursor.
Before,rebase initonly printedrebase skills install. -
A static app can have a hostname of its own. Its
pathinrebase.json
may be a full URL, such as"path": "https://admin.example.com". The app
then answers only on that hostname, and every app without one keeps
answering on the others. The longest path still wins a request, and at an
equal path the app naming the hostname wins./apiand the other backend
paths answer on every hostname before any app. The app is built for the path
part of the URL, andcmsstays a path, on the app's hostname. On Rebase
Cloud,rebase cloud deployregisters the hostname on the project and
verifies it if its DNS records are already published. If they are not, the
deploy prints the records andrebase cloud domains verify <host>to run
once they are. The console's Open CMS link then points at the hostname.
Before, every app answered on every hostname the project had, so the admin
could only be a path such as/admin. -
rebase skills install --mcpregisters the Rebase MCP server in each
agent's project config, the wayrebase init --agentdoes. Without
--mcp, it names the agents whose config is missing the server. Use it for
a project that already exists. -
Tokens for CI and agents on Rebase Cloud.
rebase cloud tokens create --can deploy,logsmints a key that acts as you, narrowed to one project and
those actions;rebase cloud tokenslists them andrebase cloud tokens revokeends one. Set it asREBASE_TOKENand everyrebase cloudcommand
uses it instead of a login session, with no session file read or written.
The capabilities aredeploy,logs,env,databaseandbackups; none
of them can restore over a live database, which stays with a signed-in owner
or admin. -
rebase cloud cron listandrebase cloud cron logs <job> [--limit N]
read a deployed app's cron jobs and their recorded runs with yourrebase cloudlogin. The run history is in the app's ownrebase.cron_logs,
behind the app's admin gate, which accepts an admin of the app, anrk_
key or the service key, and a project owner signed in to Rebase Cloud
often holds none of them.rebase cloud debug logscovers only the pod's
last half hour or so. Each command asks the control plane for a token that
reads cron and nothing else, is bound to the one project and expires within
minutes, then reads the app at its public address with it (--hostreads
another address).--jsonreturns the jobs or the runs as the app reports
them. It needs a control plane that servesruntime-tokenand an app on a
runtime that accepts platform tokens (below); against either one that does
not, the command says which one and exits non-zero. Arebase cloud tokens
key needs--can logs, and one created before this release must be created
again to cover it.
Admin (CMS & app)
-
A record's actions can be buttons, not only menu items. An entity
action declaredcollapsed: falseis now a labelled button in the record's
bar, ahead of Save, as it already was an icon on the table row. Every
action used to sit behind the ⋮ on the record, whatever the developer
declared. Where the bar is short of room (a side panel, a dialog, a narrow
window) the buttons drop their labels, then the last-declared fold back
into the menu. The read-only view carries the record's actions too; it had
none. The menu now lists the collection's own actions first, then Copy,
History and Inspect, then Delete last and set apart — Delete used to be
second, between Copy and the collection's actions. A record action that
throws or rejects now shows its error in a notification instead of failing
silently, and a button shows a spinner while its promise is pending. -
An entity action can say why it is disabled.
disabledReason, asked
with the same props asisEnabledwhen that returns false, is shown under
the action's name in a menu and as the tooltip of a disabled button or
icon — on the record's bar and on the collection's rows. A disabled row icon
now shows its tooltip at all: it was the tooltip's own trigger, and a
disabled icon takes no hover. -
A data-import preview lists every value that will not be imported, per
column, with its row and the reason, before anything is written. -
admin.filledByServeron a property lets a record be created without a
required field that a server hook fills, such as a slug computed from the
title. The field stays required when editing. The reference app's
posts.sluguses it, so its auto-slug runs from the panel. -
The admin shows a "Live updates paused" banner while the realtime
connection is down. Open records and lists keep their data instead of
turning into errors. -
useCreateFormex({ onBaselineConflict })andrebaseEditsare exported
from@rebasepro/forms, for a form that needs to know when someone else
changed a field it also touched. -
CollectionWindowandisPropertyFilterableare exported from
@rebasepro/app. Empty-state overrides now also receiveisFiltered. -
The API keys panel speaks scopes. A key shows its scopes grouped into
data, admin and app, with the collections, buckets or functions each is
narrowed to, and the RLS roles it runs as — a key running asadminis
marked as reading every row. The create dialog offers only the scopes you
hold, narrows data, storage and function scopes to chosen targets, and says
in words what the key will be able to do. A My keys tab manages your
personal keys when the backend enables them. -
The users table resets two-step verification. Reset two-step
verification callsDELETE /admin/users/:uid/mfa, and is offered only to
whoever holdsusers:write, as the route is. The auth controller now reads
the scopes the signed-in user holds (heldScopes). -
The users table switches an account off and on. Disable or enable
account shows whether the account is disabled and flips it
(PUT /admin/users/:uid { disabled }); it is offered to whoever holds
users:write, and never on your own account. -
The CMS signs in from a magic link.
<frontend>/auth/magic-link?token=…
opened the sign-in screen and dropped the token; it now signs in, through the
code step on an account with a second factor, and opens the app at its own
address. A visitor already signed in is sent on without spending the link. -
Account settings change the email address. The Profile tab has an email
field: it mails the confirmation link and says which address is waiting.
The link opens its own step on the sign-in screen, signed in or not, and
says which sign-in providers it detached.
MCP
-
Remote MCP gets
count_documents(REST's/count) andsearchString
onquery_collectionandcount_documents. -
Remote MCP's
list_collectionsreturns each collection'srowand
createJSON Schemas (required fields, enums, foreign keys) and its
softDeleteField.
Studio
-
@rebasepro/typesexportsdiffCollections,applyCollectionPatch,
nestCollectionPatchPaths,collectionPatchProblems,isCollectionPatch,
runtimeOnlyKeyInandRUNTIME_ONLY_COLLECTION_KEYS, behind the live
schema editor's new patch-based writes (see Changed). -
SQLAdmin.runSqlScript(sql, { database, role })returns rows as
Postgres's own text, plus each column's source table and column, the
source tables' primary keys, the command, the row count and any database
notices. It is also available over the socket asEXECUTE_SQLwith
mode: "script", and asRebaseWebSocketClient.runSqlScript. -
The Backups panel shows how the scheduled backup is going. A backup job
that failed every night left the panel saying "No backups found yet". The
listing (GET /api/admin/backups,client.backups.list()) now carries
schedule: the job, its cron, whether it is paused, its next run, and its
last run with the error when it failed. The panel shows a failed run as an
error, a paused schedule as a warning, and otherwise the last and next
run.
Storage & email
-
STORAGE_MAX_FILE_SIZE(bytes,__<KEY>per source) sets the largest
file a storage source accepts; refused at boot unless it is a whole number
of bytes. -
STORAGE_DOWNLOAD_TOKEN_TTL/storageDownloadTokenTtlsets the
lifetime of private download URLs (default 300 seconds, at most a week). -
resolveStorageReferences,storageReferenceandparseStorageReference
are exported from@rebasepro/clientand@rebasepro/types, for reading
rebase-storage:references out of a markdown or text field.
Realtime
-
The realtime socket honours
impersonate, sent with the socket's
AUTHENTICATE, under the same rules asx-rebase-impersonate: an
administrator's session only, never an API key or the service key. The
decision is re-checked before every frame like the rest of the identity, so
demoting the administrator or disabling the user ends the session. Each
grant is logged as[Security Audit]. -
client.ws.stateandclient.ws.onStateChange()
(RealtimeConnectionState). After an outage of about 15 seconds,
CONNECTION_LOSTis reported once to every live subscription's and joined
channel'sonError. The subscription is kept, and its nextonUpdateis
the recovery signal. -
REALTIME_MAX_SUBSCRIPTIONS_PER_SOCKET/realtime.maxSubscriptionsPerSocket
(default 1000) refuses the next subscription past the ceiling with
TOO_MANY_SUBSCRIPTIONS; an invalid value stops the server at boot.
UI kit & plugins
-
@rebasepro/uiexportsisKeyHandled(event), for a component that
needs to know whether a keypress was already claimed by something above
it.DialoggainsdismissOnBackdrop,initialFocusand
onCloseAutoFocus;SheetgainsinitialFocus,onOpenAutoFocusand
onCloseAutoFocus;BooleanSwitchacceptsaria-label,aria-labelledby
andid. -
SelectandMultiSelectaccepttriggerContent, a node that replaces
everything the trigger draws: the value, the box, its padding, its height
and the chevron. It is for a caller that frames the select itself, as a
table cell does.RelationSelectorandUserSelectoralready took it.
Server & REST
-
REBASE_HSTS_INCLUDE_SUBDOMAINSopts back into the old
includeSubDomainsbehaviour. See the entry under Breaking. -
New docs: "Running more than one instance" and "Backups and restore".
-
Personal API keys. With
personalKeys: truein the users collection's
authblock, any signed-in account can create keys at/api/auth/keys
(client.personalKeys). A personal key acts as its owner, with the owner's
roles as they are when it is used, and holds no scope the owner does not:
demote the owner and the key shrinks, delete the account and it stops. A
service key is still created under/api/admin/api-keys. No key may manage
keys, and no key is created with a scope or an RLS role its creator does not
hold (SCOPE_EXCEEDS_CREATOR,ROLE_EXCEEDS_CREATOR,KEY_MANAGEMENT_SCOPE).
A target that names nothing this backend serves is refused
(UNKNOWN_SCOPE_TARGET). -
Apps declare their own scopes and roles.
auth.scopeson the users
collection names app scopes such asproject:deploy, with a label and an
optional target, andauth.rolesgives a role its admin-plane and app
scopes. Boot refuses a declaration that reads as a grant it is not: a role
listing a data-plane scope, an app scope reusing a built-in resource name,
or a declaredadmin. A function checks an app scope withrequireScope,
hasScopeorgetScopesfrom@rebasepro/server/functions; a person always
holds app scopes, so they narrow keys.GET /api/auth/scopeslists every
scope with its wording and the ones the caller holds. -
API keys work on the realtime socket and at
/mcp. A key authenticates
the socket with the same check as a request, and its data scopes decide
which collections it reads and writes there; it cannot use channels. An MCP
client configured with a header can present a key instead of going through
OAuth.verifyCredentialgives an app's own socket or tunnel the same
answer for a session token or a key: who it acts as and its scopes. -
Composite primary keys. Mark several properties
isIdand they form
one key:rebase db push, the schema check at boot and the generated
Drizzle file all write onePRIMARY KEY (a, b), and reads, writes, nested
paths andviarelations address a row by every key column
(company_translation/1:::en_US). 0.23 refused a secondisIdwhen the
config loaded. A relation or reference into such a collection is still
refused, by the schema generators and at boot, because a foreign key is one
column; reach those rows with aviarelation.rebase schema introspect
now generates a table keyed on several columns, withisIdon each key
column. It still skips the table when a key column cannot carryisIdor
another table points at it. The live schema editor treats re-keying an
existing collection as a migration and names the new key.rebase doctor
and the boot warning tell you to mark every key column. -
A server assembled by hand can install the access model a booted backend
uses.@rebasepro/serverexportsconfigureAccess, and
accessModelFromCollectionsto build the model from the users collection,
besideconfigureJwt, so a server or test built without
initializeRebaseBackendanswers for a person the way a booted backend
does.
CI & tooling
pnpm check:tsconfig-entriesfails a tsconfiginclude/paths/
extendsentry that resolves to nothing.pnpm typecheckreads
tests/e2eagain.
Auth
-
AuthAdapter.resolveUser(uid)returns the identity a request from
that user would carry now, without a token. The built-in adapter
implements it. A custom adapter without it answersx-rebase-impersonate
with501 IMPERSONATION_UNAVAILABLE.@rebasepro/typesexports the
header name asIMPERSONATE_HEADER. -
sidandexpon access tokens, andisCurrentSessionin the
sessions list. Signing one device out now ends that device's access token
too. -
auth.requireEmailVerification(AUTH_REQUIRE_EMAIL_VERIFICATION, off
by default): registration sends a confirmation and password sign-in answers
403 EMAIL_NOT_CONFIRMEDuntil the address is verified;/registerthen
no longer says whether an address has an account. Registration now mails
the verification link either way;POST /auth/verify-emailis the new
door the SDK uses. -
auth.refreshTokenReuse("reject", the default and today's
behaviour, or"revoke-session") andauth.refreshTokenReuseIntervalSeconds. -
auth.magicLinkCreatesUsers(AUTH_MAGIC_LINK_CREATES_USERS, off by
default): magic links and email codes may create the account, under the
registration policy. -
Disabling an account:
PUT /admin/users/:uid { disabled },
admin.updateUser({ disabled }), answered withACCOUNT_DISABLED; stored in
the newusers.disabled_atcolumn, whichdb pushplans. -
Admin MFA reset and new recovery codes:
DELETE /admin/users/:uid/mfa
(users:write, never on an account that outranks the caller; it ends the
user's sessions),client.admin.resetMfa(uid), and
POST /auth/mfa/recovery-codes/mfa.regenerateRecoveryCodes(). -
RealtimeSocketOptions.identityRecheckIntervalMs(default 30000). -
Users change their own email address.
POST /auth/change-email { newEmail }mails a link to the new address and a notice to the old one;
POST /auth/confirm-email-change { token }moves the account onto the new
address, verified, detaches the sign-in providers that vouched for the old
one and keeps its sessions. The link lives 24 hours; an account with a
second factor asks from anaal2session. The address is not reserved
while the link waits, so whoever holds it when the link is followed keeps
it (409 EMAIL_EXISTS). SDK:client.auth.changeEmail(newEmail)and
confirmEmailChange(token);/auth/meandUsercarrypendingEmail.
NewbeforeEmailChangehook andemail.templates.emailChange/
emailChangeNotice. Postgres gainsusers.pending_email,
email_change_tokenandemail_change_sent_at, added at boot and planned
bydb push. An X account can now replace its placeholder address, which
also no longer receives the welcome mail. -
Platform tokens on the admin gate. A server with
REBASE_PLATFORM_TOKEN_KEY(one or more PEM EC P-256 public keys) and
REBASE_PLATFORM_TOKEN_AUDIENCE(the project's id) set accepts anrpt_
bearer token signed by the hosting platform: ES256,iss: "rebase-cloud",
audequal to the audience, asub, and at most ten minutes between
iatandexp. The caller acts asplatform:<sub>with no roles, and
holds only the scopes in the token'sscopeclaim that the server allows
a platform to hold. Today that iscron:readalone, whatever the token
asks for, so it reads/api/admin/cronand its logs and gets403from
every other admin route. Each accepted request logsPlatform token acceptedwith thesubandjti. With neither variable set the feature
is off, which is every self-hosted server. With only one set, or a key that
is not a P-256 public key, the server logs a warning, boots, and refuses
everyrpt_token with401 PLATFORM_TOKENS_OFF.
Client SDK
-
impersonateoncreateRebaseClientruns every request as another
user, named by uid. It is sent asx-rebase-impersonateon each HTTP
request and asimpersonateon the realtime socket'sAUTHENTICATE. Only
an administrator's own session may use it. The data API, custom functions
and realtime run as that user, and every other route refuses the request
with400 IMPERSONATION_UNSUPPORTEDrather than answer as the
administrator. -
client.offline.pending({ orphaned: true })and
client.offline.clear({ orphaned: true })(OfflineQueueOptions). Writes
queued while nobody was signed in are never replayed as whoever signs in;
these list them so the app can re-issue or discard them.
Changed
Server & REST
-
Realtime and SQL admin:
EXECUTE_SQL_SUCCESScarrieseffectiveRolewhen a statement asked for a role, andSqlScriptResulthaseffectiveRole.BranchInfohas a requireddatabasefield. -
compileRulePolicies(collection, rule, options)in@rebasepro/commonis the one compiler of a security rule into Postgres policies. The schema planner delegates to it.
Admin (CMS & app)
- The home page's collection cards show the favourite star on hover or
focus, and keep it once the collection is a favourite, instead of an
empty star on every card; a touch screen always shows it. Group labels use
the UI kit's micro type and line up with the cards' left edge.
Studio
-
The translation key
studio_api_keys_admin_reads_every_rowis renamed tostudio_api_keys_admin_bypasses_rls. An app that overrides it must rename its override. The keysstudio_policy_help_role_authenticatedandstudio_policy_help_role_anonare removed, with the policy help that named roles no request runs as. -
POST /api/admin/schema/plan|applyand
/api/admin/schema-editor/collection/saveaccept apatch(operations
on key paths) for an existing collection, instead of only a whole
collection object;/applyacceptssourceOnly: trueandremove: true.
A wholecollectionposted for an existing collection is now applied as
the patch of what differs from it, so handlers, shared properties and
anything else defined in code are left alone instead of being overwritten. -
Renaming a property's key while keeping its column is a safe,
no-prompt change; changing or dropping a property's default is applied
withALTER COLUMN … SET/DROP DEFAULTinstead of being reported as
requiring a migration. -
validation.maxon atextcolumn is no longer reported as a column
change in the live schema editor's plan. -
useLiveSchemaEditing'swriteSourceOnlyoption is removed.
"Edit source only" is now a committed/applycall withsourceOnly: true
(see Added/Studio and Fixed/Studio).
CLI
-
REBASE_BRANCH_PARENT_DATABASE:rebase dev, and the other commands that start a child process, export it on a switched checkout. It names the database the branch was taken from, so the server keeps the branch registry and the default source there. -
rebase schema introspectwrites more explicit property keys than
before:columnType,precision/scale,defaultValue,required,
the key's real strategy,onDeleteand thesearchblock. A uuid key with
no database default is now written asisId: "manual"(it wrote"uuid",
which madedb pushadd a default that was never there); a serial key is
written asisId: "manual"withcolumnType: "serial"/"bigserial"(it
wrote"increment", an INTEGER identity). Re-introspecting overwrites only
with--force, as before. -
A boot-provisioned database whose bundle listed a two-sided
many-to-many's "later" collection first gets one junction primary-key
rebuild on its nextrebase db push, then none. -
rebase db pushtreats a primary key change as destructive. Changing a
collection's key, such as(id)→(id, locale)or moving it to another
column, gives every row a new id, and push applied it without asking, even
in CI. It now asks on a terminal, refuses elsewhere unless you pass
--allow-destructive, and says what changes: URLs, foreign keys and ids
stored elsewhere stop finding their row.
Client SDK
-
client.cron.listJobs()returns aCronJobListing, which carries the jobs the server refused (rejected) and the files it could not load (skipped,note) besidejobs. -
Generated
database.types.tsentries carry aSlugfield (regenerate
withrebase generate-sdk; older generated files still work). -
onSyncErrorreports a write discarded because an earlier write to the
same row was refused, with codeDEPENDENCY_REJECTEDand the earlier
refusal ascause. Before, the discarded write carried the refused
write's own error.
Storage & email
-
An upload into a property's
storagePaththat breaks the property's
maxSize/acceptedFilesis refused even when the request names no
property. -
The TUS upload URL (
Location) is now a path, not an absolute URL, so
an upload behind a TLS-terminating proxy is no longer sent to anhttp://
address the browser blocks. -
getSignedUrl/deleteObjectgiven ans3://bucket/keyor
gs://bucket/keyaddress the object by key, so a file stored with
includeBucketUrlon S3 or GCS can be previewed and deleted.
CI & tooling
-
./tooling/scripts/verify-quality.shruns every end-to-end suite CI
runs, through the newpnpm ci:e2e(lanesvitest,cli,selfhost,
admin). No suite stops the ones after it, and nothing is retried. Needs
Docker. -
pnpm check:api-surfacepasses an additive export change and banks it
intocontracts/server.api.txt. Only a removed export or a lost member
still fails it. The canary release runs it with--strict, where an
unbanked addition fails. -
The unit test suites run under
TZ=America/Los_Angelesin CI and in
verify-quality.sh. A test that needs a specific zone pins it with
tooling/scripts/jest/zone-environment.cjs.
Auth
-
change-passwordanswers with a fresh session, which the SDK adopts, so
the CMS stays signed in after a password change. -
Linking a provider to a guest turns it into an account, with the
registration policy and hooks. -
A refusing auth hook answers 400
HOOK_REJECTED(or the 4xx its error
carries) instead of 500; malformed JSON on an auth or admin route is 400
INVALID_JSON. -
onAuthenticatedalso fires for OAuth, refresh, password reset and MFA;
beforeLogin("oauth")andbeforeUserCreaterun on OAuth sign-in and
sign-up;afterLogoutfires for SDK and CMS sign-outs. -
Sessions record the client address the rate limiter resolves
(TRUSTED_PROXY_HOPS) instead of a rawX-Forwarded-For. -
The seeded admin is stored verified; the MFA key falls back to
auth.jwtSecret; the welcome email's button links to the app's root. -
A database failure while judging a token is 503 on every door; the
watermark-only check failed open.
Fixed
Server & REST
-
A record opens from a
viarelation's tab. Opening a row through a
joinPathrelation (an order from a product's Orders tab, through
order_items) failed with "Could not load data" while the tab listed it
fine. The check that the row belongs to the parent counted through a join
builder that read a step's tables only from atable.columnspelling, and
its columns only by Drizzle property key: a step written asJoinStep
documents it ({ from: "order_id", to: "id" }) named no table, and on a
generated schema (orderId: uuid("order_id")) no column either. A step's
tables now come from its position in the path, its columns resolve by SQL
name or property key everywhere a join path is built, and the count uses the
same condition as the listing. One-to-oneviawrites had the same table
lookup and refused every write; they resolve by position too. -
The OpenAPI spec says a delete on a
softDeletecollection moves the row to the trash. It documents?hard=trueon DELETE and bulk delete, and?deleted=include|onlyon list, single read, count and aggregate. A column nameddeletedis no longer offered as a filter, because the server reads that name as the soft-delete parameter. -
The SQL console's "current database" is the database the app's connection is on, not the one
ADMIN_CONNECTION_STRINGnames. A statement sent under the admin URL's database name no longer runs on the app's connection. "Default (main database)" in branching copies the app's database. -
A live schema edit now reminds a project that keeps migrations to run
rebase db generate. The reminder only appeared when migrations were in
drizzle/migrations/at the project root.rebase db generatewrites them
next to the backend, inbackend/drizzle/migrations/, so in a scaffolded
project the reminder never appeared. The change reached the database and
the repository, but the next environment built from migrations did not get
it. Both places are checked now. -
A static app nested inside another's path serves its deep links. With
one app at/adminand another at/admin/beta, a link such as
/admin/beta/settingsanswered 404 instead of/admin/beta's
index.html. Every app declined the paths of all the others, including
the app it sits inside, so both apps turned the request down. Each app now
declines only the apps nested beneath it. -
Editing a row in the trash, other than restoring it, is a
404through
the socket, MCP andrebase.data, as it already was over REST. An
update of a missing row no longer runsbeforeSavebefore the404. -
Restoring a soft-deleted row is recorded in history as an
update. A
trashed row's history can be read, and reverting it to a version from
before the delete restores it. A delete's history entry now holds the row
as REST serves it, not the admin view model, so reverting to the version
before a delete works, and entries recorded earlier can still be read. -
?include=together with?fields=returns the included relations on
REST and the SDK'sfind(), aslisten()already did. Naming the
relation infieldsis no longer a400. -
A REST list answer always carries
meta.offset. It is0when the
request named none; before,meta.offset + meta.limitwasNaNon a
first page. -
Sorting a list by an aggregate over a relation
(?orderBy=count(orders):desc) works again, and pages by cursor. The
list path refused every such sort with400 ORDER_BY_FIELD_NOT_SORTABLE,
and nometa.nextCursorwas ever issued for one. -
storage.putObject({ file, public: true })with nokeystores a
public file. Before, the upload was stored as a private one. -
The self-hosting VPS systemd unit boots. It sets
ALLOW_LOCALHOST_IN_PRODUCTION=truefor a database on the same machine,
and the production refusal of a loopback address now names that variable. -
Scheduled backups run in the official image.
rebasepro/serverships
the PostgreSQL 18 client tools (pg_dump,pg_dumpall,pg_restore);
every scheduled backup used to fail with "Could not find the 'pg_dump'
binary". -
Backup listing reads every page of an S3/GCS listing, so backups past
the oldest thousand objects are listed, and pruned, again. -
Backup download from object storage is held to the backup prefix; any
other.dumpobject in the bucket is refused. -
An unknown
GETunder/apianswers the JSONNOT_FOUNDenvelope
when a static app is served at/, instead of a plain-text 404. -
CORS_ORIGINS/FRONTEND_URLmatch the browser'sOriginwith a
trailing slash, a path, different case or a default port. A refused
origin is now logged once in production too. -
A full database pool answers
503 DB_POOL_EXHAUSTEDwith
Retry-After(and a connect timeout answers503 DB_CONNECT_TIMEOUT)
instead of500 INTERNAL_ERROR;/healthreports the underlying cause. -
An open Logs Explorer tab no longer holds a shutdown until the force
timeout. -
LOG_LEVEL=DEBUG, in any case, is accepted at boot, as the logger
already read it. -
A static-only bundle refuses a non-numeric
PORTand honours
PORT=0, as the backend does. -
What a cron job logs reaches the server log. Lines a handler wrote with
ctx.log()were kept only with the run, inrebase.cron_logsand the
in-memory history,
which only the admin-gated/api/admin/cron/:id/logsreads. The process
log got one line per run,✅ [cron] "<id>" completed in Nms, so nothing
a job said about itself showed up inrebase cloud debug logs. A managed
app's AI step that fell back withmodel unavailable (quota_exhausted)
left no trace there, and the app wrappedlogin all ten of its crons to
copy each line to stdout itself; that wrapper can go once the app upgrades.
Each line is now also logged atinfowith acronfield holding the job
ID. That copy is redacted like any other log line: the values of keys such
aspasswordorapiKey, and the SQL and bound parameters of a logged
query error, are replaced. The lines stored with the run are unchanged.
Postgres
-
Queues and topics declared in
config/resources.tsnow run. The
worker used to start with 0 tasks and everyenqueue/publishthrew "no
queue runtime is installed": the server's inlined copy of
@rebasepro/typesand the project's own copy each kept their own
registry. A third-party driver'sregisterDataSourceCapabilitieshad the
same split, and is fixed too. -
afterSave,beforeDelete,afterDeleteand history now receive the
row as stored, notafterRead's view of it. A masked field is no longer
recorded in history or written back by a revert, and a computed field no
longer makes a version un-revertable. -
On a fresh database, the first boot grants the request role
rebase_userUSAGE on therebaseschema, and on collection schemas, as
soon as provisioning creates them. Writes tohistory: truecollections,
and job enqueues inside a write, no longer fail with "permission denied for
schema rebase" until a restart. -
A
42501"permission denied for schema/table/function …" is now
reported as a missing GRANT, not as a row-level security policy. -
A hook can catch a
context.datadelete the database refused, as it
already could a create or update. Relation unlinks and many-to-many pivot
writes are contained the same way. -
A hook whose
context.datawrite re-runs itself without end is stopped
at depth 16 with500 CALLBACK_RECURSION, naming the hook and the
collection, instead of holding the transaction until the process dies.
Siblings, such as the rows of a bulk write, are not counted as nesting. -
A cron
timeoutSecondsof0orNaNis refused and listed under
rejected, as documented, instead of silently running with the 300
second default. -
A collection that declares no key can take a row. It used to get an
id TEXT PRIMARY KEYwith no default, and no door fills a key that is not
a property, so creating the quickstart's first row failed in the panel,
over REST and through the SDK withMissing required field: "id". The
implicit key now defaults togen_random_uuid()::text; a table created
before this gets the default at its next boot, and a key you send is
still used. -
A
hasOnerelation no longer stops the Postgres backend from booting.
A collection declaring one (a company with its billing config, the
foreign key on the child) crashed at startup with "Cannot read properties
of undefined (reading 'reduce')". The generatedschema.generated.tsbooted,
but reading the relation withincludefailed with "There are multiple
relations between…" once the child had two links back to the parent, and
for everyhasOneto its own collection. Both now name the join columns,
and the related row is typed as possiblynull.
CLI
-
A flag written before an action is no longer read as the action. The
CLI named the action as the first word after the group that was not a flag,
and a flag's value is such a word. Sorebase cloud deployments --limit 5
was refused as an unknown command5,rebase cloud debug --host staging.example.comasstaging.example.com, andrebase cloud projects --name shop createasshop. The same happened at the top level:
rebase db --collections ./config/collections push,rebase api-keys --name ci createandrebase skills --agent claude installwere each refused as
an unknown subcommand. All of these lines now run the action they name.
--helpon such arebase cloudline now prints the named action's page.
A mistyped action is still refused by name. -
rebase cloud debug logs,rebase cloud debug errors,
rebase cloud debug requestsandrebase cloud debug bootsay what
window their lines cover, and say when a limit cut it. The control
plane returns at most 2000 lines per pod and lowered a larger--tail
without saying so, and itstruncatedflag covers only its byte
budgets. So--since 30d --tail 200000returned 35 minutes of lines
marked"truncated": false. Each view now ends with the start of the
window asked for and the first and last timestamps returned, per pod
and overall (window,pods[].firstTimestamp/lastTimestampunder
--json). A pod that returned the full line limit and whose earliest
line is later than the start of the window is markedtruncated, with
atruncatedReasonsaying how much of the window was not read. A pod
that started recently returns fewer lines than the limit and is not
marked. A--tailabove 2000 is lowered to 2000 before the request, and
the output says so (tail.note). A--tailthat is not a positive
whole number is refused. -
rebase cloud debug bootreads each pod's log from its start, with no
time window and 2000 lines per pod instead of the newest 500 from the last
7 days, which missed the startup lines of any pod that had served traffic
since. When a pod has logged more than 2000 lines since it started, its
startup lines are out of the control plane's reach. The command then
names the pod (reachedStart: false,unreachedPods) instead of reporting
that nothing matched. -
rebase cloud debug --helplists--since,--tailand--previous
under the actions that take them, with each action's own default. The
help said "Default: 500" for every action, butrequestsreads 1000
lines, and it offered--previoustorequests, which rejects it. -
rebase db pushstops before a column type change that can lose
values —numeric(5,2)tonumeric(4,1),biginttointeger,
jsonbtotext— as documented. It never fired on the plan Atlas
actually prints, so these changes applied without a prompt, even in CI.
Each destructive change is also counted once instead of twice, and a plan
the CLI cannot read is refused rather than called safe. -
rebase db pushno longer fails forever after you change the type of a
column asearchblock reads ("cannot alter type of a column used by a
generated column"): the search column is dropped and rebuilt around the
change. If a push fails for another reason, the search columns it moved
are put back, and a generated column of your own that blocks a retype is
named with the way past it. -
rebase db push --dry-runon a synced database prints "No changes". -
rebase schema introspectfollowed byrebase db pushno longer plans
type changes, dropped defaults, dropped NOT NULLs, a phantomidkey or a
dropped search column on the database it just read. Tables keyed on more
than one column are skipped with the reason, and columns no property can
describe are listed with what a push would do. -
rebase doctorchecks primary keys (type, columns, a missing key) and
junction key columns, finds enum types whose names Postgres truncated to
63 bytes, and its type-mismatch remedy is now a statement that actually
clears the finding. -
A many-to-many that both collections declare gets the same junction
primary key fromrebase devandrebase db push, so a
boot-provisioned database no longer has its junction key rebuilt on its
first push. -
rebase init .works in a freshly cloned orgit init-ed directory.
A directory may already hold.git, other repository metadata, OS litter
files or aLICENSE; any other entry is refused by name, with the ways
forward.--gitreuses an existing repository instead of re-initialising
it. -
Saving a collection during
rebase devrestarts the backend once,
after the schema and SDK are regenerated. It used to restart twice, and
the first boot told you to runrebase schema generate. -
rebase startwarns at the top when it is not running as a production
server (NODE_ENVfrom.envor unset), and says what that means: the
first account to register becomes the admin. -
rebase buildbuilds each static app once. It used to run the
frontend'svite build && tsctwice: once to fold it into the backend
bundle, once for its own bundle. -
rebase build --helplists--output,--no-staticand
--skip-static-build.rebase db url --helplists--database-urland
--docker.rebase normalize-imports --helpprints its own help instead
of the global one. -
rebase apps config <app>prints an emptyVITE_API_URLfor an app the
backend bundle serves on its own origin. It no longer prints the address
of arebase devthat has stopped. -
Printed next steps now work for the reader they target:
rebase init --yesends with an--agentcommand;rebase start
explains how to reach an app that answers only on its own hostname; a
failedrebase upgradeinstall names the command that puts the pins back. -
The docs, the scaffold and
rebase-server --helpno longer say boot
leaves junction-table RLS todb push. Boot applies it. -
rebase skills install --agent codexwrites where Codex reads. It wrote
.codex/skills, which Codex never opens: Codex reads repository skills from
.agents/skills. It now writes there, the directory Gemini CLI and
Antigravity read too, and installs it once when both are named. -
The scaffold's
.gitignorekeeps.vscode/mcp.json. It ignored
.vscode/whole, so the MCP server registered for GitHub Copilot never
reached the first commit. Other editor settings in.vscode/are still
ignored. -
rebase upgradeleaves files git ignores alone. It read every
package.jsonandpnpm-workspace.yamlunder the project, so a gitignored
copy — a stale build context underscratch/, a vendored package — was
listed by--dry-runand rewritten with the rest. In a git repository it now
reads only whatgit ls-fileslists: tracked files, and untracked ones no
.gitignoreexcludes.node_modules,dist*and hidden directories are
skipped either way. Outside a repository, or run inside a copy its repository
ignores as a whole, it walks every directory. -
A new project's .env lists the REBASE_MIGRATE_ON_BOOT values the boot
accepts. It offeredmigrateandoff, and either one stopped the
backend from starting. It now listsensureandnone, says any other
value refuses to boot, and points migration files atrebase db migrate. -
The published rebase-collections skill no longer offers a cuid id. The
0.23.0 skill listedisId: "cuid", which config load refuses, so an agent
following it wrote a collection whose first boot failed. The skill now
offersmanual,uuidand a SQL strategy, and sayscuidis refused.
Admin (CMS & app)
-
useBackendStorageSourceaddresses a file under a folder named "default" by its own path, instead of the root file of the same name. It now shares the SDK's path rule, which is exported from@rebasepro/types. The rule's bucket check also requires a slash after the bucket name now. -
Selecting a table cell no longer moves its value. An enum's chip
dropped 3px when its cell was selected, and an enum array's chips 2.5px,
because the select inside the cell kept the height of a form field, which
is taller than a row's line. A number jumped 4px and switched from the mono,
tabular figures it shows at rest to the text font. A markdown paragraph
moved 2px, because its source was edited on a shorter line than the one it
is drawn on. All four now stay exactly where they were, at every row
height. Markdown with headings or lists still shows its source while it is
edited. -
The admin panel no longer sends requests on page load that it knows
will be refused. For a user without theschema:readscope, every load
calledGET /api/admin/schema/statusandGET /api/schema-editor/status,
and both answered 403. On a deployment without storage, every load also
calledGET /api/storage/sources, which answered 501 and was logged at
ERROR. These failures filledrebase cloud debug errorsand hid the real
ones. The panel now asks for the schema status only when
GET /auth/scopessays the user holdsschema:read. It asks for the
storage sources only whenGET /auth/configdoes not report
storage: false. A customAuthControllertakes part by reporting
heldScopesandstorageEnabled. One that reports neither keeps asking,
as before. -
The list view keeps its scroll position when you come back from a
record opened full screen. Full screen replaces the collection view, and
the list view, unlike the table and card views, never saved or restored
its offset. Going back landed at the top with only the first page loaded.
It now returns to the same rows. The card view also stops jumping back
slightly the first time more rows load after a fresh visit. -
Collection views scroll past row 1,000. The table, list and card
views used to stop at row 1,000 with the API's "limit above the maximum"
error, and each scroll page re-read every row before it. Board columns
said they were finished at 1,000 cards while their header counted more,
and the relation picker's "load more" failed the same way. Every view now
reads one page at a time, never above the read ceiling, and realtime
still updates every loaded row.pagination: falsenow loads every row,
as documented, instead of the server's default 50. -
A scrolled collection view keeps its rows while realtime reconnects.
The paged views turned aCONNECTION_LOSTinto an error about 15 seconds
into an outage, replacing every loaded row, even though the subscription
survives and its first push after reconnecting carries what changed. Rows
already on screen now stay there, under the connection banner, as they
already did elsewhere in the admin. -
Filters on a collection with a
fixedFilterwork. Filters applied in
the dialog or the header combine with the fixed filter instead of being
silently dropped, and the fixed fields stay locked. -
Bulk removal on a linked (many-to-many) tab no longer says "Delete …
This cannot be undone". It now says the rows are removed from this
record, which is what the server does. -
A failing
collectionsorviewsbuilder is reported. The home page
and a deep link show "Error loading navigation" with the cause and a
retry button, instead of an empty page or "Collection not found". -
A record deleted by someone else while you edit it keeps your form
open. A banner explains what happened and offers "Save as new", instead
of the form being replaced by "Entity not found". -
An empty collection with a default
sortoffers "Create your first
entry" instead of "No results with the applied filter/sort". A
read-only viewer sees "No entries found". -
The filters dialog and the table header agree on which fields can be
filtered. An array of maps, booleans or geopoints no longer shows an
empty, unusable filter row. -
Duplicating a record no longer moves the original's
hasMany/hasOne
children, or a one-to-oneviatarget, to the copy. The copy keeps its
ownbelongsTokey and itsmanyToManylinks. -
Property
conditions(required,hidden,disabled,readOnly,
min,max,defaultValue,acceptedFiles,maxFileSize) are applied
by the record form, and adynamicPropsvalidation is checked on save.
A required field the form keeps hidden, read-only or disabled no longer
blocks the save. -
A record stored before a validation rule existed can still be edited
from the panel. Untouched fields that already broke a rule are listed
as a warning instead of blocking the save. -
Emptying a text field or text cell writes NULL, not an empty string.
-
A list item or map key deleted in the side panel stays deleted after
"open full screen", a split-view toggle, or a restored draft. -
A
columnType: "time"property reads and writes its time of day; it
no longer reads as null. -
A CSV the admin exported imports its geopoints, key-value maps and
arrays of maps back. A cell that is not one of those is reported before
anything is written. -
An open record form no longer saves back the old value of a nested map
field someone else changed while you edited a different field of the same
map. -
A collection whose slug contains slashes can have subcollections that
open. A versioned or locale-partitioned collection such as
medico/v2.0.0/jointsopened, but nothing under it did: its subcollection
tabs rendered empty, because every path reader took the first segment as
the collection and alternate segments as ids. The admin now finds the
longest leading run of segments that names a collection, so breadcrumbs,
the side panel, subcollection tabs and data routing reach
medico/v2.0.0/joints/j1/movements. A reference filter in the URL into
such a collection keeps its whole path, andusers/abc123is no longer
read as the collectionuser. -
A Firestore collection that declares its own
pathis read, written and
referenced at that path. A collection registered asfs_diagnosiswith
path: "diagnosis"(beside a Postgres collection calleddiagnosis), or as
medico_jointsfor documents atmedico/v2.0.0/joints, was read at its
slug: the list, the side panel and every subcollection under it came up
empty. A stored reference todiagnosis/abcopened the Postgres
collection, and a reference picked in a form stored the slug. The admin
still addresses the collection by its slug, and the driver is now handed
the declared path on every read and write, subcollections included.
References are stored under the declared path and read back preferring the
data source of the record they were read from, or the reference's own
driver. Form actions copy and delete by the slug, so a delete can no
longer reach the Postgres collection of the same name. -
A JSON file exported from a collection imports its geopoints, key-value maps
and vectors back; before, they were dropped without a word. A nested key in a
JSON file is mapped under the map its parent matched, such as
Home Address→home_address.street_name. -
A CSV exported from a collection imports back its vectors, its block lists
written one cell per item, the dates inside arrays of maps and fixed lists,
and times of day exported as timestamps. -
In the import's mapping step, a key-value map can be chosen as a column's
target. -
A column mixing numbers and text is imported as text. Creating a
collection from a CSV voted such a column into a number, so the zip code
02134 became 2134, 20-digit SKUs were rounded, andN/Aor1,234in a
price column became empty. Text that spells a number exactly, such as
10.00, still counts as one. -
A file imported into a new collection keeps every column, whatever its
headers.First Nameandfirst_namebecame one property, and so did
Price ($)andPrice (€); a header row in Japanese, Cyrillic or Greek
collapsed to a single property with an empty key. A header that slugs to
nothing is nowcolumn_<n>, and a taken key gets_2,_3and so on.
Studio
-
SQL console:
-
An inline edit that changes no row says "No row was updated" instead of "Row updated successfully", and the grid keeps the stored value. This happens when an update policy hides the row from the chosen role, or the row changed after it was read.
-
The console says when a query ran as the connection owner rather than the role picked (
DISABLE_DB_ROLE_SWITCHING), and greys out the role picker. -
"Open #id" is offered only on rows read from the app's own database. Before, a row read from a branch or another database opened, and saved, the main database's record with that id.
-
An empty-string cell is shown as empty, not as NULL. Opening a cell and leaving it unchanged no longer writes NULL.
-
"Format SQL" leaves string literals, quoted names and comments alone. It no longer joins a
--comment to the code after it, or splits>=,<=and!=. -
"Limit 1000" now limits a SELECT that ends in a comment or uses an aggregate (
count(*) OVER ()included). -
Branches: Create Branch's "Source Database" copies the chosen branch's database. Before, it sent the branch name, so it failed, or it copied an unrelated database of that name. On a
rebase devrunning on a branch, the pane lists the branches recorded in the main database, the current one included, and records new ones there. -
RLS editor:
-
On a table Rebase manages, where the editor writes straight to the database, a banner says each server start (and
rebase db push) turns RLS back on and re-creates the declared policies. -
Saving a policy with nothing changed says so, instead of "Policy saved successfully". The "Unapplied" tooltip names what applies the policy.
-
The policy help no longer points at the
authenticatedandanonroles, which Rebase never creates. -
The schema visualizer draws the table
db pushcreates (blog-postsbecomesblog_posts), with that table's RLS marker. It says it is drawn from your collections, and shows the declared property types instead of guessed SQL types. -
Storage browser:
-
Deleting a folder deletes all of it: every file past the first thousand per level, and on S3 and GCS the marker left by "New folder". The browser reads the folder back after a delete and says what is still there.
-
Listings show more than a thousand entries.
-
Switching source or folder while a listing is loading no longer shows, or acts on, the previous one's files.
-
The preview's link is minted when the file is opened, and Download and Copy URL each mint a fresh one, so they keep working past the token's five minutes. The preview says whether a link is temporary (anyone who has it can open the file until it expires) or public and permanent.
-
Cron Jobs:
-
Run Now reports how the run went; a failed run shows its error instead of a green "Job triggered".
-
The panel names cron files that failed to load and jobs the scheduler refused, with the reason.
-
When the run history cannot be read, the panel says so (
GET /api/admin/cron/:id/logsanswers 503) instead of "No executions yet". -
On more than one replica, a job's card shows every replica's runs, not only the serving replica's.
-
Backups:
-
The panel lists and downloads
s3://andgs://destinations through a controller for that bucket, as the backup cron does, instead of through the app's file storage, which showed "No backups yet." for a bucket full of backups. A destination it cannot read answers 503 with the reason. -
"Last scheduled backup" counts scheduled runs only; a run started by hand is shown on its own line.
-
A job declared
enabled: falsereads as off, not paused. -
A refused job is reported with the reason, and the history is shown as unreadable when it cannot be read.
-
Logs Explorer: the Source filter files sign-ins under Auth and uploads under Storage; before, every request was filed under API. The explorer says it shows the log of the one server instance serving it, and names that instance.
-
A list that fails to load because the session ended (401), or because of a server fault, shows the error and a Retry button. It no longer says the account lacks a scope. This applies to API keys, Backups, Cron Jobs, Branches and Storage.
-
"Run as" and the JS editor:
-
The picker says when it lists only the first page of users ("50 of 500 users").
-
The JS editor's "Admin: List users" snippet returns one page and its
total, and says so. -
Result rows offer "Open #id" only when the script names a single collection outside comments and ran as you.
-
The JS editor's "Run as" runs the script as the user you pick. It built
a client with your own token and nothing naming the user, so a script run
"as" someone ran as you. The picker also offered only you. It now lists your
users, and a run as one of them usesimpersonate: data, functions and
listen()run as that user, and calls to other routes (client.admin,
storage, auth) are refused instead of answered as you. -
The API explorer's "Run as" runs the request as the user you pick. It
sentx-rebase-impersonate, which no server code read, so the request ran
with your own session and the response showed what an administrator sees,
labelled as the other user's. The data and functions APIs now honour the
header on a signed-in administrator's session: the request runs as that
user, with their uid, current roles and claims, under row-level security.
Each such request is logged as a[Security Audit]event, and its request
log line carriesimpersonatedBy. An API key, the service key or a
non-administrator gets403 IMPERSONATION_FORBIDDEN. A uid with no active
user gets404 IMPERSONATION_TARGET_NOT_FOUND. Every other route refuses
the header with400 IMPERSONATION_UNSUPPORTED. None of these runs as the
caller. The picker also lists your users now; before, it offered only you. -
The source-only schema editor no longer overwrites edits made on disk
since the server started; it re-reads every file before each edit. -
A schema commit refused by a git hook, identity or lock leaves the tree
as it was, and namesgit commitwith the hook's output; retrying is no
longer refused as a dirty tree. -
A DDL failure part-way through a schema change reports how many
statements ran, instead of claiming the database was not changed. -
Saving a collection in the editor writes only the keys you changed:
handlers (onClick, a computedvalue), properties shared from another
module, imported enums, comments and formatting are kept. An edit that
reaches into something defined in code is refused, naming where to make
it instead. -
"Save default filter" no longer writes the runtime view of the
collection into its file, which stopped the file type-checking;
failures are now shown instead of silently dropped. -
The live schema editor classifies changes from the schema each side
actually produces: an integer toggle, string↔enum, an array's element
type, a relation's target/kind/key/onDelete, a unique toggle, and a
hasManyover a column nothing creates are no longer reported as "safe,
no change". -
Re-adding a property over a column left behind says it re-uses the
column, and refuses one of another type instead of silently reusing it. -
"Edit source only" says, change by change, what it leaves in the
database (including a NOT NULL column that will reject every insert),
and is committed with a message naming it. -
A security-rules edit is planned and committed as one change ("change
the security rules of posts"), not reported as "no change to make". -
Deleting a collection removes it from
index.tstoo, is refused
while another collection links to it, is committed, and keeps the table
and its rows, as the confirmation now says. Previously the project stopped
loading its collections. -
The SQL console refuses to edit a JOIN result whose key column name is
shared, or where the edited value has no traceable source column
(a computed or literal column). Editing a cell is now refused with the
reason when Postgres cannot say which table and column it came from;
before, a JOIN edit could silently update the wrong row, or write a
computed column's value into a real column of the same name. Identifiers
in the generatedUPDATEare now quoted. -
A SQL console run that leaves a transaction open (a bare
BEGIN) is
refused and rolled back, instead of letting a later run's statements
land inside it. SplittingBEGIN, anUPDATEandROLLBACKacross three
separate runs used to commit theUPDATEand report three successes.
Database notices such as "there is no transaction in progress" are now
shown. The role picked in the console now holds for the whole run; a
COMMITinside the script used to drop it, after which later statements
ran as the connection's own role. -
A multi-statement script in the SQL console shows its last statement's
rows instead of "no results". Values round-trip exactly — arrays,
bytea,json,numeric, timestamps — because the console now reads and
writes Postgres's own text form. -
A folder created on S3 or GCS no longer lists a nameless file. The
zero-byte marker that stands for the folder was returned as an item of its
own listing. -
The storage browser is translated. Its buttons, tooltips, empty and
refused states, dialogs and notifications were English in every locale;
they arestudio_storage_*keys in all seven now, with plural forms for
counts. The icon buttons' labels in the collection editor and the storage
preview are translated too.
Realtime
-
Realtime socket requests are no longer sent twice after a dropped
connection. A request already in flight when the socket closed now
fails withCONNECTION_LOSTinstead of being replayed on reconnect — one
save()used to make two rows, and the Studio SQL console could run a
statement twice. A request queued while disconnected fails with
REQUEST_TIMEOUTafter 30 seconds instead of waiting forever. -
Live views survive a server outage longer than a minute. While
anything is subscribed, the client keeps reconnecting (at most every 30
seconds, with jitter) instead of giving up after five attempts, and every
subscription and joined channel is re-subscribed and refreshed once the
server is back. -
Signing in over the realtime socket on a project with two
realtime-capable data sources no longer fails withINTERNAL_ERROR,
and re-scopes subscriptions on every source. -
Subscribers asking the same question as the same user now share one
refetch per write. One write to a list held by 1,000 identical
subscribers now costs 2 queries instead of 2,000, and a large statement
under change capture no longer stalls the event loop. -
Realtime subscription ids belong to the socket that chose them; two
clients that happen to pick the same id no longer steal each other's
subscription. -
A realtime LISTEN connection that goes half-open (an idle-flow
timeout on a load balancer or NAT) is detected by a heartbeat, replaced,
and its subscribers resynced.isCdcActive()and/healthreport it,
and/healthturns degraded after 60 seconds down. -
Retained channels on the
postgresbus deliver in sequence order
across instances. Readers could previously see message N+1 before N,
and the SDK would drop N. -
Without change capture (
REALTIME_CDC=off, or a database role that cannot
create triggers), the cross-instance LISTEN connection is checked by a
heartbeat. A connection that goes half-open — an idle-flow timeout on a load
balancer or a NAT — is replaced within about 45 s and every subscription is
refetched; a database that cannot be reached at startup is retried./health
lists the connection ascross-instancewhile it is down and fails after
60 s down. Before, writes made on other instances silently stopped arriving. -
The server pings every realtime socket every 30 s and drops one that has
not answered by the next ping, and drops a socket with more than 16 MiB of
unread frames queued. A dropped socket's subscriptions, channels and presence
are cleaned up as on a normal close. Before, a client that vanished without
closing (lost signal, sleep) kept its subscriptions refetched on every write
for about two hours. Clients that speak the protocol directly must answer
pings, which every standard WebSocket library does.
Client SDK
-
A file under a folder named "default" is the file the SDK reads and deletes. For a key like
default/photo.png,getSignedUrl,getObjectanddeleteObjectused to name the root file of the same name. So the storage browser previewed the wrong file, and its Delete removed the root file while the intended one stayed. A key underdefault/public/was also served as the root public file, without a token. -
With
authFlowMode: "cookie", the client no longer sends a refresh token
in the body of/auth/refreshor/auth/logout. The cookie carries it.
The client used to send back the""the server returns in cookie mode,
and servers since 0.10.0 refused that with a 400, so an open tab could not
renew an expired session. In cookie modesession.refreshTokenis"",
and the client never sends it as a token. A refresh refused with400is
now logged withconsole.error, including the server's message, and is
not retried, because the same request gets the same answer. It used to be
retried silently six times. The session is kept, as it is when retries run
out. -
A generated SDK lints clean. A collection with no relations was emitted
asRelations: {}, which typescript-eslint's recommended
no-empty-object-typerefuses in any project that lints its generated
types; it is nowRecord<never, never>, the same type forinclude. -
upsert()withoutonConflictupdates the row already at that key,
instead of answering409, as documented; it now matches what it already
did offline and in a function. -
A typed
client.batch()takes the collection accessor (orderItems)
and sends the slug (order_items); every snake_case collection was
unusable in a typed batch. Batch values are now typed per collection, so a
misspelt column, or a create missing a required one, is a compile error. -
A browser on another origin can read
ETag,Retry-After,
X-Request-ID,X-RateLimit-*andPreference-Applied: the runtime
and the ejected backend now expose them. Before,etagOf(row)was always
undefinedcross-origin, andifMatchwrites silently went out
unconditional. -
A grouped
aggregate()says when its groups were cut off
(result.meta.hasMore; the HTTP route defaults to 50 groups) and takes
offsetto page past them. -
Generated types no longer let you write a field nobody may write; a
non-required column can be written asnull, andhasMany/manyToMany
memberships (with_pivot) are writable. -
A typed client refuses an unknown collection accessor and an unknown
includerelation at compile time, typesreturning: falseresults as
possiblyundefined, and typescollection("snake_case_slug")and
client.collection(slug). -
An offline create refused with a 409 (a unique value already taken)
is rolled back and reported toonSyncErrorinstead of being quietly
dropped from the queue as synced. -
With
offlineon,find({ where: { field: undefined } })and the bare
PostgREST string form no longer throw aTypeError. -
A request answered after a sign-out/sign-in no longer writes the
previous user's rows into the new user's offline database, and a replay
acknowledged after the switch is no longer sent again when the first user
comes back. -
With
offlineon,listen()shows the user's queued edits instead of
the raw server row, andlistenById()removes a row deleted elsewhere. -
One refused field in a merged offline edit no longer rolls back the
user's other edits in that write. -
With
offlineon, a write made while the session is still being restored on
load is queued for the user the session turns out to be. In cookie mode every
page load has this window, and a write made in it was queued as the
signed-out user's, where no sign-in ever replayed it; reads in that window
showed the signed-out local database instead of the user's. Offline reads and
writes now wait forauth.isInitialized(). -
With
offlineon, a stored session whose refresh is refused on load no
longer leaves that user's cached rows readable, or their outbox writable, by
the now signed-out client. -
The offline queue replays when the browser comes back online, also with
automatic retries off. WithsyncIntervalMs: 0the queue waited for the
app to callsync(), although the documentation says the browser's online
event triggers a replay. It now does.
Auth
-
Searching the admin user list also matches roles and uids (
GET /api/admin/users?search=, Postgres), so the "Run as" picker's "search by name, email, or role" works. MongoDB still matches email and name only. -
Under
cookieAuth, a tab whose access token has expired can renew it
again./auth/refreshanswered{"refreshToken": ""}with
400 INVALID_INPUTbefore reading the cookie, and@rebasepro/client
sends exactly that from any open tab, because""is what cookie mode
returns in the token's place. Any tab left open past the access token's
lifetime (one hour by default, so every laptop that slept) showed its
login screen, and a reload signed the user back in. An empty
refreshTokennow counts as no token on/auth/refreshand
/auth/logout, so the server reads the cookie. This also fixes apps
already deployed with the older client, with no rebuild: a managed
runtime serves them its own copy of@rebasepro/server. -
A wrong password or MFA code is no longer answered with a token refresh
and a resend. A mistyped step-up code used to spend two of the
challenge's five attempts instead of one. -
The CMS verifies a verification link opened while signed in; it rendered a
not-found page. -
Expired auth tokens are deleted.
deleteExpiredTokenshad no caller,
so every expired reset link, magic link, email code, abandoned refresh token
and MFA challenge stayed in the database. The server now sweeps them once an
hour, on the process that owns the timers and on one instance of the fleet:
the hour is claimed inrebase.cron_claimsasrebase:auth:expired-tokens.
The Postgres repository's sweep now covers magic links, email codes,
refresh tokens and MFA challenges as well as reset links. -
providerIdkeeps saying how the session was signed in after a refresh.
/auth/refreshand/auth/meansweredpasswordfor every session, so a
Google user readgoogleat sign-in andpasswordan hour later, and a
guest readanonymous, thenpassword. The method is stored with the
session (refresh_tokens.method, added at boot) and carried across every
rotation; sessions signed in before the upgrade readpassword.
MCP
-
Remote MCP reads return REST's rows (ISO dates, a
belongsToas its
foreign key), not the admin view model. A row that was read can be
sent back inupdate_document. -
An update or delete of a row in the trash over remote MCP is refused,
as REST refuses it with a 404. -
Remote MCP no longer tells the model a failure inside the server is
"usually a permission rule" when it is not.
UI kit & plugins
-
A dialog or side sheet opened from the keyboard now takes focus, so it
is announced and a second Enter no longer fires the button behind it.
Closing it returns focus to the button, or menu, that opened it. -
A focused primary, secondary or destructive button, any switch, and a
multi-select now show a visible focus ring. -
Escape on an open dropdown or menu inside a record closes only the
dropdown, not the record or the inspector. -
A disabled date field can no longer be cleared, and its calendar button
no longer throws. -
Expandable field groups and home-page groups open and close with Enter
and Space. -
A boolean field is announced by its label, as one switch.
-
Pressing Enter in the property editor's Name/ID fields, or the
collection details form, submits what is typed, not the value from
before typing. -
Icon buttons with a tooltip are announced by the tooltip's text.
-
AI autofill never offers or applies a suggestion for a locked field, or
for a key that is not a property of the form.
rls-check
-
unqualified-column-in-subqueryfires on a live database, instead of
being silently skipped. -
security_invoker = 1/yes/'t'is no longer reported as a bypass. -
A write policy with no clause is no longer reported as accepting every
row. -
Printed REVOKEs revoke every grant that reaches a role.
-
A table no exposed role can reach is no longer reported.
-
An UPDATE policy with only
WITH CHECK (true)is now graded high, not
critical. -
A 20,000-table catalogue is scanned in about a second, down from
about 34 seconds.
CI & tooling
- The CLI end-to-end suite passes again: it looks for
db generate's
output in.rebase/sqlanddrizzle/migrations, and it no longer leaves
the backend it starts running after the suite exits.
Runtime
-
Google sign-in works on the managed runtime. The image did not ship
google-auth-library, which the server loads to verify a Google ID token.
Every One Tap or rendered-button sign-in failed, and so did every
authorization-code sign-in, the admin's included, because Google returns an
ID token with the code. The server logged "google-auth-library is required
for Google OAuth", and the user saw "Invalid google credentials". Only the
access-token flow worked. Adding the package to a project did not help: the
server resolves its imports from the image, never from the bundle. The image
now installs it. -
Live schema editing works on the managed runtime. A deployed server has
no collection source, so it edits throughliveSchema.repository, and it
rewrites the file withts-morph, which the image did not ship. The editor
reportedSCHEMA_EDITOR_MISSING_DEPENDENCYand said to run
pnpm add -D ts-morph, which a managed project cannot use to reach the
server. The image now installsts-morph28.0.0, the version the server
pins.
Storage & email
-
GET /api/storage/sourceson a deployment without storage answers
200 {"data": [], "configured": false}instead of
501 STORAGE_NOT_CONFIGURED. Storage being off is a configuration
choice, not a server error, so the request is no longer logged at ERROR.
Uploads and every other storage route still answer 501. With storage
configured, the answer carriesconfigured: true.GET /api/auth/config
now includesstorage, which says whether the backend serves file
storage, so a client can know before it asks. -
A production deployment that configures no storage logs
"File storage is off" once at INFO on boot, instead of an ERROR. With
noSTORAGE_TYPEand no declared bucket engine, the default source falls
back to local disk. Production drops local disk, and that was reported as
a misconfiguration on every boot, though the project simply uses no
storage. Local storage asked for withSTORAGE_TYPE=local, or passed in
code, is still an ERROR in production. -
A storage path with a
%that begins no escape
(/api/storage/metadata/100%-done.txt) answers400 INVALID_STORAGE_KEY
instead of 500. -
/api/storage/file/DEFAULT/public/x,public//xand.%2Fpublic/xare
served anonymously likepublic/x. The route already served that object;
the public check refused it. -
A re-sent resumable-upload chunk is written once. Two TUS
PATCHes at
the same offset, which is how a client retries a stalled chunk, both
appended, so the object held the chunk twice. APATCHthat arrives while
another is being received now answers423 UPLOAD_LOCKED, which
tus-js-client retries, and each chunk is written at the offset it declares. -
S3 and GCS objects keep one version across reads. Every read of an
unchanged object had a newETagand aLast-Modifiedof now, so nothing
ever answered304, every?width=request re-encoded the image, and with
storageRenditionCacheon each such request wrote another rendition into
the bucket. Objects are now dated by their last write, on local disk too. -
Reading a folder as a file is a 404 on local storage, as on S3 and GCS.
GET /api/storage/file/<folder>answered 500 with the filesystem'sEISDIR,
and/metadata/<folder>answered 200.
Security
Realtime
-
A retained channel's messages no longer travel in the Postgres bus's
NOTIFY payload. The notification names the message (channel,seq) and
each instance reads it back fromrebase.channel_messages. Before, any
database login couldLISTENand read every retained channel's traffic.
Ephemeral broadcasts and presence still travel in the payload. -
Change capture no longer broadcasts changed rows. The
rebase_cdc
NOTIFY payload now carries only the changed row's key (plus a junction's
two ids). Previously any database login couldLISTENand read every
changed row of every instrumented table, including the auth table's
password hashes and verification tokens. Existing databases are updated
on the next boot, including boots withREALTIME_CDC=off.
Studio
-
Studio's JS editor runs "No Auth" scripts anonymously. Its client restored the session the app keeps in
localStorage, so requests and the realtime socket carried the signed-in administrator's token. The "No Auth" and "Run as" clients now start with an empty session of their own and are closed after the run. Under "Run as",client.auth.getSession()isnull, andcontext.user(nullunder "No Auth") says who the script runs as. -
The RLS editor lists a policy that is in both your code and the database as the database holds it. It shows the database's TO list, command, mode, USING and WITH CHECK, and a "Differs from code" badge where they differ. Before, it showed the code's version, so Rebase's own policies (the admin baseline, the tenancy gate, the auth write gate) appeared with an empty USING. Saving one straight to the database in the hosted console recreated it without its condition, which removed a restrictive gate. Editing a Rebase-generated policy directly in the database is now refused.
-
The SQL console's audit log line no longer contains the literals a
statement carries.ALTER ROLE … PASSWORD '…', user-mapping passwords
and connection strings were written verbatim to the logs.
Admin (CMS & app)
- The admin's browser bundle no longer ships server-only callback code
written as a method, by shorthand, by reference, by spread, or imported
from a server module. Before, the build-time strip recognised only
arrow functions, so any other way of writing a server hook shipped its
source, and anything that code alone imported, to the browser.
Storage & email
-
SVGs are served as
image/svg+xmlunder
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox
on every serving path, so an SVG opened directly can no longer run
script on the API's own origin. Before, it downloaded as
application/octet-stream. -
Private file reads (
?token=URLs, the panel's thumbnails) are charged
to the user who minted the token, not to the reader's IP at the anonymous
allowance; the server's ownrebase.storagecalls are never
rate-limited; and a refused bearer token is no longer partly written to
the error log. -
A private file whose key contains
<word>:/public/can no longer be read
anonymously asGET /api/storage/file/<word>://public/…. The "is this
public?" check read the URL path and stripped what it took for a scheme,
while the route served the canonical key, which is private: a hook that
denied every read was never asked, and/metadataminted a download token
for it. The check now runs on the key the route serves.storageAuthorize
hooks should testkeywith the newisPublicStorageKeyfrom
@rebasepro/types;isPublicStoragePathstays for client-side paths and
URLs. -
A private file under a folder named
default(keydefault/public/x) is
no longer served withCache-Control: public, and new projects'
config/storage.tshook no longer treats it as world-readable. -
storageAuthorizeand policy predicates receive the storage source the
request is served from.?storageId=%20privatereached the hook as
" private"while theprivatesource served, deleted or took the write.
The default source is now alwaysundefined, including for?storageId=
and?storageId=(default).
MCP
- The remote MCP endpoint fences the row data it returns in the same
untrusted-data envelope the local MCP server uses, and says so in its
instructions and tool descriptions.
rls-check
- rls-check grades
auth.jwt() IS NOT NULLandauth.role() IS NOT NULL
policies on Supabase as critical. They were graded low and described as
excluding anonymous callers, which they do not.
Server & REST
Strict-Transport-Securityno longer includesincludeSubDomainsby
default; setREBASE_HSTS_INCLUDE_SUBDOMAINS=trueto opt back in (see
Breaking). Static apps, the CMS included, now send
Content-Security-Policy: frame-ancestors 'self'; object-src 'none'; base-uri 'self'
unless the response already set its own.
Auth
-
The API keys panel says what the admin role on a key does. Row-level security does not limit its reads, writes or deletes, and it passes the admin checks in custom functions and storage. The panel used to say only that such a key "reads every row".
-
A key's rate limit holds on the realtime socket and on /mcp. A key's data frames and MCP requests count in the same
api-key:<id>bucket as its HTTP requests, at the key's own limit. Before, those doors counted it at the per-user default, separately. The panel says what the limit covers, and that admin operations are not rate limited. -
A
users:writerole cannot reach past itself. Nobody edits, resets the
password of, or deletes an account holding a scope they do not hold
(ACCOUNT_OUTRANKS_CALLER), and nobody grants a role holding more than they
do (ROLE_EXCEEDS_CALLER). Only an administrator grantsadmin. Without
this, a support role could reset an administrator's password and sign in as
them. -
The registration default role may hold no admin-plane scope. Every
registrant receives it, so boot refuses adefaultRolethat isadminor a
declared role holdingusers:write,schema:writeor any other admin
scope. -
"Admin" is decided one way on every door. Storage, the upload handler
and both realtime sockets compared against"admin"by hand while the REST
admin gate also acceptedschema-admin; they all read the same roles and
scopes now. -
A deleted account's tokens are refused at once on every door. The
revocation watermark lived on the user row, so deleting the row brought
back tokens the user had revoked. One check now reads existence, watermark
and live roles together, and a source guard fails if anything reads the
watermark alone. -
The email-verification pre-hijack is closed. Registering someone's
address with a password and getting them to click a genuine verification
link left the attacker's password working on the account they later
joined by OAuth. -
A second MFA enrolment no longer destroys the printed recovery codes.
-
Password sign-in no longer reveals by timing whether an address has an
account. -
A new password voids outstanding reset links, and verification links
expire after 24 hours. -
The CLI no longer writes a refused password straight into the
database: it falls back to the direct-database reset only on a network
error. -
rebase auth reset-passwordends the account's sessions when it resets
in the database directly. With the backend unreachable it wrote the new
hash and nothing else, so an operator recovering a phished account left the
attacker's refresh token minting access tokens. It now deletes the refresh
tokens and outstanding reset links and stampstokens_valid_afterin the
same transaction, as the API reset does.
Postgres
-
A
rebase db pushthat fails part-way no longer leaves the tables it just
created open to every caller. Row-level security was switched on by the
policies step, last, whilerebase_useralready held read and write on a
new table through the schema's default privileges; a push stopped by a
refusedsearchblock change, a vector or trigger error, or a policy that
would not compile left the table readable and writable by every
authenticated request — for good withREBASE_MIGRATE_ON_BOOT=none. Push now
turns RLS on for every collection and junction table straight after the
schema change, before any step that can fail, so a failed push leaves new
tables refusing requests until their policies arrive. Tables created at boot
and by the live schema editor have RLS on from the moment they are created. -
A collection table boot could not switch row-level security on for stays
closed. Boot revoked the role's access to such a table and reported it as
closed, then its last step granted every authenticated request read and
write on every table again, with no row filtering. The table now stays
closed, and boot refuses to start if it cannot keep it closed.