Skip to content

v0.13.0

Choose a tag to compare

@github-actions github-actions released this 04 Aug 00:01
· 1798 commits to main since this release

Breaking

  • rebase.data is gone — use rebase.dataAsAdmin. The server singleton had two names for one accessor, and the shorter one gave no hint of what it does: rebase.data and rebase.dataAsAdmin were the same admin-scoped, RLS-bypassing driver. data is the name a browser client uses for its user-scoped accessor, so the same expression meant "whatever this user may read" on the client and "everything, no policies" on the server. That is a bad thing to have to remember at a call site that reads fine either way.

  • const { data: rows } = await rebase.data.projects.find();

  • const { data: rows } = await rebase.dataAsAdmin.projects.find();

  `RebaseServerClient` now extends `Omit<RebaseClient, "data">`, so this is a compile error rather than a silent privilege. **The property still exists at runtime**, aliasing `dataAsAdmin`, so an untyped JavaScript caller keeps working instead of failing on `undefined` mid-upgrade — the type is the contract, and it is the type that changed.

  Unaffected, because their accessor is genuinely user-scoped and was never deprecated: `context.client.data` in entity callbacks, and `client.data` in a cron handler — both are `RebaseClient`. Also unaffected: `rebase.data` in a **generated SDK** or browser app, which is a different object entirely.

  For user-scoped queries inside a request handler, neither name is right: use the request-scoped driver (`c.var.driver`), which carries the caller's identity so RLS applies.

- **Every other deprecated export is gone too.** Ten more symbols carrying `@deprecated`, removed rather than carried across the 1.0 line. After 1.0 a deprecated export costs a major to remove, so the choice was to drop them now or keep them until 2.0 — and each one was an alias for something already exported under a better name, so keeping them only bought a second way to write the same line.

  | Removed | From | Use instead |
| --- | --- | --- |
| `buildCollection` | `@rebasepro/common` | `defineCollection` |
| `buildProperty` | `@rebasepro/common` | a plain property object |
| `RebaseUser` | `@rebasepro/client` | `User` from `@rebasepro/types` |
| `RebaseTokens` | `@rebasepro/client` | `AuthTokens` from `@rebasepro/types` |
| `UserInfo` | `@rebasepro/app` | `User` from `@rebasepro/types` |
| `Session` | `@rebasepro/app` | `DeviceSession` from `@rebasepro/types` |
| `AuthApiError` | `@rebasepro/app` | `RebaseApiError` from `@rebasepro/types` |
| `DatabaseConnection` | `@rebasepro/server` | `DriverConnection` |
| `createApiKeyRateLimiter` | `@rebasepro/server` | `createDataRateLimiter` |
| `resolveChannelBusConfig` | `@rebasepro/server-postgres` | `resolveChannelBusSetting` |

  Every one is a rename at the import site. The three that are not purely cosmetic:

  `createApiKeyRateLimiter` **skipped every request that was not API-key-authenticated**, which on a normal deployment is nearly all of them — a limiter that reads as protection and passed the traffic you would want limited. `createDataRateLimiter` covers signed-in users and anonymous callers too, and has been the wired default since it landed.

  `buildCollection` / `buildProperty` were **announced as removed in 0.11 and were not** — the note went into the changelog and into the collections docs, and both functions kept shipping from `@rebasepro/common` for two more minors. Anyone who read the note migrated; anyone who did not kept a working build. Now the code matches what was published, and the collections docs no longer name a version the removal did not happen in.

  `DatabaseConnection` is still a name you can import from `@rebasepro/server` — that is the point of removing it. Two different shapes answered to it: a local alias for `DriverConnection`, and the canonical `DatabaseConnection` from `@rebasepro/types` that the package re-exports. Deleting the alias leaves one. If your import resolved to the alias, it was the driver connection and wants `DriverConnection`; if it type-checks unchanged, it was already the canonical one.

