Skip to content

v0.17.0

Choose a tag to compare

@github-actions github-actions released this 29 Aug 17:02
· 911 commits to main since this release

Breaking

Under 0.x the minor is the breaking position: ^0.16.0 resolves
>=0.16.0 <0.17.0, so nothing here reaches a project until it deliberately moves
to 0.17. The entries below say what stops working and what to do; the reasoning
for each is in the detailed section it links to.

  • @rebasepro/admin is now @rebasepro/cms, and @rebasepro/admin-types is
    @rebasepro/cms-types.
    "Admin" named two things at once — the whole panel,
    and the content-management half of it — and the ambiguity had already cost
    something: spreadsheet views, entity history, users & roles and CSV import were
    being sold as Studio features because there was no other name for the half they
    actually belong to. The structure is now three peers under Rebase — Backend,
    CMS, Studio — rather than a parent with two children. "Admin panel" survives
    only as a lowercase phrase for CMS and Studio rendered together.

  • import { RebaseAdmin } from "@rebasepro/admin";

  • import { defineCollection } from "@rebasepro/admin-types";

  • import { RebaseCMS } from "@rebasepro/cms";
  • import { defineCollection } from "@rebasepro/cms-types";

  **Who this breaks, and what to do.** Anyone importing either package: change the
specifier, and `RebaseAdmin` to `RebaseCMS`. There is no alias and no
deprecation period — a shim would keep both meanings of "admin" alive, which is
the defect being fixed. `@rebasepro/admin` and `@rebasepro/admin-types` stop at
0.16.0 on npm and receive nothing after it, so a range like `^0.16.0` keeps
resolving to the last release rather than breaking; it simply stops moving.

  **Your collection files do not change.** The `admin:` config key is deliberately
untouched, along with every identifier named after it (`AdminCollection*`,
`Admin*Options`, `ADMIN_COLLECTION_KEYS`), `DatabaseAdmin`/`databaseAdmin`,
`wsAdmin`, the `admin` auth role, and `/api/admin`. Those name something other
than the CMS product: the `admin:` block feeds a nav drawer Studio shares, and
`/api/admin` serves the RLS audit and API keys, both of which are Studio's.
Renaming them would have doubled the churn to no one's benefit.

  The panel's mode value moved with the package, `"content"` → `"cms"`. It is
persisted per browser and migrates on read, so a browser that used the panel
before this keeps working instead of holding a mode nothing matches and
rendering neither half of the drawer.

- **Resources are declared, not configured.** `RebaseBackendConfig`'s
`dataSources` and `storageSources` are gone; declare them in `rebase.json` and
the config package instead. **A bundle built before this will not boot on a
current runtime — rebuild it with `rebase build`.** The runtime contract stays
at 1 deliberately; see the note under *Removed*.

- **A collection still carrying `admin.titleProperty` is rejected at boot.**
Use `admin.display.title` — the same string works there. This can stop a project
that starts today, which is the point: silence would mean a title quietly
reverting to the derived one with nothing to explain why. Details under
*Removed*.

- **`ctx.client` in a cron handler is now `ctx.rebase`**, and `userId` is no
longer an accepted identity spelling anywhere — `uid` everywhere. Both under
*Removed*, with the reason each alias was more dangerous than the rename.

- **`rebase eject infra` is gone**, along with `rebase.infra.json` and the
`{"$env": "..."}` indirection. Resources bind from the environment on the
`<BASE>__<KEY>` convention, which is the path every deployment already used.

- **`rebase build --legacy` and `rebase start --legacy` are now `--workspace`.**
The mode is supported, not retired, and the old name said otherwise.

- **Every deprecated API alias is deleted rather than warned about**, including
`WhereValue<T>` (use `WhereValueFor`) and `RENAMED_SLOTS`. The full list is
under *Removed*.

- **An incoherent Kanban board now fails at boot.** A board is two declarations
that have to agree, and every way of getting it wrong used to parse, boot,
serve rows and render — the only symptom being that dragging did not stick.
`checkBoardConfig` now runs wherever collections load, so the runtime,
`rebase schema generate`, the policy generator and `rebase doctor` all say it.
An `orderProperty` naming a property that does not exist, **or one that is not
a string, is fatal**; `kanban` with no `orderProperty` only warns, and the
board still boots without reordering.

  **This can stop a project that boots today, and the docs are why.** An order
