Skip to content

v0.24.0

Choose a tag to compare

@github-actions github-actions released this 08 Oct 14:04
· 1106 commits to main since this release

Breaking

CLI

  • rebase db push and rebase db generate write their SQL to .rebase/sql/
    instead of drizzle/.
    This covers schema.sql, policies.sql,
    search.sql, vector.sql and triggers.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 reads drizzle/policies.sql, point it at
    .rebase/sql/policies.sql and run rebase db push or rebase db generate
    first.

  • rebase dev --generate is removed, because what it did is now the
    default.
    rebase dev regenerates the SDK types in generated/sdk/ on
    start and on every save under config/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 ran rebase generate-sdk.
    Passing --generate or -g now stops with a message telling you to drop
    it, and REBASE_AUTO_GENERATE and REBASE_GENERATE are no longer read.

  • A relative --collections path is resolved from the directory you run the
    command in, on every command that takes one.
    This is the rule
    generate-sdk and db backup --out already followed; schema generate,
    db push/generate and doctor previously resolved it from backend/
    instead. The same now applies to schema --output and
    doctor --schema/--sdk. A script written as
    --collections ../config/collections and run from the project root must
    drop the flag (config/collections is the default) or write
    config/collections; the CLI refuses the old spelling and says exactly
    this. New scaffolds' scripts carry no flag. generate-sdk now also accepts
    --collections (--collections-dir remains an alias).

  • rebase generate-sdk fails on a collection file that does not load,
    instead of skipping it with a warning.
    It now loads every top-level file
    in config/collections/ with the same predicate the runtime boot uses, not
    the index.ts barrel, 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 (plus defaultSecurityRules), not a registry.

  • rebase init and rebase skills install --mcp configure 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-mcp on npm), instead of
    npx -y @rebasepro/mcp from the registry.
    A project already past init
    gets the devDependency added, with a message to install it, the next time
    configureAgents or skills install --mcp runs.

  • rebase api-keys create takes scopes. --scopes data:read:posts,logs:read
    names what the key may do, --roles the 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 except keys:*. --permissions,
    --admin and --expires are removed and refused by name with their
    replacement. rebase api-keys scopes lists every scope this backend knows.

Server & REST

  • createBackupRoutes takes storageFor(destination), replacing its storage option. listBackupObjects, readBackupBytes and openBackupStream throw for an object destination they have no controller for, instead of returning nothing. BackupScheduleStatus.lastRun excludes manual runs, and adds lastManualRun, disabledInCode, refused and historyError. BackupListing adds localDiskOfThisProcess.

  • 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. SchemaCommitPaths and
    DEFAULT_COMMIT_PATHS now hold only schemaFile.

  • An upsert whose key belongs to a soft-deleted row is refused with
    409 ROW_IN_TRASH on every upsert door
    (?on_conflict=, /bulk,
    /_batch, the SDK, the socket, and rebase.data/context.data). It used
    to write the new values into the hidden row and answer 201 Created.

  • An update that changes a row's key is refused with 400 KEY_IMMUTABLE,
    naming the field, on every door including in-process rebase.data. It used
    to answer 500, and in-process the row had already moved under its new key
    when the write threw.

  • Strict-Transport-Security no longer sends includeSubDomains by
    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=true to keep the old header. See the
    entry under Security.

  • Permissions are scopes: one resource:action vocabulary 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:posts is data:read on posts alone.
    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 the admin role, and by any role
    the app declares under auth.roles on 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, and schema:write to 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 answers 403 SCOPE_MISSING with details.requiredScope.

  • API keys hold scopes, not a permission list and an admin flag.
    permissions: [{ collection, operations }] and admin: true are gone from
    CreateApiKeyRequest, UpdateApiKeyRequest and ApiKeyMasked, along with
    isOperationAllowed, ApiKeyPermission and ApiKeyOperation. A service key
    has scopes and roles — the RLS roles it runs as beside service, where
    ["admin"] reads every row. Keys already stored are converted on boot to
    the scopes they held and never more: { collection: "posts", operations: ["read"] } becomes data:read:posts, "*" becomes the data plane,
    admin: true becomes the admin role and the users, schema, backups, cron
    and logs scopes. A function grant without write becomes nothing, since
    calling a function is not a read. "storage" and "functions" are no
    longer names in the collection field, so a collection called storage no
    longer shares a grant with the files.

  • schema-admin is no longer an administrator, and roles are no longer
    stored objects.
    admin is the one built-in role. A project that used
    schema-admin declares it with the scopes it should hold, under
    auth.roles. createRole, updateRole, deleteRole, getRoleById,
    getUserRoles, RoleRepository, RoleData and CreateRoleData are
    removed: the Postgres driver's versions saved nothing, and nothing read the
    isAdmin, defaultPermissions and collectionPermissions they carried.
    getUserWithRoles returns role ids. GET /api/admin/roles lists admin and
    the declared roles with their scopes, and BootstrappedAuth.roleService is
    gone. The MongoDB driver no longer seeds a rebase_roles collection.

  • The hosted MCP server's scopes are data:read, data:write and
    data:delete.
    Each can be narrowed to collections (data:read:posts),
    and the tools reach only the collections granted. Grants made with
    mcp:read and mcp:write keep their reach: they are read as data:read
    and as data:write data:delete.

  • RealtimeSocketLimits is now RealtimeSocketOptions. It carries the API-key
    resolver beside the limits, as the sixth argument of initializeWebsockets.