- **Default foreign-key column names were mangled for irregular plurals, and are fixed.** `generateForeignKeyName` singularized by chopping a trailing `s` off the snake-cased name, which produced `categorie_id` for `categories`, `addres_id` for `addresses`, never `child_id` (it gave `children_id`), and — because `toSnakeCase` splits on every capital before the chop — `ur_l_id` for `URLs`. It singularizes first now, with the package's real `singular()`, then snake-cases. Two guards: a double-`s` ending is never a plural marker, and a name that singularizes to nothing keeps its original.

  **This changes the default column name for affected relations**, so an existing database has the old name. Boot-ensure migrates it: when a table carries the relation column under its pre-singularization name and not its current one, it emits `ALTER TABLE … RENAME COLUMN "categorie_id" TO "category_id"` rather than `ADD COLUMN`. In Postgres a rename is metadata-only — the values stay put and the column's indexes and constraints travel with it. Adding was the actual bug: it created the new column empty beside the populated old one, every statement succeeded, and the relation then read the empty one.

  If you named the column explicitly, nothing changes — this is only the default.

- **`firestoreToCMSModel` and `cmsToFirestoreModel` are renamed** to `firestoreToRebaseModel` and `rebaseToFirestoreModel` in `@rebasepro/firebase`. They reached consumers through the package barrel's `export *`, so this is a breaking rename with no alias — a shim would keep the word in the API it is being removed from. (`toCmsRow` → `toFlatRow` moves with them, but is internal to `server-postgres`.)

- **MongoDB search matched no field.** `buildSearchConditions` selected searchable columns with `prop?.dataType === "string"`. No property in `@rebasepro/types` has ever had a `dataType` field — a real collection carries `type` — so the loop matched nothing for every collection a user could declare, `orConditions` came back empty, and the fallback turned every search into a `$text` query, which needs a text index and throws `IndexNotFound` without one. The suite passed because its fixtures were written with the same wrong key.

- **`admin.widthPercentage` is gone — use `admin.span`.** Field width is a span over a shared four-column grid now, so two fields line up whatever order they were declared in. A raw percentage could not line up with anything: `33` and `35` produced different widths that looked like a mistake, and nothing snapped to a common edge.

  ```diff
- admin: { widthPercentage: 50 }
+ admin: { span: 2 }

If you are migrating: ≤30 → 1, ≤55 → 2, ≤80 → 3, otherwise 4. Spans are ignored where the form is too narrow for two columns — the side panel, the split pane, a phone — which was also true of percentages.

  • RebaseAuthConfig is gone from @rebasepro/admin-types — use RebaseAuthViewConfig. It was a compatibility alias for a name that collides head-on with RebaseAuthConfig in @rebasepro/server, which configures the backend auth: JWT secrets, OAuth providers, password hooks. Two unrelated shapes under one name, exported from two packages whose whole job is to be imported together.

  • react-router 8, and react-router-dom is gone — react-router 8 deletes the react-router-dom package outright. It was only ever a v6-compatibility shim: everything DOM-specific had already collapsed into react-router itself in v7.

    @rebasepro/admin, app, studio and plugin-ai now peer react-router ^8.3.0. Two imports move, and only one of them is a rename:

  • import { createBrowserRouter, RouterProvider } from "react-router-dom";

  • import { createBrowserRouter } from "react-router";
  • import { RouterProvider } from "react-router/dom";

  Everything else — `useNavigate`, `useLocation`, `useSearchParams`, `useParams`, `Link`, `NavLink`, `Outlet`, `Navigate`, `Route`, `Routes`, `MemoryRouter`, `useBlocker` — is the same name from `react-router`. `RouterProvider` is the exception: it lives in `react-router/dom`.

  The floors underneath move with it, because react-router 8 requires them: `react` and `react-dom` peers go to `>=19.2.7` (were `>=19.0.0`), and `engines.node` on `@rebasepro/admin` and `app` to `>=22.22.0` (was `>=20`). Declaring `>=20` while a mandatory peer needs 22.22 is a promise the package cannot keep.

  This closes GHSA-qwww-vcr4-c8h2, which has no fix on the 7.x line. That advisory is an RSC-mode CSRF bypass and nothing here uses RSC mode, so the vulnerable path was unreachable — but 8.3.0 is the only patched release, and the alternative was staying on a package that no longer exists.

  **If you test with Jest**, budget for this: react-router 8 is ESM-only, and it breaks ts-jest's CommonJS output in two unrelated ways. react-router guards a Vite HMR hook with `import.meta.hot`, which is a *syntax* error in CJS — and ts-jest cannot fix it, because TypeScript emits `import.meta` verbatim under `module: commonjs`. Separately, react-router depends on `cookie-es` 3, which ships `.mjs` only, and TypeScript keys module format off the file extension, so it will not emit CJS for a `.mjs` input whatever `module` says. Every affected suite dies at module load with zero tests run, which reads as a broken config rather than a dependency-format problem. `scripts/jest/react-router-esm-transform.cjs` in this repo handles both and is a reasonable thing to copy. Vitest is unaffected.

- **`rebase cloud deploy --source` on a managed project now needs `--force`.** It ejects the project to a custom container image, and until now it did that on the strength of `--source` alone — read as self-evidently a deliberate eject. It is not. `--source` answers *which source gets built* — this directory, rather than the months-old archive the control plane is holding — and the eject is a side effect of that answer, not something the caller named. Someone reaching for `--source .` because they want their working tree deployed has the right instinct and no reason to expect a runtime change.

  That is how a live project got flipped from `runtime.mode: managed` to `custom`, discovered afterwards from `rebase cloud status` showing `frameworkVersion: null`. The bare form had been refused for the identical reason since the release below; `--source` was the hole left in it. Both forms are now the same rule: a container-image build of a project the platform runs as managed happens only when `--force` says to.

  ```diff