key is a `fractional-indexing` key in base36 (`"i0"`, `"i1"`, `"i0i"`), so a
`number` can never hold one — but the documentation said
`sortOrder: { type: "number" }` in every locale, and five translated copies
additionally nested `orderProperty` inside `kanban`, where nothing reads it.
All of that is corrected. If you followed it, change the property to a string:

  ```diff ts
- sortOrder: { type: "number" }
+ sortOrder: { type: "string" }
  • A static app can no longer claim a path the backend serves. One process
    serves the API and however many static apps a project declares, and mounting is
    longest-path-first — so an app declaring path: "/api" outranked the API
    itself, and every request to it was answered with that app's index.html: a
    200 carrying HTML where the caller wanted JSON, from a project that looked
    deployed and healthy. rebase.json validation, the control plane at deploy
    intake, and the router's own mount ordering now enforce the same reserved list
    from @rebasepro/types. Matching is at segment boundaries, exactly as the
    router matches: /apidocs is still fine, /api/v2 is not.

PUT on the data API is not in this list: it was removed during this cycle
and put back before release, because every published SDK still sends it. See
PATCH is the update verb under Changed.

Removed

  • rebase eject infra and rebase.infra.json. The command wrote a file
    documented as being "read before the environment", and nothing read it:
    loadInfraConfig and bindResources had no caller outside their own tests,
    in either repository. The three-tier binder they implemented — file, then
    environment, then a local provisioner — never ran, and the header claiming
    the control plane injected such a file was contradicted by the control plane's
    own comment saying it deliberately does not.

    Resources bind from the environment on the <BASE>__<KEY> convention, which
    is the path every deployment has always used. Running the command now names
    the removal rather than failing as an unknown app. packages/server/src/boot/local-provisioner.ts
    went with it — it returned STORAGE_BUCKET and REBASE_STORAGE_ENGINE, names
    the resolver has never read.

    Removing this drops the {"$env": "..."} indirection with it. A self-hoster
    wiring secrets from Vault or SOPS renders them into the environment, which is
    what everyone was already doing — the alternative was maintaining a second
    binding path no deployment has ever exercised.

Breaking: resources are declared, not configured. RebaseBackendConfig's
dataSources and storageSources are gone; declare them in rebase.json and
the config package. A bundle built before this will not boot on a current
runtime — rebuild it with rebase build.

The runtime contract stays at 1. Pre-release, a breaking change is just a
change: there is no population of old bundles to protect, so a major would buy
nothing and invalidate the rebase range in every manifest and template.

Added

  • pnpm check:portable-core — what the request path depends on Node for. A request this server can answer without touching the database pool is a request an isolate could answer, and that set is larger than it looks: token verification, rate limiting, idempotency, storage URL signing, and every custom function. Eight modules on that path needed a Node process, for nine separate reasons. Five of those modules needed it for no reason anyone had chosen: randomUUID from node:crypto where the crypto global would do, node:path to fold . out of a storage key that never touches a filesystem, SHA-256 and a constant-time compare that WebCrypto does just as well.

    Those five are gone, and the gate records what is left in contracts/portable-core.txt. It is a ratchet rather than a wall: the file may shrink and may never grow, so a branch that puts a fresh dependency on Node in front of every request has to say so in review instead of a year later. Nothing has to reach zero for that to be worth having — drizzle-orm and pg need a TCP socket, and that is a driver decision. What it buys is that a later port is a scoping exercise against a list, not an excavation.

    Three lines remain, each with its reasoning in the file: the JWT library, PEM key parsing, and the client's socket address — a per-adapter capability rather than something a portable module can reach, since Hono has no runtime-agnostic getConnInfo.

    The SSRF guard joined the same list and needed two changes to clear it. net.isIP became utils/ip-address.ts, a transcription of Node's own grammar held to it by a property test comparing the two directly — a validator that is stricter than net.isIP sends a literal down the resolution path, and one that is looser judges bytes nobody else agrees with. And its default resolver is loaded on use rather than imported, so a runtime with no node:dns can be handed one instead of being unable to load the module at all. A host with neither fails closed and says which of the two it is missing: the alternative to resolving a name is not "allow it", it is "do not send".

  • Custom functions have their own entry point: @rebasepro/server/functions. import { defineFunction } from "@rebasepro/server" reaches the whole framework — the boot sequence, the collection loader, the backup routes, the SPA server, @hono/node-server, ws, jsonwebtoken, Drizzle. On Node that costs a little start-up time and nothing else, which is why it stood. It also meant a function file could only ever resolve inside a Node process, however portable the function's own code was — and since that import line is in every function file, every template and every documentation page, it is not a thing that can be changed later without breaking everyone who wrote one.

    The new entry point carries the authoring surface and nothing else: defineFunction, the rebase singleton, route guards, typed context accessors, configuration readers, waitUntil, ApiError, HonoEnv. Its published bundle imports exactly two things, hono and hono/adapter, and the build refuses to ship it otherwise — a test walks the import graph from source and names the chain that broke the rule, and a second check evaluates the emitted file in a context holding web globals and no process, Buffer or require at all. Importing from the package root still works and still behaves identically; it is now the second-best way to write a function rather than the only one.

  • Typed accessors for the request context. getUser(c) returns { uid, roles, …claims } or undefined, with roles always an array. Every documented example used to open with const user = c.get("user") as { uid: string; roles?: string[] } | undefined — an assertion in a security-relevant position, copied once and never re-examined, and wrong for at least one auth path that reaches it. getUserId, getRoles, hasRole, isAdmin, isAuthenticated, getDriver, requireDriver, getApiKey and getRequestId come with it. requireDriver(c) replaces c.get("driver")! and, when there genuinely is no driver, says that the app was mounted outside the functions router instead of failing twenty lines later on undefined.

    requireRole("editor", "admin") joins requireAuth and requireAdmin. All three read the identity the platform already resolved rather than parsing a token, which is what makes them portable — and is a distinction with no behavioural difference inside a function, where both auth middlewares have already run. Outside one, where nothing has, they answer 500 naming the wiring rather than 401 blaming the caller's token.

  • waitUntil(c, promise) for work that outlives the response. An un-awaited promise looked equivalent and was not, in both directions. At SIGTERM a floating promise is dropped mid-flight, so a rolling deploy has always been able to lose the webhook a request had already answered 200 for; shutdown now waits for tracked work, bounded, and says how much it had to drop. And on any host where the process does not outlive the request, an un-awaited promise is not slow but cancelled — silently, behind a clean 200. waitUntil is the one construct both cases honour.

  • Configuration is read from the request: getEnv, env, requireEnv, lazyResource. const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) at the top of a function file is a live defect today, not merely an unportable one: it is evaluated while the file is being imported, so an unset variable throws before any request exists and the loader reports the whole file as a skipped function. The route 404s, and the reason is one line in a boot log. lazyResource(env => new Stripe(env.STRIPE_SECRET_KEY!)) builds the same client once, on first use, from that request's configuration. rebase doctor and rebase build now report module-scope process.env reads in the functions directory.

  • rebase build records what each function needs from its host. The bundle manifest gains a functions array — name, file, and whether the function's own source reaches a Node built-in or a package that needs one. Purely descriptive: nothing fails, and a function that opens a file or runs raw SQL is a fine function. It is recorded because the name is already the function's identity everywhere (/api/functions/<name>, the functions/<name> API-key permission, REBASE_FUNCTIONS_ONLY), and a host that wants to know what is in a bundle should not have to boot it to find out.

  • Live schema editing, from the collection editor to the database. A running backend can now plan a schema change, show what it would do, and apply it only once somebody agrees. planSchemaChange reads the live catalogue before it plans, because whether a NOT NULL can be added is a question about rows and whether an enum value will land is a question about the type — neither is answerable from the collections alone. The editor's save path shows the verdict in a sentence, then each change with its remedy, and does nothing until confirmed.

    Applying is a second privilege, not the same one that opens the editor: it alters the database and it writes a commit into the project's repository under somebody's name, and an admin credential is not an author. For deployments with no working tree — a Cloud tenant runs a built bundle and its repository lives elsewhere — the commit goes through GitHub's Git Data API instead of git.

  • A managed development database, so rebase dev needs no Postgres. Getting a project running was docker compose up -d db, then db push, then dev — three steps, each a place to bounce, plus a compose file the developer then maintains. rebase dev now starts the database and pushes the schema itself. The managed database is PGlite behind a multiplexing socket server, and db pull, the schema flows and the rest of the CLI were wired through to meet it.

    Realtime was the one thing it could not do, and it failed in the worst way available: every query succeeded, LISTEN returned cleanly, and change events simply never arrived. It now works through a notification proxy.

  • REBASE_DB_POOL_MAX, a ceiling every pool honours. The managed database is a single session, where two pooled clients holding overlapping transactions deadlock rather than error.

  • The RLS audit runs on a schedule, and the backend serves what it found. Also rls-check --html: the text report is written for a terminal, and the person who has to act on it is usually not the person who ran the scan. A --fail-on exit code stops a pipeline; it does not survive being forwarded to whoever owns the database.

  • rls-check --role, because a check can only gate on a role it knows about. Every check reports a table as exposed only when a role an untrusted caller can arrive as holds privileges on it, and that set was hardcoded to PUBLIC, anon, authenticated, web_anon and rebase_user. A stack whose app role is called app_user gave every check nothing to gate on, so the scan printed a clean report for a database it had not cleared. The report now also lists unrecognizedGrantees — write-holding roles it can neither recognise as exposed nor explain as trusted — so it says "clean as far as I could tell" rather than "clean".

  • Storage: byte-range requests and per-object access control. Media can be seeked, and who may read an object is declared rather than coded.

  • Bot protection on the auth endpoints that cost something to hit, development secrets that survive a restart, and auth email captured in development instead of refused.

  • An ANN index for every vector column, with pgvector shipped in the scaffolded database image.

  • Collections declare their indexes, and a hand-written index stops
    disappearing.
    The collection model had no indexes key: the DDL generator
    emitted index statements for exactly two things, both structures a feature
    owns rather than queries anyone wrote — the GIN index behind a search block
    and the ANN index behind a vector property. The plain case, the btree behind
    a where clause, had no declaration site at all.

indexes: [
{ on: ["status", { prop: "publishDate", direction: "desc" }],
reason: "admin list: filter by status, newest first" },
{ on: ["publishDate"], where: { prop: "status", op: "=", value: "published" },
reason: "public feed is published-only" },
{ on: ["author"], reason: "an author's posts, and the ON DELETE cascade" }
]


  So the only way to have one was to write it by hand — which is the other half
of this. `rebase db push` is declarative, so an index on a managed table that
is absent from `schema.sql` is drift and Atlas plans `DROP INDEX` for it.
`DROP INDEX` is not in `DESTRUCTIVE_PATTERNS`, so the auto-approved apply took
it with no prompt. Measured against atlas v1.2.3 and Postgres 18, not
inferred: create an index by hand, re-run an unchanged push, and the plan is a
bare drop. Every hand-written index in the field has been living on borrowed
time, and since a hand-written index was the *only* kind there was, that was
the only outcome.

  Adding `DROP INDEX` to the destructive list would have been the wrong fix —
once indexes are declarable, removing one from your config *should* remove it
without a scare. Ownership is decided by the name instead, the arrangement
policies already use. An index is named `<table>_<columns>_ix_<7 hex>` (`_ux_`
when unique), which no other namer here can produce, so a declaration you
delete drops as intended and an index Rebase did not create is excluded from
the diff and never touched. That also settles the introspection round trip:
the existing indexes of a database you point Rebase at are foreign until
somebody declares them.

  The hash is over the index's *semantics*, not its rendered SQL, so
reformatting the generator never renames a live object — and it is what makes
a redefinition take effect at all, since `CREATE INDEX IF NOT EXISTS` matches
on the name. (That bug is shipped today one layer over: `vector-index.ts`
leaves `WITH (m, ef_construction, lists)` out of its name, so retuning an HNSW
index is a permanent silent no-op.)

  `prop` takes a **property key, never a column name**, because the two differ
in exactly the case people index most: a `belongsTo` resolves to its
`localKey`, so `author` becomes `author_id`. `where` is structured rather than
a SQL string — a string could not be checked against the collection's
properties and could not be fingerprinted without putting its own text in the
index name. And `reason` is required, and deliberately not hashed: an index is
the only thing a config can declare that costs money forever and whose benefit
is invisible from the config, so rewording the justification must not rebuild
it.

  Both producers emit them — `db push` on the ordinary Atlas path, and
boot-time schema ensure with `CREATE INDEX CONCURRENTLY IF NOT EXISTS`. The
first cut only did the former, which a managed-runtime tenant never runs; the
derived-names contract caught it, with the whole suite green and the round
trip through real Atlas clean.

  Not included, each its own subsystem: the deferred `CONCURRENTLY` builder for
a redefinition (today a DROP + CREATE holding a lock), a size-based push gate,
`doctor`'s index categories, introspection adoption, and the drizzle-schema
side. See [Indexes](/docs/backend/indexes).

- **A pod contract the chart and the control plane both answer to.** Probe paths, shutdown budgets, the bundle mount and the set of topology variables a deployer owns now live in one place that both pod builders read, instead of two hand-written lists that had already disagreed.

- **`rebase cloud resources` is priced, with no plan left to name**, and `rebase cloud projects info` prints a Storage line — plus a warning or a lockout notice when the project is near or past its limit. The shared pools already enforced a per-tenant disk ceiling by setting `CONNECTION LIMIT 0`; the tenant's first signal used to be their database refusing connections, with no number anywhere that would have warned them.

- **One-click deploy blueprints, an MCP registry manifest, and the security post.**

- **The eight documentation pages every locale was missing are translated**, with validation of what the model returns, and the landing page has a translation script of its own — the marketing pages read no markdown, so nothing had ever translated them.

- **Resources are declared, not configured — and there is one way to do it.** A
database, a bucket and a topic are all spelled the same way, in the project's
own config:

  ```ts