MCP

  • The remote MCP endpoint (/mcp) takes the SDK's own arguments: where
    (was filter), orderBy as ["field","desc"] or "field:desc" (was
    orderBy + order), and data (was values). query_collection answers
    REST's shape, { data, meta } (was { rows, count, truncated }). An
    argument a tool does not declare is now refused instead of ignored, and a
    limit above 1000 is refused, as on REST, instead of silently clamped to

Client SDK

  • A write made through context.data or rebase.data (in a function, hook
    or seed) now throws UNSUPPORTED_OPTION if it is given ifMatch or
    idempotencyKey,
    instead of silently ignoring them. Neither option means
    anything for a write that never leaves the process.

UI kit & plugins

  • Button and IconButton from @rebasepro/ui are typed against the
    element they actually render.
    A prop the component does not use,
    including IconButton's old color attribute (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 own data() callback and a
    scorecard mapping, 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.
    InsightsPluginConfig now takes sources, functions that each return one
    record of figures and are called with the period (from, to,
    previousFrom), and an optional period: { days } (30 by default). An
    InsightDefinition names its source, its value: { field, format } and
    an optional comparison: { 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 whose period.days is not a whole number of at
    least 1, throws when the plugin is created. Gone: ScorecardConfig,
    ScorecardFormat (now InsightFormat, without showSign),
    InsightDataResult, InsightsCache, useInsightsData,
    InsightWidgetSkeleton, and the description and dateRange fields — the
    row above the figures names the period itself. New: InsightsEngine,
    resolvePeriod, useInsightSource and InsightsRow.

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_ENDED or TOKEN_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 in AUTHENTICATE before 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.signUp resolves { confirmationRequired, user | null, … },
    because with requireEmailVerification on there is no session until the
    address is confirmed.

  • mfa.enroll().recoveryCodes is null when adding a second factor. The
    codes are made once, with the first factor; mfa.regenerateRecoveryCodes()
    replaces them on request.

  • /auth/anonymous/link and 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_REVOKED on every door
    (/me used 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/client signs 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 init sets 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.json or .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 --yes nothing is set up unless you
    pass --agent (-a), e.g. rebase init my-app --yes --agent claude,cursor.
    Before, rebase init only printed rebase skills install.

  • A static app can have a hostname of its own. Its path in rebase.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. /api and the other backend
    paths answer on every hostname before any app. The app is built for the path
    part of the URL, and cms stays a path, on the app's hostname. On Rebase
    Cloud, rebase cloud deploy registers the hostname on the project and
    verifies it if its DNS records are already published. If they are not, the
    deploy prints the records and rebase 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 --mcp registers the Rebase MCP server in each
    agent's project config, the way rebase init --agent does.
    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,logs mints a key that acts as you, narrowed to one project and
    those actions; rebase cloud tokens lists them and rebase cloud tokens revoke ends one. Set it as REBASE_TOKEN and every rebase cloud command
    uses it instead of a login session, with no session file read or written.
    The capabilities are deploy, logs, env, database and backups; none
    of them can restore over a live database, which stays with a signed-in owner
    or admin.

  • rebase cloud cron list and rebase cloud cron logs <job> [--limit N]
    read a deployed app's cron jobs and their recorded runs with your rebase cloud login.
    The run history is in the app's own rebase.cron_logs,
    behind the app's admin gate, which accepts an admin of the app, an rk_
    key or the service key, and a project owner signed in to Rebase Cloud
    often holds none of them. rebase cloud debug logs covers 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 (--host reads
    another address). --json returns the jobs or the runs as the app reports
    them. It needs a control plane that serves runtime-token and 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. A rebase 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 declared collapsed: false is 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 as isEnabled when 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.filledByServer on 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.slug uses 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 }) and rebaseEdits are exported
    from @rebasepro/forms,
    for a form that needs to know when someone else
    changed a field it also touched.

  • CollectionWindow and isPropertyFilterable are exported from
    @rebasepro/app.
    Empty-state overrides now also receive isFiltered.

  • 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 as admin is
    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
    calls DELETE /admin/users/:uid/mfa, and is offered only to
    whoever holds users: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) and searchString
    on query_collection and count_documents.

  • Remote MCP's list_collections returns each collection's row and
    create JSON Schemas
    (required fields, enums, foreign keys) and its
    softDeleteField.

