Skip to Content

v0.1.92 — 19 Aug 2026

This release contains security fixes — a row-level permission bypass on relational-only updates, a self-service privilege escalation, and two unauthenticated schema/bootstrap endpoints. Upgrading is recommended for all deployments.

Security — relational-only updates now enforce the row-level permission filter

Who is affected: Every deployment using row-scoped update permissions (item filters).

The row-level permission filter — and the 403 that backs it — only ran on the branch that sends the actual UPDATE. Relational data is processed outside that branch, so a payload made up entirely of relational fields carried no row-level check on any collection: a non-admin whose update permission is scoped by a filter such as { owner: { _eq: "$CURRENT_USER" } } could mutate relations on rows belonging to anyone by choosing a payload shape with no scalar columns. PATCH /api/items/:collection/:id was the short path to it.

Relational-only updates by non-admins now read the requested primary keys back through the same filter the UPDATE would have carried; keys that don’t survive answer the same 403 the scalar path raises, and a failed authorization read reports every key as unauthorized rather than failing open.


Security — non-admin self-PATCH can no longer grant itself admin

Who is affected: Every deployment with non-admin users.

PATCH /api/users/[id] lets a user update their own record without a permission check and runs the service with admin accountability — which disarmed every downstream gate on that path. The route also forwards relational alias fields, two of which decide privilege: roles and policies. A non-admin could therefore self-assign an admin role or policy — a global admin grant, effective immediately. The generic PATCH /api/items/daas_users/:id delegates to the same handler, so it inherited the hole.

The non-admin self path now refuses privilege-bearing fields — a deny-list plus a blanket refusal of every relational alias on daas_users, so operator-added custom columns remain ordinary self-service while any new relation is refused by default. Two fail-opens in the same handler (a swallowed field-metadata read error and an unfiltered-payload fallback) now throw instead of letting a roles entry slip through.


Security — two endpoints answered anonymous callers with real data

Who is affected: Every deployment reachable by untrusted networks.

  • GET /api/collections/[name]/fields returned the full column layout — names, types, defaults, primary-key flags — of any table in the public schema to unauthenticated callers. It now requires authentication (it remains available to non-admin policy managers, who legitimately reach it from the permissions editor).
  • GET /api/setup/directus-collections, one-time bootstrap tooling with no in-app caller, returned real daas_collections rows. It is now admin-only.

Both predate the recent middleware changes; anonymous requests now answer 401 with no payload.


Fixed — missing_uri_mode='reject' is now enforced on writes and relational paths

Who is affected: Multi-tenant deployments using reject-mode scope configs.

Reject-mode collections refused scope-less reads and creates with 403 MISSING_SCOPE, but other operations slipped through:

  • Scope-less updates and deletes fell through to the scope filter and answered 404 — and rows with a NULL scope column (left over from a strict-mode era) stayed mutable at root.
  • Nested relational writes (O2M, M2A, M2M, A2O payloads) stamped the scope column with no reject check at all.
  • Embedded reads of a 'down'-mode reject collection at root scope leaked every row.

All three paths now fail closed: updates/deletes throw 403 MISSING_SCOPE before any write work, every relational write entry runs a shared scope guard, and embedded reads of reject collections at root match nothing.

daas_user_roles and daas_access are seeded 'strict', so system role/access flows are unaffected — reject mode is opt-in per collection. If a client relied on the old 404 for scope-less updates/deletes on reject collections, it will now receive 403 MISSING_SCOPE.


Fixed — every daas_users write path is paired with its Supabase Auth account

Who is affected: Anyone writing to daas_users outside /api/users (generic items API, upsert, import — including Platform Studio’s rebuilt Users page).

Only 2 of the 8 ways to write daas_users kept the profile row and its Supabase Auth account in step. The rest reached the table directly — creating profiles with no working login, leaving orphaned auth accounts that still authenticated after their profile was deleted, writing password as plaintext into the dead DB column, and desyncing email from the credential store. Now:

  • POST, single-item PATCH, and single-item DELETE on /api/items/daas_users delegate to the auth-aware /api/users handlers (account creation, password and duplicate-email validation, credential sync); password length is validated on create
  • Email changes sync to Supabase Auth, so logging in with the new address works immediately
  • Batch delete removes each auth account after the rows are gone
  • Batch update refuses only password and email — the two fields that cannot be written correctly there; everything else stays an ordinary admin capability
  • PUT (upsert) and POST /api/utils/import for daas_users answer 400 naming /api/users as the endpoint to use