// config/resources.ts
export const main    = database();
export const media   = bucket("media", { engine: "s3" });
export const signups = topic<{ userId: string }>("signups");

Before this, storage topology was hand-written into rebase.json while
database topology lived in TypeScript, and the boundary between them was a
fact about what the control plane could read before a build — a platform
implementation detail a developer had no way to derive. Worse, a bucket could
be declared in both, and the runtime merged them: one engine was kept and
the other silently discarded. A declaration accepted and then ignored, which
is the class this release removed everywhere it appeared.

Kinds are registered, not hardcoded, because the cost of adding one is
exactly why the last two ended up in different homes — a new kind needed a
manifest schema edit, a validator edit and a switch statement, so the cheapest
thing was always to bolt it onto whichever home was nearest. cache, queue
or search now need none of that. Each kind owns its engine list and
custom:<id> is always accepted, which fixes engine having been a free
string: "s2" used to pass every check and fail far from the typo.

  • rebase resources lists what a project declares; --write regenerates
    rebase.resources.json and --check fails on drift. That file is generated
    and committed, and it is what a host reads to decide what to provision
    before running anything — which is how a console can say "wants a media
    bucket, has none" on a first deploy, and how a custom runtime (which emits
    no bundle manifest) is visible to the platform at all.

  • Binding is separate from declaration, and identical everywhere. A
    declaration says a resource exists; the environment says where it lives, on
    the <BASE>__<KEY> convention. Baking the address into the repository is how
    a project ends up with its staging credentials in git, and it is why staging
    and production can run the same commit against different infrastructure.

    The cloud is not a second mechanism: the control plane binds the same
    variables a self-hoster sets, so a managed tenant runs exactly the code path a
    self-hoster runs.

  • Several buckets can share one account. bucket("media", { account: "minio" })
    reads its own S3_BUCKET__MEDIA while the provider-level variables —
    credentials, endpoint, region — fall back to S3_ACCESS_KEY_ID__MINIO and so
    on. Fifteen buckets on one install go from ninety variables to eighteen, and
    rotating a key is one edit rather than fifteen paired ones.

    The bucket name itself never falls back, and neither form falls through to the
    unsuffixed variable: that one belongs to the default source, and letting a
    named bucket inherit it would mean a mistyped key silently signs with another
    source's credentials.

  • Topics, delivered through the durable job queue. Publishing writes one
    row per subscription, so each subscriber retries on its own schedule and a
    broken one neither blocks the others nor makes them run again. Delivery is
    at-least-once and says so — at-most-once is refused at declaration rather
    than quietly given the other guarantee. A publish inside a transaction that
    rolls back never happened. Declaring a topic turns the job queue on by itself,
    and a driver that cannot carry the queue refuses to boot rather than starting
    a backend where every publish throws.

  • The managed tier provisions what a project declares, and charges for it. A
    second database is created on the project's own pool, owned by the same role,
    and billed as a second shared-database line. The disk quota moved from
    per-database to per-project for it: the ceiling used to be keyed on datname,
    so five declared databases would have held five full quotas against a volume
    sized to budget one each — the pool would have run out of space with nothing
    naming the cause. Sizes and allowances are both summed per project now, so a
    second database brings its own space rather than splitting the first one's.

  • Six-digit sign-in codes by email. A magic link opens the session on
    whichever device holds the mailbox, which is the wrong device on a television,
    a terminal, a kiosk or a second browser — the flow simply cannot be completed
    there. auth.emailOtp (or AUTH_EMAIL_OTP=true) adds POST /auth/otp and
    POST /auth/otp/verify, and rebase.auth.sendEmailOtp / verifyEmailOtp in
    the client.

    Six digits is a million possibilities, so what is stored is a hash of the
    address and the code together: a guess is a guess against one named account
    rather than against every account in the table, which is what a code-only
    lookup would have made of it. Five verification attempts per address per
    window, keyed on the address rather than the caller's IP because an IP is the
    attacker's to rotate and the account under attack is not. Ten minutes, single
    use, uniform digits. POST /auth/otp answers identically for an address with
    no account, so it cannot be used to ask whether somebody is a customer.

    AUTH_MAGIC_LINK arrives with it: both flows were code-level flags only, so a
    bundle deployment — the shape every self-hosted and managed project runs —
    could not turn either on without rebuilding.

  • Storage triggers: run something when an object lands. A row has
    beforeSave and afterSave, a schedule has a cron job, and an upload had
    nothing — so everything an upload implied had to be a second call from the
    client, which means it does not happen when the client goes away between the
    two. storageTriggers fires on finalize and delete, matched with the same
    pattern language storagePolicies uses, for the multipart and resumable paths
    alike. Handlers are awaited before the response, because a floating promise is
    one a serverless runtime may freeze mid-flight; a handler that throws is
    logged and does not fail the request, because the object is already stored and
    an error would tell the client to repeat a write that succeeded.

  • Image renditions can live in the storage source instead of one process's
    memory.
    The transform cache was per-instance and did not survive a restart,
    so every replica computed every variant and every deploy threw the lot away.
    storageRenditionCache: { enabled: true } writes each rendition back to the
    source's own bucket under _rebase/renditions/, keyed by the source object's
    version so a replaced image serves the new one. Off by default, because
    turning it on makes a GET write to somebody's bucket — and when that write
    fails the request still succeeds from memory, with the reason logged once.

  • The development mailbox is readable over HTTP. Auth mail with no SMTP
    configured is captured and its links printed, which completes the flow for
    somebody watching a terminal and leaves it incomplete for a server in Docker,
    in another window, or one line above where the log has scrolled to.
    GET /api/admin/dev/emails serves the same capture, DELETE empties it. What
    it hands out is a working login, so it is gated three times over: admin-only,
    a sink must be registered, and the handler re-reads NODE_ENV per request —
    there is no configuration that makes it readable in production.

  • A pooled Postgres port for the callers that cannot hold one.
    docker compose --profile pooler up -d adds pgbouncer on 6432, for the
    serverless functions, scheduled scripts and BI tools that would otherwise
    exhaust max_connections long before the database is busy. Documented with
    what transaction pooling takes away — LISTEN/NOTIFY, session-level SET,
    cross-statement advisory locks, prepared statements — which is why the runtime
    keeps its direct connection. SET LOCAL survives, so RLS behaves identically
    through it.

  • The runtime keeps a little history of itself, and rebase cloud metrics
    prints it.
    Drawing "CPU over the last hour" from Cloud Monitoring would have
    made the panel unportable the day the platform moves, for a feature every
    self-hoster also wants; metrics-server cannot help either, since it stores only
    the latest sample by design. So the process samples itself
    process.cpuUsage() and process.memoryUsage(), no cluster and no vendor —
    into its own database, and anything that can read the database can draw the
    chart. A laptop, a Hetzner box and a Cloud tenant keep the same history from
    the same code. One row per series per minute, five series, swept to a
    fourteen-day window at boot, beside the job and cron stores and for their
    reason: it is the moment the schema is reachable and nobody is mid-request.

  • rebase cloud resources set --replicas and --autoscale-max. Autoscaling
    had columns and no flags, so the console form was the only way to reach it.
    Two flags on the command that already writes every other dial, rather than a
    rebase scale verb — a second CLI surface writing the same row, whose
    --size medium form would have had to carry a t-shirt→cpu/memory mapping
    client-side, which is exactly what substrate differences (Autopilot's
    250m/512Mi floor and 1:1–6.5:1 band do not exist on Hetzner or EKS) make
    wrong. --replicas is the floor and the spend a project is guaranteed to
    incur; --autoscale-max is the ceiling and the worst case it may be billed.
    There is deliberately no --autoscale on|off, which would admit the
    incoherent state where autoscaling is on and the range is a single point.

  • A Terraform module for Hetzner, and a Hetzner page that is true. The old
    page described a Rebase that no longer exists — Docker building a Node.js
    backend from a local Dockerfile, and boot creating only auth tables so
    collections 404 until someone runs db push. Both were wrong, in all six
    locales, and that page is where a reader lands from /docs/deployment. It is
    rewritten against the contract the self-host compose file implements, and
    points at that file rather than carrying a copy that can drift again. The
    module provisions the host — server, firewall, a primary IP that survives a
    rebuild, and a volume holding Postgres data, Caddy's certificates and the
    bundle cache. The volume is the reason it exists: replacing the host must not
    destroy the database, which the shell recipe cannot promise.

  • Live schema editing works on MongoDB. isSchemaEditingAdmin is a
    structural check — a driver either offers planSchemaChange or it does not —
    and the Mongo driver did not, so a Mongo project fell back to the source-only
    editor, which is off in production. A schemaless database is the one place
    where changing a collection against a running backend cannot fail, and it was
    the one place it did not work. planMongoSchemaChange is short by the whole of
    its difficulty: no table to alter, so every change is applicable, nothing is
    refused, and there are no statements. What each change still carries is what
    happens to the data, because that is where a reader imports the wrong
    intuition — removing a property on Postgres is refused because it would drop a
    column, while on MongoDB the field stays in every document that has it and the
    API stops serving it. Saying so is the difference between knowing the data is
    there and assuming it is gone.