Studio

  • @rebasepro/types exports diffCollections, applyCollectionPatch,
    nestCollectionPatchPaths, collectionPatchProblems, isCollectionPatch,
    runtimeOnlyKeyIn and RUNTIME_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 as EXECUTE_SQL with
    mode: "script", and as RebaseWebSocketClient.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 / storageDownloadTokenTtl sets the
    lifetime of private download URLs (default 300 seconds, at most a week).

  • resolveStorageReferences, storageReference and parseStorageReference
    are exported from @rebasepro/client and @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 as x-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.state and client.ws.onStateChange()
    (RealtimeConnectionState).
    After an outage of about 15 seconds,
    CONNECTION_LOST is reported once to every live subscription's and joined
    channel's onError. The subscription is kept, and its next onUpdate is
    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/ui exports isKeyHandled(event), for a component that
    needs to know whether a keypress was already claimed by something above
    it. Dialog gains dismissOnBackdrop, initialFocus and
    onCloseAutoFocus; Sheet gains initialFocus, onOpenAutoFocus and
    onCloseAutoFocus; BooleanSwitch accepts aria-label, aria-labelledby
    and id.

  • Select and MultiSelect accept triggerContent, 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. RelationSelector and UserSelector already took it.

Server & REST

  • REBASE_HSTS_INCLUDE_SUBDOMAINS opts back into the old
    includeSubDomains behaviour. See the entry under Breaking.

  • New docs: "Running more than one instance" and "Backups and restore".

  • Personal API keys. With personalKeys: true in the users collection's
    auth block, 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.scopes on the users
    collection names app scopes such as project:deploy, with a label and an
    optional target, and auth.roles gives 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 declared admin. A function checks an app scope with requireScope,
    hasScope or getScopes from @rebasepro/server/functions; a person always
    holds app scopes, so they narrow keys. GET /api/auth/scopes lists 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. verifyCredential gives 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 isId and they form
    one key: rebase db push, the schema check at boot and the generated
    Drizzle file all write one PRIMARY KEY (a, b), and reads, writes, nested
    paths and via relations address a row by every key column
    (company_translation/1:::en_US). 0.23 refused a second isId when 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 a via relation. rebase schema introspect
    now generates a table keyed on several columns, with isId on each key
    column. It still skips the table when a key column cannot carry isId or
    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/server exports configureAccess, and
    accessModelFromCollections to build the model from the users collection,
    beside configureJwt, so a server or test built without
    initializeRebaseBackend answers for a person the way a booted backend
    does.