- rebase cloud deploy --source .        # ejected, with a warning
+ rebase cloud deploy --bundle          # stay on managed — almost always what was meant
+ rebase cloud deploy --source . --force  # eject on purpose

The refusal carries code: "managed_project", which is what it already used, so a caller already branching on that code needs no change.

  • rebase db branch keeps the name you give it. Branch names were stripped of everything outside [a-zA-Z0-9_], so rebase db branch create my-feature answered ✓ Branch "myfeature" created — a different name than the one asked for, and the only one list would ever show.

  • $ rebase db branch create my-feature

  • ✓ Branch "myfeature" created successfully.

  • $ rebase db branch create my-feature
  • ✓ Branch "my-feature" created successfully.

  Nothing needed the stripping: every identifier the branch service builds is double-quoted, which is what makes a hyphen safe, and the validator used for `--from` had always accepted hyphens — the two disagreed about the same character class. A name that *cannot* be represented (a space, a dot, a slash) is now refused with `Invalid branch name: only letters, digits, underscores, and hyphens are allowed.` rather than quietly turned into a different one. Names are also capped at 60 characters, because Postgres truncates identifiers past 63 bytes silently, which is the same rename by another route.

  **Branches created before this keep the name they were stored under.** `my-feature` from an older release is recorded as `myfeature`, and that is what `list` shows and what `delete` takes. `delete` and `info` now read the database name from the metadata row instead of re-deriving it, so those older branches drop the database they actually own — re-deriving would have aimed at `rb_my-feature`, which is either nothing or somebody else's database.

### Security