Changed

  • JWT verification and signing are asynchronous. verifyAccessToken, generateAccessToken, verifyDownloadToken, generateDownloadToken, hashRefreshToken and extractUserFromToken return promises. Nothing about their behaviour moved; the signatures did, and on purpose, before anything forced it.

    Every portable JWT implementation is asynchronous, because crypto.subtle is. So a later swap of jsonwebtoken — for jose, or for WebCrypto directly — is not the expensive part: the expensive part is going from synchronous to asynchronous verification, which touches every caller of every function that reads a token. That was 22 call sites in src and about 190 in the suite. Paying it now, with the tests green and nothing else moving, costs a day; paying it as a line item inside a runtime port, on top of everything else changing at once, is how a port stalls.

    jsonwebtoken is now confined to one module, auth/jwt-crypto.ts, which is what makes the eventual swap a one-file change with no caller affected. The one trap in that swap is written down where the swap will happen: jsonwebtoken stamps iat on every token it signs and jose does not, and iat is what the revocation watermark is compared against — tokens minted without it would verify perfectly and simply stop being revocable.

    RateLimiterOptions.keyGenerator and resolveLimit accept a promise as well as a value, since a limiter that buckets by user has to verify a token to find one. Passing a synchronous function is unchanged.

  • EmailService.send() reports what the provider said, and carries headers. It returned Promise<void>, which meant an application that sent a message could not learn the id the server assigned it — so threading a reply back to the message that prompted it was impossible through this interface, and any app that needed it had to bypass the service and hold its own transport. It now resolves with { messageId, accepted, rejected }, every field optional because not every backend reports them: an absent messageId means "not reported", never "not sent", which is still signalled by a throw. messageId comes back without angle brackets, since it is a value to store and compare against a reply's In-Reply-To, and one that sometimes carries brackets is a bug waiting in every comparison.

    EmailSendOptions gains headers. Several things a real sender must do are only expressible as headers and had no route through this interface at all: List-Unsubscribe and List-Unsubscribe-Post, which give a mail client its own one-click opt-out and which the large providers weigh when deciding whether bulk mail reaches an inbox; In-Reply-To and References, without which a reply starts a new thread. Values are validated, not escaped — a value containing CR or LF is rejected, because a newline ends the header and begins another one, so any field built from data the sender did not write is a way to add a Bcc:. Stripping the newline instead would deliver a message the caller did not write and tell nobody. Header names are checked against RFC 5322 too, and both checks run before a custom sendEmail provider is reached, so the custom path is not a way around them.

    Breaking only for code that implements EmailService: a send returning Promise<void> no longer satisfies it. Callers are unaffected — they may ignore the result — and the auth.email.sendEmail hook stays permissive (Promise<EmailSendResult | void>), so an existing async () => {} provider still works and simply reports nothing. The development mail sink now reports a synthetic id, so a flow that stores one and later matches a reply against it takes the same path in development as in production.

  • A vendored tree too large to upload is not vendored. The control plane refuses a bundle over 100 MB, and vendoring is the one thing that can push a bundle near it — so a build that crossed the line shipped a bundle whose deploy would be rejected, with the remedy (--no-vendor) only discoverable by knowing that had happened. Past 200 MB on disk the tree is now thrown away and the bundle ships unvendored: 40–60s of cold start, and a deploy that works. --vendor keeps it regardless, for a deploy that builds from source and never uploads the tree at all.

    The ceiling assumes a pessimistic 2× floor on compression, because the limit is on the compressed upload while this measures the tree on disk. The warning below it now says which quantity is which — "201 MB, close to the 100 MB upload limit" was two different numbers described as one, and read as nonsense.

  • GET /api/auth/config answers one question once, from one handler. Two handlers claimed that path: init.ts registers it directly and only afterwards mounts the auth router, so the router's copy never ran — and the two returned different payloads, one reporting emailServiceEnabled and magicLinkEnabled where the live one reported passwordReset and magicLink. A fix aimed at the wrong copy therefore changed nothing, which had already happened once. The router's copy is gone, the payload is assembled in one function, and the surviving route is rate-limited like the rest of the unauthenticated auth surface — it counts users on every call.

    In the payload itself, registration and registrationEnabled were the same boolean under two names, both advertised. registrationEnabled is the only one now, and it is required rather than optional: it says whether self-registration is open right now, first-user bootstrap window included. anonymousLogin is required for the same reason. AuthConfig in @rebasepro/client and AuthConfigResponse in @rebasepro/app are aliases of AuthAdapterCapabilities instead of near-copies of it — the SDK's copy listed an emailServiceEnabled flag no backend has ever sent, and marked as optional fields every backend always sends. A test pins the exact key set, because the drift that started this was a field name, and no per-field assertion can see one.

  • PATCH is the update verb for the data API. PUT was mounted on the same handler and the generated OpenAPI spec described the operation twice — once as patch, once as put marked deprecated — so a client generated from the spec had to choose, and the verb it chose meant "replace" for a handler that merges. The SDK's update() now sends PATCH, which is what the spec has advertised since 0.14; updateMany was already there.

    PUT still answers, on the same handler, carrying Deprecation: true (RFC 8594). It was removed during this cycle and put back: every published SDK up to and including 0.16.0 sends PUT, so removing it broke clients that had no fixed version to upgrade to — see PUT on a collection answers again under Fixed. There is no Sunset date, because the removal is gated on which SDKs are in the field rather than on a calendar.