CI & tooling

  • pnpm check:tsconfig-entries fails a tsconfig include/paths/
    extends entry that resolves to nothing. pnpm typecheck reads
    tests/e2e again.

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 answers x-rebase-impersonate
    with 501 IMPERSONATION_UNAVAILABLE. @rebasepro/types exports the
    header name as IMPERSONATE_HEADER.

  • sid and exp on access tokens, and isCurrentSession in 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_CONFIRMED until the address is verified; /register then
    no longer says whether an address has an account. Registration now mails
    the verification link either way; POST /auth/verify-email is the new
    door the SDK uses.

  • auth.refreshTokenReuse ("reject", the default and today's
    behaviour, or "revoke-session") and auth.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 with ACCOUNT_DISABLED; stored in
    the new users.disabled_at column, which db push plans.

  • 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 an aal2 session. 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/me and User carry pendingEmail.
    New beforeEmailChange hook and email.templates.emailChange /
    emailChangeNotice. Postgres gains users.pending_email,
    email_change_token and email_change_sent_at, added at boot and planned
    by db 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 an rpt_
    bearer token signed by the hosting platform: ES256, iss: "rebase-cloud",
    aud equal to the audience, a sub, and at most ten minutes between
    iat and exp. The caller acts as platform:<sub> with no roles, and
    holds only the scopes in the token's scope claim that the server allows
    a platform to hold. Today that is cron:read alone, whatever the token
    asks for, so it reads /api/admin/cron and its logs and gets 403 from
    every other admin route. Each accepted request logs Platform token accepted with the sub and jti. 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
    every rpt_ token with 401 PLATFORM_TOKENS_OFF.

Client SDK

  • impersonate on createRebaseClient runs every request as another
    user, named by uid. It is sent as x-rebase-impersonate on each HTTP
    request and as impersonate on the realtime socket's AUTHENTICATE. 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
    with 400 IMPERSONATION_UNSUPPORTED rather 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_SUCCESS carries effectiveRole when a statement asked for a role, and SqlScriptResult has effectiveRole. BranchInfo has a required database field.

  • compileRulePolicies(collection, rule, options) in @rebasepro/common is 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_row is renamed to studio_api_keys_admin_bypasses_rls. An app that overrides it must rename its override. The keys studio_policy_help_role_authenticated and studio_policy_help_role_anon are removed, with the policy help that named roles no request runs as.

  • POST /api/admin/schema/plan|apply and
    /api/admin/schema-editor/collection/save accept a patch
    (operations
    on key paths) for an existing collection, instead of only a whole
    collection object; /apply accepts sourceOnly: true and remove: true.
    A whole collection posted 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
    with ALTER COLUMN … SET/DROP DEFAULT
    instead of being reported as
    requiring a migration.

  • validation.max on a text column is no longer reported as a column
    change
    in the live schema editor's plan.

  • useLiveSchemaEditing's writeSourceOnly option is removed.
    "Edit source only" is now a committed /apply call with sourceOnly: 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 introspect writes more explicit property keys than
    before:
    columnType, precision/scale, defaultValue, required,
    the key's real strategy, onDelete and the search block. A uuid key with
    no database default is now written as isId: "manual" (it wrote "uuid",
    which made db push add a default that was never there); a serial key is
    written as isId: "manual" with columnType: "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 next rebase db push, then none.

  • rebase db push treats 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 a CronJobListing, which carries the jobs the server refused (rejected) and the files it could not load (skipped, note) beside jobs.

  • Generated database.types.ts entries carry a Slug field (regenerate
    with rebase generate-sdk; older generated files still work).

  • onSyncError reports a write discarded because an earlier write to the
    same row was refused, with code DEPENDENCY_REJECTED
    and the earlier
    refusal as cause. Before, the discarded write carried the refused
    write's own error.