Changed — Users detail/create page rebuilt on CollectionForm

The /users detail and create pages moved off the legacy form fork onto the same CollectionForm + VForm stack the rest of Platform Studio uses, with success toasts on create and update. This also fixes the legacy page’s role-staging bug: adding a role via Add Existing never marked the form dirty, so Save stayed disabled even though the role was visibly staged. The migration ships with new UI test coverage for the delete flow, the policies manager, and role assignment.


Fixed — data-model choice authoring matches the field’s type

Who is affected: Anyone configuring dropdown/radio/checkbox choices in the Data Model editor.

  • The choices editor always saved choice values as strings, whatever the field’s column type — the root cause of the stringify-collision crashes patched in the selection components in v0.1.91. Numeric fields now get a number input emitting real numbers; boolean fields a True/False select.
  • Per-choice icon, color, and disabled flags can now be authored — the interfaces already rendered them, there was just no UI to set them.
  • Collection lists now resolve select/radio/multi-select values to their configured choice labels instead of raw stored values (or a JSON blob for arrays).
  • Saving a choice interface with zero choices is blocked with an explanatory error instead of silently producing a permanently empty field.

Fixed — /api/roles supports filter query params

Bracket notation (filter[name][_eq]=value) and JSON filter= were silently ignored on /api/roles while the same parameters worked on /api/items. The route now parses filters identically to the items API; the OpenAPI spec documents it, and the scope collection-config schemas were corrected to the real enum values (missing_uri_mode: strict/reject, inheritance_mode: exact/down).


Added — server-side module-access enforcement helper

Who is affected: Developers gating custom routes or Server Components with Module-Level Access keys.

The client-side usePermissions().hasModuleAccess() from v0.1.90 only decides what to render; lib/module-access/enforce.ts is the matching security boundary for API route handlers and Server Components:

import { enforceModuleAccess } from '@/lib/module-access/enforce'; await enforceModuleAccess('reports:export'); // throws 403 ModuleAccessError await enforcePermission({ collection: 'reports', action: 'read' });

getModuleAccess() returns the caller’s OR-merged key map (admins hold every key), and hasModuleAccess(key | keys) answers a boolean without throwing. The client side gains the matching registry pieces: ModuleAccessKeysService (CRUD for daas_module_access_keys) and the useModuleAccessKeys hook.


Changed — three more rounds of Buildpad UI interface fixes (ui PRs #123–#141)

The shared @buildpad/ui-interfaces components picked up the upstream fixes for the mass-unlink and off-page replace-payload defects (previously patched locally), plus: a ListM2A paginated/search replace-mode data-loss fix (new-item detection now uses the canonical sentinel set '+'/'%2B'/'new', with ListO2M brought onto the same shared helper), allowOther ref bugs in the dropdowns, M2M junction PK aliasing, readonly handled as a real state across all leaf interfaces, checkbox-tree cycle guard, per-choice icon/color rendering, ListM2M template-field fetch and select-existing labels, CollectionForm seeding create-mode defaults from the schema, input autofocus, and the conceal-field null-handling contract shared by password/token fields.


Performance — fewer Supabase round trips on writes and API auth

  • Item create/update made three separate field-metadata fetches per request plus a columns RPC and a relations lookup; the relation writer now fetches the collection’s field rows once and shares them across the write pipeline. Measured on a production build: create 11 → ~7–9, update 11 → 7 Supabase calls per request.
  • The middleware no longer calls auth.getUser() for API requests that authenticate themselves in the route handler — cookie-authenticated API reads drop from 3 → 2 calls. Page protection and CORS behavior are unchanged.
  • Saving a parent with many existing children issued one SELECT per child just to decide whether its foreign key was already correct; those checks are now batched into chunked IN (...) queries.

Testing — Playwright suite made reliable

Full-suite hard failures went from 1192 to 36: BASE_URL is honored everywhere (~120 hardcoded localhost:3000 URLs removed), logout specs use dedicated throwaway accounts instead of revoking the shared admin session, fixture-corrupting specs and an auth-setup UUID collision were fixed, order-dependent files are serialized, and new scripts maintain fixture integrity (scripts/ensure-ui-fixtures.mjs, scripts/cleanup-test-pollution.mjs — dry-run by default).

Last updated on