Removed

Nothing below was deprecated in the usual sense of "still works, please stop". Each was a second name for something that already had one, and every one of them is gone. There is no compatibility mode.

  • admin.titlePropertyadmin.display.title. The same string works there, and display.title also takes a resolver. The old key had grown seven readers that disagreed about the fallback; a collection still carrying it is now rejected at boot, by name, with the replacement in the message — silence here would mean a title that reverts to the derived one with nothing to explain why.

  • ctx.client in a cron handlerctx.rebase. Its type re-exposed client.data, the alias RebaseServerClient omits on purpose so that the privileged plane is spelled dataAsAdmin on every server surface. A reader who learned client.data in a cron carried it into a collection callback, where context.data is the user-scoped plane: same spelling, opposite privilege.

  • userId as an identity spelling. AuthResult advertised uid or userId from a custom validator, and the middleware had already half-removed it — the normalisation read ("uid" in r ? r.uid : undefined) || ("uid" in r ? r.uid : undefined), the same clause twice — so the documented userId had stopped working there while getUser() and the JWT verifier still honoured it. uid everywhere.

  • RENAMED_SLOTS, the rewrite that quietly redirected the retired collection.insights and home.card.insight slot names, and the console warning beside it.

  • WhereValue<T>, superseded by the operator-correlated WhereValueFor.

  • tooltipsOpen and adminMenuOpen on both drawer components, error and padding on RelationSelector, and the ignored second parameter of getEntityTitlePropertyKey — all declared, all documented, none of them read.

  • isBootstrapCompleted / setBootstrapCompleted on the auth route module and the admin users route. No caller ever supplied them; the bootstrap gate is "does this backend already have an admin", asked of the rows.

  • The websocket client's subscriptions map and the "legacy subscription handling" branch that read it. Nothing ever wrote to it.

  • Three modules that only forwarded exports: @rebasepro/types/controllers/database_admin (already exported from types/backend), server-postgres/utils/table-classification (from @rebasepro/common), and the unflattenObject re-export in the admin's file_to_json.

  • rebase build --legacy and rebase start --legacy are now --workspace. The mode is supported, not retired, and the name said otherwise.

  • UploadFileResult.storageUrl is required. Every controller returns one — S3, GCS and local alike — so the ?? fallback behind it was dead code.

  • dataSources and storageSources on RebaseBackendConfig, and the
    storage block in rebase.json. All three were ways to declare a resource
    somewhere other than a declaration. Each is refused at boot, by name, with the
    replacement in the message — not ignored, because a key that still parses and
    no longer does anything is the failure this replaced.

    <Rebase dataSources> and <Rebase storageSources> are unaffected: those are
    props on the React provider, a different surface. Hand them
    declaredDataSources() and declaredStorageSources() so the list is not
    written twice.