Storage & email

  • An upload into a property's storagePath that breaks the property's
    maxSize/acceptedFiles is 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 an http://
    address the browser blocks.

  • getSignedUrl/deleteObject given an s3://bucket/key or
    gs://bucket/key address the object by key,
    so a file stored with
    includeBucketUrl on S3 or GCS can be previewed and deleted.

CI & tooling

  • ./tooling/scripts/verify-quality.sh runs every end-to-end suite CI
    runs,
    through the new pnpm ci:e2e (lanes vitest, cli, selfhost,
    admin). No suite stops the ones after it, and nothing is retried. Needs
    Docker.

  • pnpm check:api-surface passes an additive export change and banks it
    into contracts/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_Angeles in CI and in
    verify-quality.sh.
    A test that needs a specific zone pins it with
    tooling/scripts/jest/zone-environment.cjs.

Auth

  • change-password answers 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.

  • onAuthenticated also fires for OAuth, refresh, password reset and MFA;
    beforeLogin("oauth") and beforeUserCreate run on OAuth sign-in and
    sign-up; afterLogout fires for SDK and CMS sign-outs.

  • Sessions record the client address the rate limiter resolves
    (TRUSTED_PROXY_HOPS) instead of a raw X-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 via relation's tab. Opening a row through a
    joinPath relation (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 a table.column spelling, and
    its columns only by Drizzle property key: a step written as JoinStep
    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-one via writes had the same table
    lookup and refused every write; they resolve by position too.

  • The OpenAPI spec says a delete on a softDelete collection moves the row to the trash. It documents ?hard=true on DELETE and bulk delete, and ?deleted=include|only on list, single read, count and aggregate. A column named deleted is 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_STRING names. 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 generate writes them
    next to the backend, in backend/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 /admin and another at /admin/beta, a link such as
    /admin/beta/settings answered 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 404 through
    the socket, MCP and rebase.data,
    as it already was over REST. An
    update of a missing row no longer runs beforeSave before the 404.

  • 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's find(),
    as listen() already did. Naming the
    relation in fields is no longer a 400.

  • A REST list answer always carries meta.offset. It is 0 when the
    request named none; before, meta.offset + meta.limit was NaN on 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 with 400 ORDER_BY_FIELD_NOT_SORTABLE,
    and no meta.nextCursor was ever issued for one.

  • storage.putObject({ file, public: true }) with no key stores 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=true for 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/server ships
    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 .dump object in the bucket is refused.

  • An unknown GET under /api answers the JSON NOT_FOUND envelope
    when a static app is served at /, instead of a plain-text 404.

  • CORS_ORIGINS / FRONTEND_URL match the browser's Origin with 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_EXHAUSTED with
    Retry-After
    (and a connect timeout answers 503 DB_CONNECT_TIMEOUT)
    instead of 500 INTERNAL_ERROR; /health reports 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 PORT and 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, in rebase.cron_logs and the
    in-memory history,
    which only the admin-gated /api/admin/cron/:id/logs reads. The process
    log got one line per run, ✅ [cron] "<id>" completed in Nms, so nothing
    a job said about itself showed up in rebase cloud debug logs. A managed
    app's AI step that fell back with model unavailable (quota_exhausted)
    left no trace there, and the app wrapped log in 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 at info with a cron field holding the job
    ID. That copy is redacted like any other log line: the values of keys such
    as password or apiKey, 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.ts now run. The
    worker used to start with 0 tasks and every enqueue/publish threw "no
    queue runtime is installed": the server's inlined copy of
    @rebasepro/types and the project's own copy each kept their own
    registry. A third-party driver's registerDataSourceCapabilities had the
    same split, and is fixed too.

  • afterSave, beforeDelete, afterDelete and history now receive the
    row as stored, not afterRead'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_user USAGE on the rebase schema, and on collection schemas, as
    soon as provisioning creates them.
    Writes to history: true collections,
    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.data delete 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.data write re-runs itself without end is stopped
    at depth 16 with 500 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 timeoutSeconds of 0 or NaN is 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 KEY with 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 with Missing required field: "id". The
    implicit key now defaults to gen_random_uuid()::text; a table created
    before this gets the default at its next boot, and a key you send is
    still used.

  • A hasOne relation 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 generated schema.generated.ts booted,
    but reading the relation with include failed with "There are multiple
    relations between…" once the child had two links back to the parent, and
    for every hasOne to its own collection. Both now name the join columns,
    and the related row is typed as possibly null.

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. So rebase cloud deployments --limit 5
    was refused as an unknown command 5, rebase cloud debug --host staging.example.com as staging.example.com, and rebase cloud projects --name shop create as shop. The same happened at the top level:
    rebase db --collections ./config/collections push, rebase api-keys --name ci create and rebase skills --agent claude install were each refused as
    an unknown subcommand. All of these lines now run the action they name.
    --help on such a rebase cloud line 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 requests and rebase cloud debug boot say 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 its truncated flag covers only its byte
    budgets. So --since 30d --tail 200000 returned 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/lastTimestamp under
    --json). A pod that returned the full line limit and whose earliest
    line is later than the start of the window is marked truncated, with
    a truncatedReason saying how much of the window was not read. A pod
    that started recently returns fewer lines than the limit and is not
    marked. A --tail above 2000 is lowered to 2000 before the request, and
    the output says so (tail.note). A --tail that is not a positive
    whole number is refused.

  • rebase cloud debug boot reads 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 --help lists --since, --tail and --previous
    under the actions that take them, with each action's own default.
    The
    help said "Default: 500" for every action, but requests reads 1000
    lines, and it offered --previous to requests, which rejects it.

  • rebase db push stops before a column type change that can lose
    values
    — numeric(5,2) to numeric(4,1), bigint to integer,
    jsonb to text — 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 push no longer fails forever after you change the type of a
    column a search block 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-run on a synced database prints "No changes".

  • rebase schema introspect followed by rebase db push no longer plans
    type changes, dropped defaults, dropped NOT NULLs, a phantom id key 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 doctor checks 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 from rebase dev and rebase 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 or git init-ed directory.
    A directory may already hold .git, other repository metadata, OS litter
    files or a LICENSE; any other entry is refused by name, with the ways
    forward. --git reuses an existing repository instead of re-initialising
    it.

  • Saving a collection during rebase dev restarts the backend once,
    after the schema and SDK are regenerated. It used to restart twice, and
    the first boot told you to run rebase schema generate.

  • rebase start warns at the top when it is not running as a production
    server
    (NODE_ENV from .env or unset), and says what that means: the
    first account to register becomes the admin.

  • rebase build builds each static app once. It used to run the
    frontend's vite build && tsc twice: once to fold it into the backend
    bundle, once for its own bundle.

  • rebase build --help lists --output, --no-static and
    --skip-static-build.
    rebase db url --help lists --database-url and
    --docker. rebase normalize-imports --help prints its own help instead
    of the global one.

  • rebase apps config <app> prints an empty VITE_API_URL for an app the
    backend bundle serves on its own origin.
    It no longer prints the address
    of a rebase dev that has stopped.

  • Printed next steps now work for the reader they target:
    rebase init --yes ends with an --agent command; rebase start
    explains how to reach an app that answers only on its own hostname; a
    failed rebase upgrade install names the command that puts the pins back.

  • The docs, the scaffold and rebase-server --help no longer say boot
    leaves junction-table RLS to db push.
    Boot applies it.

  • rebase skills install --agent codex writes 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 .gitignore keeps .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 upgrade leaves files git ignores alone. It read every
    package.json and pnpm-workspace.yaml under the project, so a gitignored
    copy — a stale build context under scratch/, a vendored package — was
    listed by --dry-run and rewritten with the rest. In a git repository it now
    reads only what git ls-files lists: tracked files, and untracked ones no
    .gitignore excludes. 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 offered migrate and off, and either one stopped the
    backend from starting. It now lists ensure and none, says any other
    value refuses to boot, and points migration files at rebase db migrate.

  • The published rebase-collections skill no longer offers a cuid id. The
    0.23.0 skill listed isId: "cuid", which config load refuses, so an agent
    following it wrote a collection whose first boot failed. The skill now
    offers manual, uuid and a SQL strategy, and says cuid is refused.

