Skip to content

v0.21.1

Choose a tag to compare

@github-actions github-actions released this 15 Sep 16:23
· 1921 commits to main since this release

Added

  • rebase upgrade moves every @rebasepro/* package to one release. A
    project pins the framework in several package.json files, and rebase build could only warn that they were behind. rebase upgrade rewrites every
    @rebasepro/* entry in dependencies, devDependencies and
    optionalDependencies, in every package.json under the project, keeping
    ^ and ~, then installs with the package manager your lockfile names.
    --to takes an exact version (0.21.0, no registry lookup) or a dist-tag
    (latest, the default, or canary). peerDependencies, workspace:,
    link:, file:, git and tag specs are left alone and listed with the
    reason. Overrides in pnpm-workspace.yaml and package.json are bumped the
    same way. A link: or file: override wins over every pin, so it is
    reported, and --drop-local-overrides removes it. Only the version strings
    change: indentation, key order and line endings stay as they were.
    --dry-run writes nothing, --no-install skips the install, and --json
    prints one document with every change. The build warning about old pins,
    and rebase doctor's version-skew fix, now name the command.

  • A bundle deploy uploads the project's source, so a platform upgrade can
    rebuild it.
    rebase cloud deploy now packs the source beside every
    backend bundle and names it in the deploy. In a git repository that is what
    git ls-files lists from the repository's top level, so .gitignore is
    honoured and a package the project links from elsewhere in the repository
    goes too. A link: or file: dependency that reaches outside the project's
    repository — a project that is its own repository, nested in another it
    links packages from — brings the repository holding its target, and the
    archive is rooted where they meet; @rebasepro/* links are never followed. node_modules, dist-bundle*, .envrc and every .env and
    .env.* file are always left out, committed or not; .env.example,
    .env.sample and .env.template are kept. The VITE_* values a production
    build of your frontend reads travel with it, and no other variable does. If
    the archive is over 100 MB or the upload fails, the deploy goes ahead without
    it and says so. --no-source skips it for one deploy. A project's owner can
    turn platform rebuilds off for good — rebase cloud settings set --platform-rebuilds off, or the project's settings in the console — and then
    no source is uploaded, the platform deletes the copy it holds, and upgrades
    move the runtime only; rebuilds are on until someone does.
    --allow-downgrade deploys a bundle built on an older framework release than
    the project runs, which the control plane otherwise refuses as
    FRAMEWORK_DOWNGRADE.

Changed

  • Entity form fields are 48px tall again. 0.20.0 took them to 32px
    (small). The form passes large again, and the field bindings default to
    it, so a field nested in a map, an array or a table cell's popup editor is the
    same height as the rest. The text stays at 14px.

Removed

  • RealtimeService.registerDataDriverSubscription, addSubscriptionCallback
    and removeSubscriptionCallback
    (@rebasepro/server-postgres). They
    registered an in-process listener in two steps and left its first read to the
    caller, which is how that read came to run unscoped (see Security).
    startDataDriverSubscription(id, request, callback) registers the listener
    and delivers its first rows. The request carries the subscriber's
    authContext. unsubscribe(id) cancels it.

Fixed

  • rebase schema stale reports a generated schema the collections have moved
    past.
    It looked for one thing only: foreign keys named the way an earlier
    release derived them. A file missing a table, with a retyped column or with an
    enum label the collection had dropped was reported as "Nothing stale", while
    every rebase build rewrote it. It now compares the file, declaration by
    declaration, with what the collections generate, as rebase doctor already
    did. It names what differs, exits non-zero, and regenerates the file under
    --fix. When both problems apply, it reports both.

  • A realtime listener on Postgres gets its first delivery in order, hears a
    deleted row, and keeps its whole query.
    listenCollection and listenOne
    fetched their first delivery themselves, outside the realtime service that
    serves every refresh after it. Four faults came with that:

    • A slow first read could arrive after a newer refresh and put the listener
      back on the rows from before the change, where it stayed until the next
      write. It could also arrive after you had unsubscribed. The first read now
      takes the same delivery slot as every refresh.
  • listenOne dropped null. A listener whose row was deleted, or never
    existed, heard nothing and kept the last row it had. It now receives
    null, as it does on MongoDB.

  • listenCollection passed on nine of the query's fields and dropped the
    rest. A listener with an or(...) group (logical) was handed every row,
    on the first delivery and after every change. The whole query is now
    stored with the subscription, logical, include, fields, distinct
    and searchExplain included.

  • A listener on the base driver, with no user, read its first rows as the
    server and every refresh as the anonymous user, so its rows changed on the
    first write. Both are now read as the anonymous user, as on MongoDB. A
    listener on a driver bound to a request, such as context.data inside a
    collection callback, had its refreshes read as the anonymous user too. It
    now reads as that request's user throughout.

  • A late subscription error no longer overrides newer data. On Postgres, a
    realtime subscription's failed fetch always sent its error, even when a
    newer fetch had already delivered fresh rows. Two fetches for one
    subscription can overlap, for example the first load and a refresh after a
    quick write, and the slower one can finish last. So the client's onError
    fired, and the admin panel showed an error, for a view that was up to date.
    A failure from a subscription that had been cancelled, or replaced under the
    same id, could also reach the new one. An error is now sent only when
    nothing newer has reached the subscriber, the same rule its rows follow. A
    subscription whose rows cannot be sent at all, such as a row holding a value
    that will not serialise, still gets an error, on both databases.

  • An in-process realtime listener hears when a read fails. Server code
    that listens with listenCollection({ onUpdate, onError }) or listenOne
    was not told when the read behind the subscription failed. On MongoDB,
    neither the first load nor any refresh after a change reached onError. On
    Postgres the first load did, but a failed refresh was only logged, so the
    listener kept its last rows as if they were current. Both now call onError
    with the error as it was thrown: an afterRead that throws a
    RebaseApiError arrives as that RebaseApiError, not masked the way a
    WebSocket frame is. A failure is not reported once newer rows have arrived,
    or after you unsubscribe. RealtimeProvider.subscribeToCollection and
    subscribeToOne take the same optional onError, and the router used when
    a project has several data sources passes it on.

  • A realtime subscription on MongoDB reports a failed read. When the fetch
    behind a WebSocket subscription failed, the Mongo server logged the error and
    sent nothing. That happened on the first load and on every refresh after a
    change. The client got neither rows nor an error, so the admin panel's view
    stayed loading and the SDK listener's onError never fired. The server now
    sends an error for that subscription. A refusal keeps its code and message,
    as it does over REST and on Postgres: an afterRead that throws
    new RebaseApiError("…", { status: 403, code: "FORBIDDEN" }) reaches
    onError with code FORBIDDEN and that message. Any other failure, a 5xx
    included, arrives as Could not load data for "<path>". Check server logs for details. with code INTERNAL_ERROR, and the error itself goes to the log.
    A refusal is logged as a warning, not an error.

  • Date and read-only fields in the entity form match the other fields. A
    date field ignored the form's size and always drew at 48px. In 0.20.0 and
    0.21.0, where the form was small, that put it 16px above the text fields
    beside it. A read-only field had a fixed 48px box and printed its value in
    16px text, a size above the 14px of every input. Both now follow the form's
    size, and a read-only value is printed at 14px. See also Changed.

  • A multi-select is as tall as the fields around it (@rebasepro/ui).
    MultiSelect stood 2px short of a TextField or Select of the same size
    (48px against 50 at large), because the field's 1px border was drawn inside
    its height rather than around it. A bordered MultiSelect is now the control
    height plus the border, like the others. An invisible one, in a table cell,
    is unchanged.

  • POST /admin/users leaves delivery to a create hook, and shows the admin
    the hook's temporary password.
    A collection's auth.onCreateUser or a
    backend's AuthHooks.onAdminCreateUser replaces Rebase's own delivery: the
    hook sends its own invitation or chooses a temporary password, and reports
    which. A POST to the auth collection honoured that. POST /admin/users
    ignored it and treated the hook's password as one Rebase had generated. With
    email configured, that meant minting a password-reset token and sending
    Rebase's own invitation: a second email when the hook had already sent one,
    with a link that competes with the password the hook chose. The response then
    left that password out, so the admin who has to pass it on never saw it. A
    hook that sent its own invitation and returned no password was reported as
    invitationSent: false. Both ways of creating a user now go through one
    step, so they give the same answer.

  • Uploads between 10 MB and 50 MB work. POST /api/storage/upload has its
    own body limit, the storage config's maxFileSize (50 MB by default), and
    the docs said it overrode the global maxBodySize (10 MB by default). It
    never did. The global limit runs first on every route under the base path and
    answers 413 on the Content-Length alone, so every non-TUS upload above
    10 MB was refused before the storage limit was checked. That includes every
    upload from the admin panel. Now the upload route is taken out of the global
    limit and meets only its own, whether maxFileSize is larger or smaller than
    maxBodySize. Every other route keeps the global limit, the rest of
    /storage included. TUS is unchanged: each PATCH chunk is still capped by
    maxBodySize, and the whole file by maxFileSize. A proxy in front of the
    runtime has its own limit, which still applies. For the Helm chart's, see the
    next entry.

  • The Helm chart's ingress lets 50 MB uploads through. nginx refuses a
    body over proxy-body-size with its own HTML 413, before the runtime sees
    it, so the chart sets ingress.maxBodySize above the runtime's limit. It was
    12m: above the global maxBodySize (10 MB), but not above the 50 MB
    maxFileSize that POST /storage/upload now meets (see the entry above). So
    on chart deployments, uploads between 12 and 50 MB still failed, admin-panel
    uploads included, and the error was nginx's page instead of the runtime's
    JSON. The default is now 64m. pnpm check:chart now refuses a default
    that is not above the larger of the two runtime limits. It reads them from
    DEFAULT_MAX_FILE_SIZE and from the new RUNTIME_DEFAULT_MAX_BODY_SIZE,
    which @rebasepro/server exports with the rest of the pod contract. A
    release that sets ingress.maxBodySize itself keeps its value. If yours is
    below 50m, raise it.

  • A collection-callback veto over the WebSocket is reported as a veto. A
    beforeSave, afterSave, beforeDelete or afterDelete that throws, or a
    beforeDelete that returns false, is answered over REST as a 400 (403 for
    false) with code CALLBACK_REJECTED, the author's message, and
    details.stage naming the hook. Both WebSocket servers answered the same
    refusal as INTERNAL_ERROR, and in production replaced the message with "An
    unexpected error occurred". The admin panel writes through the socket, so an
    editor whose save or delete a rule refused was told the server had failed,
    and never saw the rule's message. The row was still protected. Only the
    answer was wrong.

    The socket now answers with the refusal's own code, message and details,
    as REST does. The same applies to any RebaseApiError that carries a status.
    Realtime subscriptions on Postgres had the same gap: a RebaseApiError 4xx
    thrown from afterRead arrived as "Could not load data … Check server logs".
    It now keeps its code and message. The client passes a frame's details on
    to the RebaseApiError it throws, so e.details.stage works over both
    transports. e.status stays undefined for a socket error, because a frame
    is not an HTTP response. Real server faults are still masked in production.
    REST and both sockets now decide what counts as a refusal through one
    function, declaredErrorAnswer from @rebasepro/server.

  • Scheduled backups include your users again, and can be restored.
    createBackupCron left the rebase schema out of every dump unless told
    otherwise. The aim was to skip Atlas's revision table, but that schema also
    holds every user account and the rest of auth, API keys, record history, the
    job queue and cron logs, and the functions every generated RLS policy and CDC
    trigger calls. So each scheduled dump was missing all of that. It also could
    not be restored into an empty database: every collection's generated policies
    call rebase.uid() and rebase.roles(), so pg_restore stopped with
    schema "rebase" does not exist. rebase db backup excludes nothing, so the
    manual and the scheduled backup captured different databases.

    The scheduled backup now excludes nothing either, and excludeSchemas still
    narrows it when you pass one. A scheduled dump made by an earlier version
    cannot be trusted, so take a fresh backup after upgrading.

  • The Studio Backups panel downloads a backup's roles file. A backup is a
    .dump plus a .globals.sql sidecar holding the database roles its grants
    and RLS policies refer to, and rebase db restore needs both. The panel, and
    the /api/admin/backups routes behind it, only knew about the .dump, so a
    backup downloaded from Studio stopped at its first GRANT when restored into
    a new Postgres. Each row now has a Roles file button, or says No roles
    file
    when there is none. The listing carries the sidecar's key as
    globalsKey, and /download serves it.

  • FORCE_LOCAL_STORAGE=false switched the production storage guard off. In
    production a local storage backend is dropped unless FORCE_LOCAL_STORAGE
    says a durable volume is mounted, because a container's filesystem is erased
    on the next restart or redeploy. The guard tested whether the variable was
    set, not whether it was true, so =false — the natural way to say "there
    is no volume here" — registered the local backend, and uploads succeeded into
    a disk the next redeploy wiped. rebase status read it the same way and
    called that storage ready. Only a spelled yes forces it now, and the guard and
    rebase status share one reader.

    A deployment that set FORCE_LOCAL_STORAGE=false while storing uploads on
    local disk was losing them at every redeploy; its uploads now answer 501 STORAGE_NOT_CONFIGURED until a bucket is configured, or until
    FORCE_LOCAL_STORAGE=true says a durable volume really is mounted.

  • REBASE_MCP_OPEN_REGISTRATION=0 left OAuth client registration open. The
    switch that turns off dynamic client registration on the MCP authorization
    server compared against the literal "false", so 0, no and off left
    /oauth/register issuing a client ID to anyone who asked. Any spelling of no
    closes it now.

  • rebase db backup addressed a MinIO bucket as a hostname. The backup,
    restore and backups list commands passed forcePathStyle: false whenever
    S3_FORCE_PATH_STYLE was unset, overriding the default the runtime's own
    storage gets: path-style for a custom endpoint. Unset now leaves that choice
    to the endpoint in both, and S3_FORCE_PATH_STYLE__<KEY>=1 is no longer read
    as false.

  • Boolean environment variables accept one set of spellings. true, 1,
    yes and on mean yes; false, 0, no and off mean no, in any case;
    anything else leaves the variable at its default. Seven readers had spelled
    this seven ways, so DISABLE_DB_ROLE_SWITCHING=1 and
    REBASE_EXIT_ON_UNHANDLED_REJECTION=true did nothing, REBASE_DEBUG=0 turned
    debug output on in one command and off in another, and CI=0 read as a CI
    run. DO_NOT_TRACK=false now reads as a no instead of a refusal; telemetry
    still sends nothing without your explicit opt-in. The variables loadEnv
    validates still accept only true, false or empty and refuse anything else
    at boot, as before. The parser is exported from @rebasepro/types as
    parseEnvBoolean, and verify:docs now refuses a boolean read that goes
    around it.

  • A collection's onResetPassword hook could not actually reset a
    password.
    The admin panel showed the temporaryPassword the hook returned,
    and nothing saved it: the hook's context can hash a password but not store
    one, and the route only passed the value on. The documented example did
    exactly this, so every reset through it handed the user a password that did
    not work, and left the old one working. A returned temporaryPassword is now
    the account's new password: Rebase hashes it, saves it and signs the user out
    everywhere before showing it to the admin. onAdminResetPassword follows the
    same rule, so its hook no longer has to write the password through authRepo
    as well; one that already does gets it written a second time, which is
    harmless. The example now generates the password with randomBytes instead
    of Math.random.

  • A release whose smoke test fails still gets its GitHub Release. The
    release job created the GitHub Release, and published to the MCP Registry,
    only when every step before it had passed, including a smoke test that
    installs the just-published CLI. 0.21.0's smoke test ran six seconds after
    the npm publish, before @rebasepro/server-postgres@0.21.0 was visible on
    npm, so the packages and the tag went out without a GitHub Release. Both
    steps now run whenever the packages and the tag did. The smoke test now
    waits for every package at the new version rather than only the CLI, and
    fails if they never appear. The 0.21.0 GitHub Release has since been created
    by hand.

  • A refused bundle deploy prints the control plane's reason and remedy. On
    the managed path every refusal went to the generic error report, so a bundle
    refused at intake printed Managed deploy failed to start (400) and dropped
    the hint. It now prints the message, the hint and the code, as a source
    deploy already did. A FRAMEWORK_DOWNGRADE refusal also names the two ways
    past it: rebase upgrade, or --allow-downgrade.

  • A bundle deploy run from a subdirectory registers the project's apps.
    rebase cloud deploy read rebase.json from the working directory, so run
    from backend/ it found none there and told the platform the project
    declared no apps. It reads the project root's.

Security

  • A guest passed policy.registered() on MongoDB, and over the Postgres
    WebSocket.
    A guest, a caller from anonymous sign-in, has a real uid. The
    guest flag, isAnonymous, is the only thing that tells it from an account,
    and policy.registered() ("signed in, and not a guest") reads it. Several
    doors dropped the flag and treated a guest as an account:

    • On MongoDB, the check that decides a single row ignored the flag. So
      GET /api/data/<slug>/<id>, POST, PATCH and DELETE let a guest read
      and write rows a registered() rule withholds, and so did the WebSocket
      and the SDK. Lists were filtered correctly.
  • The MongoDB WebSocket never read the flag at sign-in, whether the client
    signed in with the platform's token or through an auth adapter. It served
    a guest's lists, reads, writes and subscriptions as an account's.

  • A MongoDB in-process listener, such as listenCollection or listenOne
    on driver.withAuth(guest), subscribed without the flag.

  • The Postgres WebSocket read the flag from the token, but ran every request
    frame (FETCH_*, SAVE, DELETE) as an account's. A policy on
    rebase.is_anonymous() did not stop a guest there. A session signed in
    through an auth adapter lost the flag everywhere, subscriptions included.
    And a client with no session was treated as a guest on request frames
    only. Over REST and on subscriptions it was not one, and now it is not
    one anywhere.

    REST on Postgres was never affected. The flag now reaches every door, and
    each WebSocket server builds one identity for both its requests and its
    subscriptions. If you use policy.registered(), or rebase.is_anonymous()
    in a policy, with anonymous sign-in enabled, upgrade.

  • A MongoDB WebSocket client that never signed in ignored every security
    rule.
    With requireAuth: false, a client that connected without signing
    in was served by the base driver. On MongoDB that driver applies no
    securityRules at all. So the client read every row of every registered
    collection, and could create, update and delete in any of them. REST served the
    same caller as the anonymous user and enforced the rules. The socket now
    serves such a client as the anonymous user (roles: ["anon"]), as REST
    does, for both its requests and its subscriptions. If the socket cannot
    scope a frame, the frame now fails. It used to fall back to the base
    driver. If you run MongoDB with requireAuth: false, upgrade.

  • On Postgres, a realtime listener on a user's driver got its first rows
    without that user's row-level security.
    This affected server code that
    calls listenCollection or listenOne on a driver scoped to a user, such as
    driver.withAuth(user) or a function's requireDriver(c). The first
    delivery was read on the base driver, the server context, before the user's
    identity was attached to the subscription. Only the refreshes after a change
    read as the user. So on a connection that bypasses RLS, which is when
    rlsUserRole is set (a superuser, a BYPASSRLS role, or the owner of the
    tables), the first delivery carried every user's rows, and listenOne handed
    over a row the user may not read. The next refresh dropped them again.

    A guest's refreshes had a gap of their own. The identity attached to them
    left out isAnonymous, so a policy that excludes guests did not exclude them
    after a change.

    The first read now runs in the realtime service, as the subscriber, like
    every refresh. The subscriber's identity is part of the subscription from the
    start, isAnonymous and the token's claims included, and it is built by the
    same function that scopes a request's transaction. The WebSocket
    subscriptions that the SDK and the admin panel use were not affected: their
    first read was already scoped. So was MongoDB's. If your server code listens
    through a user's driver on Postgres, upgrade.

  • The MongoDB realtime socket could read and write the auth store. MongoDB
    has no row-level security, so on that engine the collection registry is the
    whole access model: a securityRule is enforced only for a collection the
    registry resolves, and MongoDataService maps any path to a physical
    collection by name. The realtime socket forwarded FETCH_COLLECTION,
    FETCH_ONE, SAVE, DELETE, COUNT, CHECK_UNIQUE_FIELD and the
    subscribe_* frames to the driver without asking whether the path named a
    registered data collection. For a path the registry did not know,
    AuthenticatedMongoDriver resolved the collection to undefined and every
    rule check short-circuits open there — authorize(undefined) returns true
    and the query filter becomes "match all". So any authenticated client (or,
    when requireAuth is off, any anonymous one) could name the auth collections
    as the path and read rebase_users including password hashes, grant itself an
    admin role by writing rebase_user_roles, overwrite another account's
    password hash, or delete refresh tokens — on the one engine where nothing
    behind the driver would stop it. The REST routes were never exposed: they
    mount per registered slug and 404 everything else.

    The socket now refuses, fail-closed, any frame whose path the registry does
    not resolve, on every data verb and both subscribe types, with a NOT_FOUND
    naming the path — the same answer REST gives an unknown collection. Registered
    collections are unaffected, and their row security is unchanged.

  • A delete over the WebSocket wrote its own audit record, and could get past
    a beforeDelete veto.
    DeleteProps carried row.values, and both server
    drivers took them as the row being deleted: they were what beforeDelete and
    afterDelete received and what history recorded as the row's final state.
    The REST routes filled them from a read of their own. The WebSocket DELETE
    handler forwarded the client's frame, so a caller allowed to delete a row
    could record anything as its final state — and could get past a
    beforeDelete that refuses on a column, such as the callbacks guide's
    if (row.status === "published") throw, by sending values: {}. The
    in-process SDK sent {} on every delete(id), so every delete from a
    function, a cron job or a callback recorded an empty row and gave
    beforeDelete nothing to judge. The MCP delete_document tool did the same.

    Both drivers now read the row themselves before any callback runs, under the
    caller's own scope — on Postgres, inside the delete's transaction, as the
    caller's role — and that read is what the callbacks receive and what history
    records. It is the read the REST route already made, so a delete over HTTP
    sees what it saw before. A row that is not there, or that the caller cannot
    read, is a 404 before any callback runs, on every path; over the socket and
    the SDK the callbacks used to run first. DeleteProps.row is { id, path }
    now: values is gone from the type and ignored on the wire, so a published
    client that still sends it keeps working.

    The socket's DELETE also stopped forwarding the frame's collection. The
    driver merges a caller's collection under the registry's, so a key the
    registry does not declare survived, and softDelete: { field: "title" }
    turned a delete into an update of title that no beforeSave and no write
    validator saw.

    One more change came with dropping deleteMany's own pre-read: a hard
    deleteMany now purges rows that are already in the trash, as a hard
    single-row delete does. Its old read hid them and answered 404.

    If a collection has history: true, or a beforeDelete that refuses on the
    row's contents, and anything deletes through the socket or the SDK, upgrade.

  • An admin password reset left the account's existing sessions signed in.
    Resetting a user's password from the admin panel, or through
    POST /api/admin/users/:uid/reset-password and PUT /api/admin/users/:uid,
    changed the credential and revoked nothing. That applied whether the admin set
    the password directly, emailed a link, or got a temporary one. A stolen
    refresh token went on minting access tokens for the rest of its lifetime —
    after the one action an administrator takes when an account has been phished.
    Only the user's own reset and change-password signed the other sessions out.

    Every way of setting a password on an existing account now ends its sessions:
    the refresh tokens are deleted and, on Postgres, every access token issued
    before the reset is refused. That covers userManagement.updateUser with a
    password too. The emailed-link mode now ends them when the link is sent
    rather than when it is opened, and so does a reset handled by an
    onResetPassword or onAdminResetPassword hook.

  • A database error during an admin reset set a new password. Minting the
    reset token shared a try with sending the email, so a failed write took the
    "email failed" path: a fresh password was written over the user's and handed
    to the admin as a temporary one. It now answers 500 and leaves the password
    alone.