Fixed

  • PUT on a collection answers again, because every published SDK still sends it. PATCH became the update verb and the PUT alias went with it. That reached a control plane before it reached any client: collection.update() sends PUT in every release up to and including 0.16.0, which was tagged three days before the change landed, so upgrading to latest did not help either. Three CLI commands are one update()rebase cloud stop, start and restart, all through setStatus — and all three answered 404 No PUT route on collection 'projects' at this path, which reads as a fault in your own data model rather than a verb that was withdrawn. Worst on restart, the thing you reach for when a deploy has gone wrong.

    PUT is mounted on the same handler and carries Deprecation: true (RFC 8594). It is deliberately not in the OpenAPI document: PATCH remains the single update operation, so anything generated from the spec still sends the verb the server means, and a spec-validating gateway still sees one operation. There is no Sunset date, because the removal is gated on which SDKs are in the field rather than on a calendar — it goes one release after the first published client whose update() sends PATCH.

  • executeSql({ role }) answered a refused role switch with owner rows. The option exists so a statement can run as a database role, which is the only way to see what a table looks like with RLS binding. When SET LOCAL ROLE came back 42501 — the connection user not being a member of the role — the driver logged a warning and ran the statement on the unswitched connection anyway, then latched a process-wide flag so every later call skipped the switch too, in silence.

    Owner output is not a degraded answer to that question, it is a confident wrong one: a policy spot-check reads a protected table as exposed. The WebSocket audit line recorded role as the role that had been asked for, so the trail agreed with the mistake rather than catching it. The same subsystem already fails closed twice over — applyAuthContext aborts the transaction when the switch errors, scopeDataDriver refuses the request rather than proceed unscoped — so this was the one door left open, and the only one whose fallback changed which rows came back.

    It now throws RoleSwitchUnavailableError, naming the role and both ways out. DISABLE_DB_ROLE_SWITCHING=true is unchanged and remains the sanctioned way to run SQL Editor queries as the connection owner: that is an operator's decision, not a failure. effectiveSqlRole reports which of the two actually applied, so the audit line no longer restates the request as the outcome. Asking for the role the session already holds needs no switch and still runs — that is the Studio role picker's default, and it never went near the failing path.

    Not an escalation, and worth saying so: every caller that can pass role already holds owner — rebase.sql is trusted server code whose default is the owner connection, and the EXECUTE_SQL WebSocket verb is admin-gated. This is a correctness and assurance fix, not a patched hole.

  • rebase cloud deploy read its own command word as the app name. The command parsed process.argv.slice(2) permissively and took the first positional as the app to deploy — but that slice removes only node and rebase.js, so the first positional is always the string cloud. Every documented invocation therefore refused itself: rebase cloud deploy --bundle answered This repository declares no app named "cloud". It declares: backend, web. — on any project that did not happen to declare an app called cloud, which is all of them. rebase cloud deploy <app> was unreachable for the same reason: the app argument landed at _[2] and was never read.

    The failure pointed away from itself, which is what made it expensive. The refusal comes from selectDeployApp and names the apps the manifest really declares, so it reads as a fault in the user's rebase.json — and rebase apps list calls the same manifest valid and eligible. The only route through was --bundle-dir, which skips app selection by skipping the build and the static fold with it, so it uploads whatever is already on disk: correct immediately after a rebase build and a stale site at any other moment.

    deploy now parses through parseCloudArgs like the rest of the family, with commandWords: 2, so the command words are dropped from the parsed positionals — a flag written before the group no longer shifts the app either. Being a strict parse, it also refuses a flag nobody declared (--bundel no longer deploys) and a second positional, rather than treating either as the app name. --url joins GLOBAL_CLOUD_FLAGS: resolveCloudUrl honours it on every line in this family, so a strict parse had to accept it. The tests assert the resolved app name directly rather than through a fixture manifest — a fixture that happened to declare an app named cloud would have passed against the broken parse.

  • The published types were any for anyone using modern Node module resolution. Every package here is "type": "module", and tsc writes relative specifiers into .d.ts exactly as the source wrote them — extensionless, because the source is compiled by a bundler. Under moduleResolution: "nodenext" (or "node16") an extensionless relative specifier inside an ESM declaration file is an error, and TypeScript's response is the part that matters: it does not fail at the consumer's import. It resolves the package, discards every declaration it could not follow, and types the whole import any.

    So there was no diagnostic anywhere near the cause. The first thing a consumer saw was an implicit-any error in their own file, pointing at their code, in a project that had done nothing wrong. Measured on @rebasepro/server: bundler resolution saw 170 value exports, nodenext saw zero. It had been that way for the entire life of the packages and was never reported, which is what a silent failure looks like from the outside.

    Fixed by appending the extension the declarations always needed — ./init./init.js, and ./auth./auth/index.js where the target is a directory, resolved against the filesystem rather than guessed. This is not a trade: TypeScript maps a ./x.js specifier onto ./x.d.ts under node10, bundler and nodenext alike, so nothing that worked before stops working. The rewrite runs as a build step in all twenty-one published packages.

    Nothing in this repository could have caught it, and that is the more interesting half. pnpm typecheck, the docs verifier and the template checks all map @rebasepro/* onto source; the API-surface gate reads a single .d.ts in isolation. Every gate looked at something other than the artifact a stranger installs. pnpm check:dts now looks at that: it installs each built package into a throwaway directory by symlink, imports it, and asks the type checker whether the result is any — a question that needs no knowledge of any package's API, and so keeps working as they change. It runs in CI after the build.

  • bundle.mode: url had never worked, and three independent things blocked it. The runtime's fetch looked for a rebase-bundle.json that nothing has ever written — the CLI writes manifest.json — so no unpacked directory was ever recognised as a bundle; the entrypoint exited 1 before @rebasepro/server was imported; and the chart rendered a pod missing what the working path expects. Removing any one of them changed nothing, which is how the mode stayed dead while being documented, validated by the gate, and offered in the values file.

  • The runtime image stripped four packages it never supplied. packages/cli/src/bundle.ts removes five @rebasepro/* packages from a bundle's declared dependencies on the grounds that the image supplies them; docker/entrypoint.mjs supplied one. Custom functions and cron jobs therefore failed to load with Cannot find package, the routes 404'd, and the container reported itself healthy — only a boot-log warning separated a deployment whose code ran from one where none of it did. The entrypoint's dedupe step also only repaired a duplicate and never provided a missing copy, which is the common case.

    The same gap was then live on the fetch path, which does its own stitch after the download and carried a one-package list of its own. All three lists are now checked against each other.

  • The published image could not load its own Postgres driver. The driver's barrel eagerly imported a file watcher used by exactly one --watch branch of a CLI, and the image's hand-maintained dependency list does not include it — so @rebasepro/server-postgres failed to load entirely and every /api/data/* route 500'd behind a green container. Found by a new acceptance run that builds the image from source, brings the documented compose file up, and asserts from outside the container.

  • A static app dropped requests on every rollout, and a killed bundle install left a tree the next boot mistook for a finished one — at a 128Mi limit npm is OOMKilled holding 124 of 156 packages, which is indistinguishable from success unless something records completion.

  • The chart's probes contradicted the runtime, and the api counted every caller as one caller. TRUSTED_PROXY_HOPS was set on the functions unit and never on the api, so a default install ignored X-Forwarded-For and keyed every rate limit to the ingress. The chart also stopped offering migrationJob.mode: push, which the image refuses outright.

  • REBASE_RLS_AUDIT was a topology variable the pod contract did not claim. The runtime reads it to decide which process owns the RLS audit scan, beside REBASE_CRON_SCHEDULER and REBASE_JOB_WORKERS, but it was never added to the list a deployer owns — so a tenant could set it to false and stop their own audit with no error anywhere.

  • Two auth gaps on the WebSocket path. ADMIN_ONLY_TYPES held nine strings while the handler answers ten privileged verbs; the tenth ran SELECT DISTINCT unnest(roles) over the users table ungated.

  • A storage key containing #, % or an encoded slash addressed the wrong object. Every storage URL interpolated the key raw and the server decodes what it receives.

  • Three ways a legal database name generated a file that will not parse. A hyphenated collection slug, a search column with a hyphen, and a table name legal in Postgres each produced a JavaScript identifier that is not one. The same file already defined quote, propKey and member with docblocks explaining exactly this; they were applied in some positions and not others.

  • Seven presentation keys were accepted at boot and then ignored. fixedFilter, includeId, includeEntityLink, widget, sortable, canAddElements and previewProperties were still listed as top-level keys on the four property types they used to live on, so on exactly those types the key was accepted, the migration hint was never reached, and nothing read the value — while the identical key on any other type failed with a helpful message.

  • The history prune could delete below maxEntries. It decided how many rows to drop and which rows to drop in two separate reads, and the prune runs unawaited once per write — so two in flight both counted three rows, both decided to drop one, and the second re-read and took a row that was never surplus. Silent data loss, worst exactly where history matters: a record being written concurrently.

  • Reading a UI preference could crash the whole render. Four call sites guarded localStorage with typeof window !== "undefined" and then used the bare global, which answers "am I in a browser" rather than "can I read storage". Safari in private mode, a blocked cookie policy and a sandboxed iframe all throw on the property access, so a user in that state got a blank admin panel instead of the default theme.

  • rebase cloud billing and resources never printed a price. Both called invoke("pricing/quote", …), and invoke URL-encodes the function name, so the slash became %2F and the route 404'd — every time, since the commands shipped.

  • pg was imported at runtime and declared dev-only, so rebase db pull --anonymize would fail in a published CLI under pnpm's isolated layout while resolving fine in this workspace.

  • The realtime vectorSearch refusal existed and could not fire, clearFilter reset to defaultFilter so a collection defining one could never clear its filters, and the admin decided from whether an answer had arrived rather than from the answer — a save in the first round trip after mount silently took the unconfirmed branch.

  • A dead local proxy is not a TLS problem. rls-check translated every ECONNRESET into advice about sslmode=require, which is right for a managed provider and actively misleading for a loopback proxy that has died.

  • The agent-skills subpath could never reach a skillexports declared a trailing-slash directory export that Node has deprecated and cannot resolve a file through — and a scaffolded project had no schema resource, because two helpers assumed this monorepo's app/ layout.

  • "Cancelled deployment null" was the fix reported as a bug, the auth bootstrap probe swallowed its own failure and answered "already set up" in silence, and rebase dev announced the database twice during start-up.

  • Storage delivery: a replaced image no longer serves its old rendition, private objects are no longer marked public, Content-Length is declared so a player can work out what to seek to, and cacheable responses say so.

  • The snapshot recorder produced snapshots that could not restore, which is why the upgrade gate had decayed to two hand-written files while 0.14, 0.15 and 0.16 shipped without one.

  • frameworkVersion meant two different things — the framework the runtime image ships, and the framework a bundle installed — so cloud status and cloud deployments read as contradicting each other.

  • The schema dialog is no longer downloaded before login, 14 kB of eager JavaScript for a dialog that only opens when somebody edits a collection.

  • Two documentation routes only non-English readers reach were dead, 124 landing strings whose English had moved on are resynced, and --refresh-stale stopped reporting ten keys that were already correct.