Admin (CMS & app)

  • useBackendStorageSource addresses 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 the schema:read scope, every load
    called GET /api/admin/schema/status and GET /api/schema-editor/status,
    and both answered 403. On a deployment without storage, every load also
    called GET /api/storage/sources, which answered 501 and was logged at
    ERROR. These failures filled rebase cloud debug errors and hid the real
    ones. The panel now asks for the schema status only when
    GET /auth/scopes says the user holds schema:read. It asks for the
    storage sources only when GET /auth/config does not report
    storage: false. A custom AuthController takes part by reporting
    heldScopes and storageEnabled. 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: false now 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 a CONNECTION_LOST into 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 fixedFilter work. 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 collections or views builder 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 sort offers "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-one via target, to the copy.
    The copy keeps its
    own belongsTo key and its manyToMany links.

  • Property conditions (required, hidden, disabled, readOnly,
    min, max, defaultValue, acceptedFiles, maxFileSize) are applied
    by the record form,
    and a dynamicProps validation 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/joints opened, 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, and users/abc123 is no longer
    read as the collection user.

  • A Firestore collection that declares its own path is read, written and
    referenced at that path.
    A collection registered as fs_diagnosis with
    path: "diagnosis" (beside a Postgres collection called diagnosis), or as
    medico_joints for documents at medico/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 to diagnosis/abc opened 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, and N/A or 1,234 in 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 Name and first_name became one property, and so did
    Price ($) and Price (€); a header row in Japanese, Cyrillic or Greek
    collapsed to a single property with an empty key. A header that slugs to
    nothing is now column_<n>, and a taken key gets _2, _3 and 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 dev running 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 authenticated and anon roles, which Rebase never creates.

  • The schema visualizer draws the table db push creates (blog-posts becomes blog_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/logs answers 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:// and gs:// 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: false reads 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 uses impersonate: 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
    sent x-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 carries impersonatedBy. An API key, the service key or a
    non-administrator gets 403 IMPERSONATION_FORBIDDEN. A uid with no active
    user gets 404 IMPERSONATION_TARGET_NOT_FOUND. Every other route refuses
    the header with 400 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 names git commit with 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 computed value), 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
    hasMany over 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.ts too, 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 generated UPDATE are 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. Splitting BEGIN, an UPDATE and ROLLBACK across three
    separate runs used to commit the UPDATE and 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
    COMMIT inside 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 are studio_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 with CONNECTION_LOST instead 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_TIMEOUT after 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 with INTERNAL_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 /health report it,
    and /health turns degraded after 60 seconds down.

  • Retained channels on the postgres bus 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 as cross-instance while 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, getObject and deleteObject used 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 under default/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/refresh or /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 mode session.refreshToken is "",
    and the client never sends it as a token. A refresh refused with 400 is
    now logged with console.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
    as Relations: {}, which typescript-eslint's recommended
    no-empty-object-type refuses in any project that lints its generated
    types; it is now Record<never, never>, the same type for include.

  • upsert() without onConflict updates the row already at that key,
    instead of answering 409, 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-* and Preference-Applied:
    the runtime
    and the ejected backend now expose them. Before, etagOf(row) was always
    undefined cross-origin, and ifMatch writes 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
    offset to page past them.

  • Generated types no longer let you write a field nobody may write; a
    non-required column can be written as null, and hasMany/manyToMany
    memberships (with _pivot) are writable.

  • A typed client refuses an unknown collection accessor and an unknown
    include relation at compile time,
    types returning: false results as
    possibly undefined, and types collection("snake_case_slug") and
    client.collection(slug).

  • An offline create refused with a 409 (a unique value already taken)
    is rolled back and reported to onSyncError instead of being quietly
    dropped from the queue as synced.

  • With offline on, find({ where: { field: undefined } }) and the bare
    PostgREST string form no longer throw a TypeError.

  • 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 offline on, listen() shows the user's queued edits instead of
    the raw server row, and listenById() 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 offline on, 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 for auth.isInitialized().

  • With offline on, 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.
    With syncIntervalMs: 0 the queue waited for the
    app to call sync(), 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/refresh answered {"refreshToken": ""} with
    400 INVALID_INPUT before 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
    refreshToken now counts as no token on /auth/refresh and
    /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. deleteExpiredTokens had 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 in rebase.cron_claims as rebase:auth:expired-tokens.
    The Postgres repository's sweep now covers magic links, email codes,
    refresh tokens and MFA challenges as well as reset links.

  • providerId keeps saying how the session was signed in after a refresh.
    /auth/refresh and /auth/me answered password for every session, so a
    Google user read google at sign-in and password an hour later, and a
    guest read anonymous, then password. The method is stored with the
    session (refresh_tokens.method, added at boot) and carried across every
    rotation; sessions signed in before the upgrade read password.

