Upgrading 0.23 to 0.24
Ce contenu n’est pas encore disponible dans votre langue.
Upgrading 0.23 → 0.24
Section titled “Upgrading 0.23 → 0.24”These are the ### Breaking entries 0.24 carries for an app on 0.23.
Sections 41 to 46 are about the CLI: generated SQL leaves the repository, rebase dev regenerates the SDK types without being asked, and paths, failures and
agent setup are made consistent. Sections 47 to 50 change what the server
answers, and 51 to 55 are about access. Permissions are now
scopes, one vocabulary for sessions, API keys,
roles and MCP tokens; if every person who uses your admin surfaces holds the
admin role, most of them ask nothing of you. Sections 56 to 58 cover the remote
MCP endpoint, in-process writes and the UI kit, 59 to 64 the session and
account changes, 65 the backup routes, 66 the insights plugin,
and 67 images in markdown fields.
41. db push and db generate write their SQL to .rebase/sql/
Section titled “41. db push and db generate write their SQL to .rebase/sql/”rebase db push and rebase db generate render your collections to five SQL
files: schema.sql, policies.sql, search.sql, vector.sql and
triggers.sql. They were written into drizzle/, beside the migrations, and
committed. Both commands regenerate all five before reading any of them, so the
committed copy was never read, and it showed up in the diff of every collection
change. They are now written to .rebase/sql/, relative to the directory the
command runs in — backend/.rebase/sql/ in a scaffolded project. The directory
writes its own .gitignore, so it is never committed.
The first run after upgrading deletes the old copies in drizzle/ and names
each one. It deletes only files that open with the generator’s header, so a file
you wrote there stays, and so do the migrations in drizzle/migrations/.
What to change: commit the deletion the first run reports. If a script or a
CI step reads one of these files, point it at .rebase/sql/ and run
rebase db push or rebase db generate before it: a fresh checkout no longer
has the file.
grep -rnE "drizzle/(schema|policies|search|vector|triggers)\.sql" --exclude-dir=node_modules .42. rebase dev no longer takes --generate
Section titled “42. rebase dev no longer takes --generate”What the flag switched on is what rebase dev always does now. On start and on
every save under config/collections/, it regenerates the SDK types in
generated/sdk/, as it already did for the Drizzle schema. Before, the SDK was
regenerated only with the flag, so a fresh clone had no SDK types until someone
ran rebase generate-sdk. Passing --generate or -g stops with a message
telling you to drop it, and REBASE_AUTO_GENERATE and REBASE_GENERATE are no
longer read.
What to change: remove --generate and -g from every script that starts
rebase dev, and delete REBASE_AUTO_GENERATE and REBASE_GENERATE from your
.env files. rebase generate-sdk is unchanged: keep it in CI, in a frontend
repository that has no collections (--from link), and anywhere rebase dev is
not running.
grep -rnE "rebase dev.*(--generate|-g)|REBASE_(AUTO_)?GENERATE" --exclude-dir=node_modules .43. A relative --collections path is read from where you run the command
Section titled “43. A relative --collections path is read from where you run the command”schema generate, db push, db generate and doctor resolved a relative
--collections from backend/, while generate-sdk and db backup --out
resolved theirs from the directory you typed the command in. Every command now
uses the second rule, and so do schema --output and doctor --schema/--sdk.
A script written for the old rule, --collections ../config/collections run from
the project root, now names a directory that is not there. The CLI refuses it
before anything runs, because a schema generated from no collections is empty and
pushing it would drop every table, and the message says what to write instead.
What to change: drop the flag where it names config/collections, which is
the default, or write the path from where the command runs. generate-sdk now
takes --collections too; --collections-dir still works.
"db:push": "rebase db push --collections ../config/collections""db:push": "rebase db push"grep -rnE -- "(--collections|-c) \.\./" --exclude-dir=node_modules .44. rebase generate-sdk stops on a collection file that does not load
Section titled “44. rebase generate-sdk stops on a collection file that does not load”generate-sdk read the index.ts barrel in config/collections/ and skipped,
with a warning, a file that failed to load. It now loads every top-level
collection file in the directory, the same files the backend serves and the
admin panel lists, whether or not the barrel names them. A file that does not
load fails the command, as it already failed the boot. The barrel orders the
navigation and holds defaultSecurityRules; it is not a registry.
What to change: nothing, if your backend boots. If generate-sdk (or
rebase dev, which runs it) now stops, fix the file it names: it is one the
backend would refuse too. A collection left out of the barrel now appears in
the generated Database type, so a typed client gains its accessor.
45. Agents start the project’s own @rebasepro/mcp
Section titled “45. Agents start the project’s own @rebasepro/mcp”The .mcp.json that rebase init scaffolded ran npx -y @rebasepro/mcp: the
newest version on the registry, whatever version the project’s CLI was.
rebase init, and the new rebase skills install --mcp, now add
@rebasepro/mcp as a devDependency pinned with the CLI and start that copy:
pnpm exec rebase-mcp, or npx --no rebase-mcp in an npm project. A project
made before this gets the devDependency, with a message to install it, the next
time skills install --mcp runs.
What to change: add the devDependency, with rebase skills install --mcp or
pnpm add -D @rebasepro/mcp, and point the rebase server in .mcp.json (and
any other agent configuration you wrote by hand) at the local binary:
"command": "npx", "args": ["-y", "@rebasepro/mcp"]"command": "pnpm", "args": ["exec", "rebase-mcp"]Upgrade @rebasepro/mcp with the CLI from then on, since the two are pinned
together. See Connecting a client.
46. rebase api-keys create takes scopes
Section titled “46. rebase api-keys create takes scopes”--scopes names what the key may do, comma-separated or repeated:
--scopes data:read:posts,logs:read. --roles gives the RLS roles it runs as
beside service, and --expires-in <days> or --expires-at <date> says when
it stops. --full-access is every scope the service key holds, except
keys:read and keys:write, which no key may hold. rebase api-keys scopes
lists every scope the backend knows.
--permissions, --admin and --expires are removed. Each one stops the
command with a message naming its replacement, so a script carrying one fails
rather than minting a different key than it meant.
What to change: in every script that creates keys, replace --permissions
with --scopes, --admin with --roles admin plus the admin-plane scopes the
key needs, and --expires 90d with --expires-in 90.
| Before | After |
|---|---|
--permissions '[{"collection":"orders","operations":["read","write"]}]' |
--scopes data:read:orders,data:write:orders |
--full-access |
--full-access (now also the admin-plane scopes the service key holds) |
--admin --full-access |
--full-access --roles admin |
--expires 30d |
--expires-in 30 |
grep -rnE "api-keys create.*(--permissions|--admin|--expires )" --exclude-dir=node_modules .47. A live schema edit commits no SQL
Section titled “47. A live schema edit commits no SQL”A change applied from the admin panel’s
live schema editor commits the collection
and backend/src/schema.generated.ts, and nothing else. It also used to commit
the five SQL files, but it wrote them at the project root while the CLI writes
them beside the backend, so a project could hold two copies that disagreed.
SchemaCommitPaths and DEFAULT_COMMIT_PATHS now hold only schemaFile.
What to change: nothing, unless your code reads DEFAULT_COMMIT_PATHS or
builds a SchemaCommitPaths — then drop ddlFile, policiesFile, searchFile,
vectorFile and triggersFile, which TypeScript now reports. If a live edit
committed those files to a drizzle/ directory at your project root, nothing
reads them: delete them. A project that keeps versioned migrations still runs
rebase db generate after a live edit, and it writes the migration from the
committed collection.
48. An upsert into a row in the trash is refused with 409 ROW_IN_TRASH
Section titled “48. An upsert into a row in the trash is refused with 409 ROW_IN_TRASH”On a collection with soft delete, a deleted row
keeps its key. An upsert whose key belonged to such a row wrote its values into
the hidden row and answered 201 Created, so the row stayed deleted and the
caller saw a success. It is now refused with 409 ROW_IN_TRASH and nothing is
written, on every upsert door: ?on_conflict=, /bulk, /_batch, the SDK, the
socket, and rebase.data or context.data on the server.
What to change: nothing, unless something upserts into a soft-deleted
collection and expects to bring a row back. Restore the row first, with an
update setting its soft-delete field to null, then upsert. Or purge it with
?hard=true and upsert a new one.
await client.data.invoices.upsert({ id, ...values });await client.data.invoices.update(id, { deletedAt: null });await client.data.invoices.upsert({ id, ...values });49. An update that changes a row’s key is refused with 400 KEY_IMMUTABLE
Section titled “49. An update that changes a row’s key is refused with 400 KEY_IMMUTABLE”An update whose body named the row’s key with another value, or a beforeSave
that changed it, answered 500. In-process, through rebase.data, the row had
already moved to the new key when the write threw. Every door now refuses it
with 400 KEY_IMMUTABLE before any hook runs, naming the field, and nothing is
written. Sending the key the row already has is still accepted.
What to change: where code moved a row by updating its key, create the row
under the new key and delete the old one. A beforeSave that rewrote the key
on update has to stop. See Writing.
50. Strict-Transport-Security no longer sends includeSubDomains
Section titled “50. Strict-Transport-Security no longer sends includeSubDomains”The header told browsers to refuse plain HTTP on every subdomain of the server’s domain, including ones the server has nothing to do with, for as long as the header said. It now covers the server’s own host only.
What to change: nothing, unless your parent domain relies on this server’s header to force HTTPS on its subdomains. Then set the variable to keep the old header:
REBASE_HSTS_INCLUDE_SUBDOMAINS=trueSee Security headers.
51. Permissions are scopes
Section titled “51. Permissions are scopes”Every “may this caller do this?” now asks for a scope, named resource:action:
data:read, users:write, cron:write. A scope may name one target:
data:read:posts is data:read on posts alone. A person’s session, an API
key, a role and an MCP token all hold scopes, and they all use the same strings.
Every signed-in person holds the data plane: data:*, storage:* and
functions:invoke. Their rows are still decided by the collection’s
securityRules, so nothing changes there. The admin plane is users, schema,
database, backups, cron, logs and keys. The admin role holds all of it, and
any role you declare under auth.roles on the users collection holds what you
list. Every admin surface now asks for its own scope instead of “is admin”: the
user routes need users:read or users:write, cron needs cron:read or
cron:write, and so on. The endpoint index
names the scope on every route. A refusal is 403 SCOPE_MISSING, with the scope
in details.requiredScope.
What to change: nothing, if everyone who uses the admin surfaces holds the
admin role, because an admin holds every scope. If a narrower role should
reach one — a support team that manages accounts, a developer who reads logs —
declare it with the scopes it needs. See
Roles and scopes. A client
that branched on API_KEY_FORBIDDEN, or on FORBIDDEN from an admin route,
branches on SCOPE_MISSING.
grep -rnE "API_KEY_FORBIDDEN|Admin privileges required" --exclude-dir=node_modules .52. API keys hold scopes, not a permission list and an admin flag
Section titled “52. API keys hold scopes, not a permission list and an admin flag”A key now has scopes and, for a service key, roles: the RLS roles it runs as
beside service. permissions: [{ collection, operations }] and admin: true
are gone from CreateApiKeyRequest, UpdateApiKeyRequest and ApiKeyMasked,
and so are ApiKeyPermission, ApiKeyOperation and isOperationAllowed. A
request that sends permissions or admin instead of scopes is refused with
400 INVALID_INPUT.
Keys already stored are converted at boot to the scopes they held, and never
more: { collection: "posts", operations: ["read"] } becomes data:read:posts,
and admin: true becomes the admin role plus the users, schema, backups, cron
and logs scopes. The secret does not change. The full table is in
API keys.
One grant narrows. A function grant without write becomes nothing, because
calling a function is not a read. A key that called a GET function with
read alone is refused after the upgrade.
What to change:
- Give any key that calls a custom function
functions:invoke, orfunctions:invoke:<name>, withPUT /api/admin/api-keys/:idorclient.apiKeys.updateKey. - Code that creates keys sends
scopes, androles: ["admin"]where it sentadmin: true.
grep -rnE "ApiKeyPermission|ApiKeyOperation|isOperationAllowed|api-keys create" --exclude-dir=node_modules .53. schema-admin is no longer an administrator, and roles are declared
Section titled “53. schema-admin is no longer an administrator, and roles are declared”admin is the one built-in role. A role named schema-admin used to count as a
second admin; it now means nothing on its own. Roles are no longer stored
objects: createRole, updateRole, deleteRole, getRoleById,
getUserRoles, RoleRepository, RoleData and CreateRoleData are removed,
and so is BootstrappedAuth.roleService. getUserWithRoles returns role ids.
GET /api/admin/roles lists admin and the roles you declare, with their
scopes. The MongoDB driver no longer seeds a rebase_roles collection.
What to change: if any account holds schema-admin, declare the role with
the scopes it should have:
import { defineCollection } from "@rebasepro/cms-types";
export const usersCollection = defineCollection({ slug: "users", name: "Users", table: "users", auth: { enabled: true, roles: { "schema-admin": { name: "Schema admin", scopes: ["schema:read", "schema:write", "database:read", "database:write"] } } }, properties: { email: { name: "Email", type: "string" } }});Code that called the role functions reads the declared roles instead, with
client.admin.listRoles(). A defaultRole that holds any admin-plane scope now
fails the boot.
grep -rnE "schema-admin|roleService|RoleData|createRole|getUserRoles|collectionPermissions" --exclude-dir=node_modules .54. The hosted MCP server’s scopes are data:read, data:write and data:delete
Section titled “54. The hosted MCP server’s scopes are data:read, data:write and data:delete”The remote MCP endpoint used mcp:read and
mcp:write. It now uses the data scopes every other credential uses, and each
can be narrowed to a collection: data:read:posts. delete_document needs
data:delete, which data:write no longer covers.
What to change: nothing for clients already connected. A stored grant of
mcp:read is read as data:read, and mcp:write as data:write data:delete,
so they keep their reach, and a client that still asks for the old names gets
the same. Code that compares a granted scope string with mcp:read or
mcp:write compares it with the new names.
55. RealtimeSocketLimits is now RealtimeSocketOptions
Section titled “55. RealtimeSocketLimits is now RealtimeSocketOptions”It carries the API-key resolver beside the limits, as the sixth argument of
initializeWebsockets. That is what lets an API key authenticate the realtime
socket.
What to change: nothing, unless you call initializeWebsockets from your own
bootstrapper. Then rename the type.
grep -rn "RealtimeSocketLimits" --exclude-dir=node_modules .56. The remote MCP endpoint takes the SDK’s arguments
Section titled “56. The remote MCP endpoint takes the SDK’s arguments”The tools on /mcp used their own argument names. They now take what the SDK
takes: where (was filter), orderBy as ["field", "desc"] or
"field:desc" (was orderBy plus order), and data for a write (was
values). query_collection answers REST’s shape, { data, meta }, instead of
{ rows, count, truncated }. An argument a tool does not declare is refused
instead of ignored, and a limit above 1000 is refused, as on REST, instead of
being clamped to 200.
What to change: nothing for an assistant, which reads the tool schemas each
time it connects. Code or saved prompts that call the tools with fixed
arguments use the new names and read data and meta.total from a query:
{ "collection": "orders", "filter": { "status": ["==", "paid"] }, "orderBy": "total", "order": "desc" }{ "collection": "orders", "where": { "status": ["==", "paid"] }, "orderBy": ["total", "desc"] }See The remote endpoint.
57. In-process writes refuse ifMatch and idempotencyKey
Section titled “57. In-process writes refuse ifMatch and idempotencyKey”A write through context.data in a callback, or rebase.data in a function,
cron job or seed, never leaves the process: there is no If-Match to check and
no idempotency store. Both options were silently ignored, so a conditional write
there was unconditional. They now throw UNSUPPORTED_OPTION before anything is
written.
What to change: drop the option from in-process writes. Where the condition matters, check it in the code that writes, or make the write through the HTTP client, which honours both.
await context.data.orders.update(id, { status: "paid" }, { ifMatch: etag });await context.data.orders.update(id, { status: "paid" });grep -rnE "(context|rebase)\.data\..*(ifMatch|idempotencyKey)" --exclude-dir=node_modules .58. Button and IconButton are typed against the element they render
Section titled “58. Button and IconButton are typed against the element they render”Both accepted any prop, which landed on the DOM and did nothing. They are now
typed against the element they render, a button or the component you pass,
so a prop they do not have is a compile error. That includes IconButton’s
color, the obsolete HTML attribute, which never coloured anything, and
endIcon or loading on Button, which never rendered.
What to change: drop each prop TypeScript reports. What renders does not change; an icon that was meant to show goes in as a child.
<IconButton color="primary" onClick={close}><CloseIcon /></IconButton><IconButton onClick={close}><CloseIcon /></IconButton>59. An open realtime socket is checked again, and closed when its identity ends
Section titled “59. An open realtime socket is checked again, and closed when its identity ends”A socket checked its token once, when it authenticated. A token that expired, a
session that was revoked or a role that was taken away changed nothing until the
socket reconnected. Now, before each frame, at least every 30 seconds for a
socket that only listens, and at the instant its token expires, the server asks
whether the account still exists, is still signed in and still holds its roles.
An ended identity gets an AUTH_ERROR frame (SESSION_ENDED or
TOKEN_EXPIRED) and close code 4001. A changed role re-scopes the socket’s
subscriptions from the next frame.
What to change: nothing with the SDK, which re-authenticates on every token
refresh and reconnects after a 4001. A client that speaks the
socket protocol directly must send
a fresh token in AUTHENTICATE before the old one expires, and treat close
code 4001 as a reason to reconnect with a new session.
60. An email-verification link proves the address, and nothing else
Section titled “60. An email-verification link proves the address, and nothing else”Following a verification link marked the address verified and kept everything else on the account. Someone could register a stranger’s address with a password of their own, and when the owner clicked the link, that password became a way into a verified account. 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.
client.auth.verifyEmail(token, options?) now POSTs, sends the session when
there is one, and may throw PROOF_REQUIRED (409): the account holds a
password or an identity the request does not prove, and the token is not spent.
What to change: in a custom verification page, catch PROOF_REQUIRED and
ask for the password, or let the person verify without it. A page that calls
GET /api/auth/verify-email?token= without the account’s session now removes
the password. The CMS’s own page already asks.
await client.auth.verifyEmail(token);try { await client.auth.verifyEmail(token);} catch (error) { if (error instanceof RebaseApiError && error.code === "PROOF_REQUIRED") { await client.auth.verifyEmail(token, { password: await askForPassword() }); } else throw error;}See Email verification.
61. client.auth.signUp resolves confirmationRequired, and user may be null
Section titled “61. client.auth.signUp resolves confirmationRequired, and user may be null”With requireEmailVerification
on, registering signs nobody in until the address is confirmed. signUp now
resolves { confirmationRequired, user, accessToken, refreshToken }: with
confirmationRequired: true, the other three are null. With it off, the
default, confirmationRequired is false and the rest is as before.
What to change: TypeScript now reports code that reads user without
checking. Narrow on confirmationRequired:
const result = await client.auth.signUp(email, password);showWelcome(result.user.uid);if (result.confirmationRequired) showCheckYourInbox();else showWelcome(result.user.uid);62. mfa.enroll().recoveryCodes is null when adding a second factor
Section titled “62. mfa.enroll().recoveryCodes is null when adding a second factor”Every enrolment made a fresh set of recovery codes and replaced the old one, so
adding a second authenticator, or starting an enrolment and abandoning it,
silently invalidated the printout. The codes are now made once, with the first
factor. Enrolling another returns recoveryCodes: null and keeps the codes the
account has; mfa.regenerateRecoveryCodes() replaces them when asked.
What to change: show the codes only when they are there, and offer regeneration where you offered re-enrolment. See Enrolling a factor.
const { totp, recoveryCodes } = await client.auth.mfa.enroll({ friendlyName: "Phone" });showRecoveryCodes(recoveryCodes);if (recoveryCodes) showRecoveryCodes(recoveryCodes);63. Turning a guest into an account needs registration open
Section titled “63. Turning a guest into an account needs registration open”POST /api/auth/anonymous/link, and linking an OAuth provider to a guest
session, turn a guest into an account. Both now answer to allowRegistration
like any other sign-up, and refuse with 403 REGISTRATION_DISABLED when it is
off.
What to change: nothing, if allowRegistration is on. If your app keeps
registration closed and upgrades guests, it has to open it, in the users
collection’s auth block. See
Anonymous sessions.
64. A deleted account’s token answers 401 SESSION_REVOKED
Section titled “64. A deleted account’s token answers 401 SESSION_REVOKED”An access token whose account had been deleted answered 404 from /me. Every
door now answers 401 SESSION_REVOKED, as for a session that was signed out.
What to change: nothing with the SDK, which signs out on it. A client that
treated a 404 from /me as “account gone” checks for SESSION_REVOKED
instead. See Errors.
65. createBackupRoutes takes storageFor, not storage
Section titled “65. createBackupRoutes takes storageFor, not storage”The backup routes listed and downloaded an s3:// or gs:// backup through
the app’s own file-storage controller, which addresses another bucket, a local
directory, or nothing. They now build a controller for the backup
destination’s bucket from BACKUP_DESTINATION and the S3_* variables, the
way the backup cron does. The storage option of createBackupRoutes is gone;
storageFor(destination) replaces it. listBackupObjects, readBackupBytes
and openBackupStream throw for an object destination they have no controller
for, where they used to return nothing.
What to change: nothing, unless you mount createBackupRoutes yourself.
If you do, drop storage, or pass storageFor when the destination needs a
controller other than the one the environment describes. See
Backups.
66. The insights plugin reads named sources
Section titled “66. The insights plugin reads named sources”An insight carried its own data() callback and a scorecard block, so the
same figure on the home page and on a collection’s card was fetched twice, and
the change beside it was a delta the callback had to compute. Now the config
declares sources — each returns one record of figures for the period it is
handed — and an insight names the source, the value field, and the field
holding the previous figure. The plugin computes the change itself.
What to change: in your useInsightsPlugin config, move each data()
callback into sources under a name, have it return one record instead of
{ rows }, and replace scorecard with source, value and comparison.
ScorecardFormat is InsightFormat, without showSign. Drop description
and dateRange: the row above the figures names the period. Wrap the config in
useMemo, because a new sources object starts a new cache.
const insightsPlugin = useInsightsPlugin(useMemo(() => ({ period: { days: 30 }, sources: { orders: ({ period }) => fetchOrderStats(period.previousFrom, period.from, period.to) }, insights: { home: [{ id: "revenue", title: "Revenue", source: "orders", value: { field: "revenue", format: { style: "currency", currency: "USD" } }, comparison: { previous: "previousRevenue", intent: "increase_is_good" } }] }}), []));The source returns both windows’ figures, here { revenue, previousRevenue }.
A count that moves by a few reads better as a difference than a percentage:
set show: "absolute" on its comparison.
67. A markdown field stores an uploaded image as a storage reference
Section titled “67. A markdown field stores an uploaded image as a storage reference”The markdown editor wrote a private file’s signed URL into the text. Its download
token expires, five minutes after the upload by default, so the image broke in
the admin panel and on every site that published the text. An image uploaded in
the editor is now stored as rebase-storage:<key> (with ?storageId= for a
named source), and the panel exchanges each reference for a fresh URL when it
renders.
What to change: nothing in the panel. A site or app that renders a markdown
field itself resolves the references before it renders, or the images point at
rebase-storage: URLs a browser cannot open:
import { resolveStorageReferences } from "@rebasepro/client";
const body = await resolveStorageReferences(String(post.body ?? ""), client);Text saved before keeps the URLs it was saved with. See Files inside text.