- **`realtime.requireAuth: true` opened the socket instead of closing it.** The connection handler seeds every session with `authenticated: !requireAuth`, so a `requireAuth` that resolves false does not skip a later check — it marks each connecting client as *already authenticated*. Both sockets computed it as

  ```ts
authConfig.requireAuth !== false && !!authConfig.jwtSecret

which ANDs the one setting whose entire purpose is to demand authentication together with the presence of a local secret. On a server that authenticates through an AuthAdapter — or through anything other than auth.jwtSecret — that expression is false, so asking for authentication was what granted it, silently, to everyone who connected.

  • The socket answered the opposite of the HTTP routes. One product decision — "does this server require an authenticated caller?" — with two enforcement points that each computed it. init.ts had resolveRequireAuth: no auth configured means auth is required, an AuthAdapter always means required, and only an explicit requireAuth: false opens it. The socket carried its own copy, and the two disagreed on the case that matters most: with no auth configuration at all, /api/data answered 401 to every read while the socket admitted everyone and served the same rows. Not a weaker gate on the socket — the opposite answer.

    The socket's expression is gone rather than corrected; both enforcement points call resolveRequireAuth, and the tests pin that they agree rather than restating each answer separately.

  • policy.authenticated() admitted anonymous visitors. There were two sentinels for "nobody is signed in". The types, the policy compiler, the JavaScript evaluator and the anonymous-grant linter were all built on ANONYMOUS_USER_ID ('anonymous'); the request path scoped unauthenticated callers as 'anon'. So policy.authenticated() — the sanctioned, documented way to write "signed in", the thing the linter tells you to use — compiled to auth.uid() <> 'anonymous' and was true for every signed-out caller.

    The linter had it exactly backwards, too: it flagged auth.uid() <> 'anon' as a Supabase habit comparing against "a string no caller ever has", when 'anon' was the only spelling that worked.

    This is worse than a default that fails open, because it inverts a rule the author wrote deliberately. A policy that reads as a lockdown was a full grant, and nothing about it looked wrong at any layer — in one deployment it left INSERT on companies, company memberships and jobs open to anonymous callers, and a membership row is a privilege boundary: every anonymous visitor shares one uid, so a single claim is a membership held by the internet.

    The request path now reports ANONYMOUS_USER_ID everywhere it scopes a caller — the JWT and adapter middlewares, the websocket handshake, the realtime service, and the rate limiter's "is this a real user" check. New: ANONYMOUS_USER_IDS (every spelling, newest first) and isAnonymousUid().

    Existing databases are fixed by upgrading the server, without regenerating a single policy: a stored auth.uid() <> 'anonymous' starts excluding anonymous callers the moment they report that id. policy.authenticated() now compiles to NOT IN ('anonymous', 'anon') rather than a single literal, because a policy is written into the database and outlives the server that generated it — one spelling is a hole in whichever direction the versions happen to skew.

    What breaks: a policy that grants to anonymous callers by comparing auth.uid() = 'anon' stops matching. That fails closed, and policy.not(policy.authenticated()) is the supported way to say it.

  • auth.requireAuth: false no longer un-gates cron, logs, backups and the schema editor. That flag answers a question about the data plane — must a caller present a token to read /api/data, or does RLS alone decide? — and false is the answer the server itself recommends at boot to anyone serving a public website from their own backend. It was also, silently, the switch that decided whether the admin surfaces were gated at all.

    So the documented configuration for a public job board or marketing site mounted POST /api/cron/:id/trigger, GET /api/logs and /api/admin/backups for anyone who could reach the service. A single warn per surface at boot was the only notice, and on a --allow-unauthenticated Cloud Run deployment "anyone who can reach the service" means the internet. Anyone whose cron jobs spend a metered third-party quota was paying for that.

    Admin surfaces are now gated whenever there is authentication to gate them with — an AuthAdapter, or a jwtSecret — independent of requireAuth. Whether anonymous callers may read your posts has no bearing on whether they may run your cron jobs.

    If you deploy with requireAuth: false, calls to these routes that previously succeeded unauthenticated now answer 401. They accept what every other admin surface accepts: an admin JWT, the service key, or an rk_ API key created with admin: true — the API-key pre-auth runs ahead of the JWT check, so a scheduler holding an admin key keeps working. Point Cloud Scheduler (or whatever triggers your jobs) at an admin key before upgrading.

    One thing comes back: /api/meta/contract is served again on these deployments. It is only mounted when it can be gated, so a public-data-plane project had been 404ing it, and with it typed client generation from another repository.

  • A backend with no authentication at all now refuses its admin surfaces instead of serving them open. With no AuthAdapter and no auth.jwtSecret there is no credential this server could check a caller against, so it cannot tell an admin from the internet. It used to mount cron, logs, backups and the schema editor anyway, ungated, with one warn per surface at boot as the entire defence.

    They now answer 501 ADMIN_SURFACE_UNAVAILABLE, with a message naming the missing switch. They stay mounted rather than disappearing on purpose: an unexplained 404 on /api/cron reads as a broken path or a failed deploy and gets debugged as one. A token does not change the answer — there is nothing to verify it against.

    This is unlikely to touch you: every scaffolded backend and the bundle runtime configure auth.jwtSecret (the runtime requires it, and auto-generates one in development), so the affected shape is a hand-rolled entrypoint that passes no auth — or one whose JWT_SECRET quietly failed to reach the container, which is precisely the deployment that should not be serving a cron trigger to anonymous callers.

    The data plane is unaffected and still answers 401 there: "show me a token" is a truthful thing to say about /api/data, and a dishonest one about a surface no token can open.

  • Every overrides: entry is a bounded security floor now. An override replaces each transitive consumer's own range, so a bare >=X is not a floor — it is a floating pin that drags in the next major to publish, whatever asked for what.

    One of them had inverted completely: js-yaml: ">=4.2.0 <5" pinned the tree at 4.2.0, which is precisely the version GHSA-52cp-r559-cp3m says to leave (patched in 4.3.0). The pin meant to protect was the thing holding the exposure. uuid had meanwhile floated from its 11.x floor to 14 unnoticed.

    Closes 12 further advisories across brace-expansion (three live majors, so its floors are keyed per-major rather than forcing one on every consumer), js-yaml, react-router, shell-quote and protobufjs. Re-resolving moved no package version, so the bounds themselves are hardening only.

  • @hono/node-server in the scaffolded backend goes from ^1.19.12 to ^2.0.12, closing GHSA-frvp-7c67-39w9 (a serve-static path traversal on Windows via an encoded backslash). The 1.x line has no patch, and @rebasepro/server already peered ^2.0.12 — a new project was being handed an adapter two majors behind the server consuming it.

Fixed

  • customProps in the collection editor was marked deprecated by accident. It carried a @deprecated Superseded by span tag that belonged to widthPercentage and slid onto the next field along when that one was deleted. customProps is live — it is how a custom Field or Preview receives its props, and PropertyFieldBinding reads it on every render. Nothing about the behaviour changed; the tag is gone, so editors stop striking through a supported field and suggesting a replacement that does something else entirely.

  • The eject warning was suppressed exactly where it mattered. The warning above the refusal — the one that exists because ejecting "is not something to discover from a runtime version going blank" — was printed behind !isJsonMode(). JSON mode latches on whenever stdout is not a TTY, so piping the command, or running it from CI or a coding agent, deleted the warning outright, and the deploy's JSON payload carried no equivalent field. The one case with nobody watching the terminal was the one case that said nothing.

    Warnings now go to stderr in every output mode — stderr is not the JSON stream, so it cannot corrupt a parser — and only their formatting depends on the mode. Whether a warning is emitted at all no longer does. The deploy payload gains warnings: [{code, message, hint}] and a denormalised ejectsManagedRuntime boolean for CI to test directly; both fields are always present, so false never has to be told from absent.

  • deploy printed human progress to stdout in JSON mode, ahead of the result object, breaking any parser reading it — the 🚀 Triggering deployment… banner on both the source and managed-bundle paths, the source upload's size line, and on the bundle path the entire build transcript (Building bundle…, the compiler's own log lines, frontend folding, Uploading bundle…). Progress goes through one progress() helper now, which drops it in JSON mode. The rule it settles: progress is not a result and disappears when stdout belongs to the JSON; a warning is not a result either, but goes to stderr and never disappears.

  • A project that had never deployed reported custom · your own image. projects.runtime_mode is a record of what the last deploy made a project, and it carried DEFAULT 'custom' from the migration that added it — which was a true statement about the projects that existed then, and applied to every row created ever after. So a project created seconds ago, which had never built anything, named a container image nobody had built. Most visibly right after the console's create wizard, whose runtime step defaults to Managed and says outright that the choice is intent and writes no mode.

    It also blunted the one signal that catches an accidental eject: custom was equally the resting value of a project nothing had happened to, so it could not distinguish "a source build moved you off managed" from "nothing has happened here yet."

    The column stops defaulting (control plane migration 0040_runtime_mode_undecided), making NULL the honest third state, and rebase cloud status and the console's overview, infrastructure and apps headers all read it as "not deployed yet" rather than inventing an image. Every non-display reader already coerced absent to custom before use, so nothing else changes. Existing rows are deliberately not backfilled — a row saying custom today may be a project that really did ship source, and there is no way to tell those apart from the ones the default flattened.

  • Several concurrent realtime subscriptions hung on a cold page load. A view that opens more than one at once — a Kanban board opens one per column — reported Subscription timed out for all but one of them, thirty seconds in. The socket was healthy: probed directly, six concurrent subscribe_collection frames all answered inside 15ms. The frames were never sent.

    ensureAuthenticated published its in-flight guard only after awaiting the token getter, so every caller arriving in that gap started an attempt of its own — and the message queue flushing on connect delivers exactly that. Each attempt then registered under auth_${Date.now()}, the one request id with no random suffix, so attempts in the same millisecond collided in a Map and only the last survived. One promise settled; the frames waiting behind the others never reached the socket. Client-side navigation skips the path (isAuthenticated is already true), which is why the same view worked on every visit after the first.

  • Kanban drag-and-drop put cards in the wrong place, and did not persist a column change at all. handleDragOver moves the card between columns while the pointer is still down, so looking it up by id at drop time finds it in its destination — the board reported every cross-column drop as a same-column reorder and never wrote the column property. Separately, the drop handler passed every card in every column to onItemsReorder, whose consumer reads it as the target column and takes the moved card's neighbours from it to compute a sort key.

    Also: releasing over a column rather than a card no longer forces an append (which sent a card dropped mid-column to the bottom), dropping onto an empty column no longer aborts the save, and collision detection is closestCorners — the default only reports a target while the dragged rect overlaps one, so a card held over a gap reported nothing.

  • Board sort keys are fractional-indexing keys the database can sort. The library's default base62 output only orders correctly under byte comparison, and the sort is done by Postgres, whose default collation is not byte comparison: under en_US.UTF-8, "aa" sorts before "aC". A board dragged around enough to reach the upper-case digits stopped agreeing with its own keys. Keys are base36 and single case now. Existing keys no longer validate, which is what surfaces the board's Initialize bar — and that bar works now: it only ever looked for a null order value, so a column full of unusable-but-present values offered a button that updated nothing and never went away.

  • Kanban columns could not be scrolled. A flex-1 item defaults to min-height: auto, so the view holding the board grew to the board's full content height — 1230px inside an 883px area — and the ancestor's overflow-hidden cut off the rest. Each column had a working scroller that never reached its limit.

  • A failed column subscription rendered as an empty column. Entities cleared, no error surfaced, "No items" under a header still counting eleven of them. It falls back to a one-shot read, reports a failure only if that fails too, and no longer waits out the client's full 30-second watchdog before painting anything.

  • Date previews required a Date instance, so every audit column in every revision-history entry rendered as a red "Unexpected value" box. History is raw API payload, where a timestamp is still the string Postgres sent. Any value that unambiguously names a date is accepted now.

  • Chips lost three quarters of their palette. A cleanup flattened CHIP_COLORS from four tones per hue to one, which left every colorScheme="blueDark" resolving to undefined — a chip with a colour in its config rendering with no colour at all — and made seeded chips pick from ten schemes, so a five-value enum routinely drew the same background three times. The tones are generated from a per-hue table now, and ChipColorKey is a real union rather than keyof Record<string, …>, which is why none of it was a type error.

  • The Firebase example compiles again. It had not built since the property-options split, which made url a statement about the data — it feeds format: "uri" into the OpenAPI contract — and moved presentation to admin.urlPreview. The example's admin: { url: "image" } had both halves in the wrong place, and expanded likewise belongs in the admin block.

Added

  • User is exported from @rebasepro/client and @rebasepro/app. The removals above tell a caller to import User from @rebasepro/types, which was not an instruction a browser app could follow: it installs the client (or app) package alone, and @rebasepro/types is that package's dependency, not a specifier resolvable from its own project. So the deprecated aliases were removable in a monorepo and stranding anywhere else. User now sits beside RebaseSession, AuthTokens and DeviceSession, which were already re-exported for exactly this reason.

  • The entity form has a layout. It had exactly one — a single centred column of full-width cards in declaration order — and one escape hatch, formView.Builder, which replaces the whole form. Nothing in between.

    There is now a four-column grid, titled sections that collapse, and a metadata rail for the fields that describe a record rather than constitute it. All of it is derived by default: a collection that configures nothing gets a two-column form, its id and audit timestamps in the rail, long text and arrays full width, short enums and booleans narrow. admin.form is for when the derived answer is wrong. See Form Layout.

    On the demo's products form this is 2932px of scroll down to 1587px, and 219px of dead space above the first field down to 24px.

  • The record's identity and its actions live in persistent chrome. The title, the id and the Save/Discard buttons used to sit inside the scrolling form, so the moment you touched the wheel nothing on screen said which record you were editing. They are in a bar above it now, which is also what let the 320px footer holding two buttons go away entirely.

  • JSON and revision history moved out of the tab strip and into a record inspector. They were the first two tabs — icon-only, unlabelled, ahead of the record you opened the page to edit. They are developer tools, so they sit behind the overflow () menu and open in a panel beside the form; the tab strip is for destinations. Old #json / #history URLs open the inspector on the pane they name.

  • Two gates for things that were rotting silently. pnpm check:examples typechecks examples/*, which were in no pipeline and no root script — pnpm build covers ./packages/* and ./app only — which is why the Firebase example above stayed broken for weeks. They resolve @rebasepro/* to built output the way an installing user does, rather than to source the way pnpm typecheck does, so they catch a class of drift the source-resolving gate structurally cannot see.

    pnpm check:generated regenerates the committed website artifacts (llms.txt, sitemap.md, the changelog mirror) and fails on a diff. llms.txt had been sitting a commit behind the docs it summarises.