MCP

  • Remote MCP reads return REST's rows (ISO dates, a belongsTo as its
    foreign key), not the admin view model. A row that was read can be
    sent back in update_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-subquery fires 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/sql and drizzle/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 through liveSchema.repository, and it
    rewrites the file with ts-morph, which the image did not ship. The editor
    reported SCHEMA_EDITOR_MISSING_DEPENDENCY and said to run
    pnpm add -D ts-morph, which a managed project cannot use to reach the
    server. The image now installs ts-morph 28.0.0, the version the server
    pins.

Storage & email

  • GET /api/storage/sources on 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 carries configured: true. GET /api/auth/config
    now includes storage, 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
    no STORAGE_TYPE and 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 with STORAGE_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) answers 400 INVALID_STORAGE_KEY
    instead of 500.

  • /api/storage/file/DEFAULT/public/x, public//x and .%2Fpublic/x are
    served anonymously like public/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. A PATCH that arrives while
    another is being received now answers 423 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 new ETag and a Last-Modified of now, so nothing
    ever answered 304, every ?width= request re-encoded the image, and with
    storageRenditionCache on 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's EISDIR,
    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 from rebase.channel_messages. Before, any
    database login could LISTEN and 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 could LISTEN and 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 with REALTIME_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() is null, and context.user (null under "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+xml under
    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 own rebase.storage calls 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 as GET /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 /metadata minted a download token
    for it. The check now runs on the key the route serves. storageAuthorize
    hooks should test key with the new isPublicStorageKey from
    @rebasepro/types; isPublicStoragePath stays for client-side paths and
    URLs.

  • A private file under a folder named default (key default/public/x) is
    no longer served with Cache-Control: public,
    and new projects'
    config/storage.ts hook no longer treats it as world-readable.

  • storageAuthorize and policy predicates receive the storage source the
    request is served from.
    ?storageId=%20private reached the hook as
    " private" while the private source served, deleted or took the write.
    The default source is now always undefined, 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 NULL and auth.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-Security no longer includes includeSubDomains by
    default;
    set REBASE_HSTS_INCLUDE_SUBDOMAINS=true to 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:write role 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 grants admin. 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 a defaultRole that is admin or a
    declared role holding users:write, schema:write or 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 accepted schema-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-password ends 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 stamps tokens_valid_after in the
    same transaction, as the API reset does.

Postgres

  • A rebase db push that 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, while rebase_user already held read and write on a
    new table through the schema's default privileges; a push stopped by a
    refused search block 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 with REBASE_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.