Skip to Content

v0.1.94 — 4 Sep 2026

Added — multi-factor authentication with TOTP

Who is affected: Every deployment. Users who enroll a factor change how their requests authenticate.

A user can now enroll a TOTP authenticator app from their user detail page. The flow uses the native MFA support in Supabase Auth, so the server reads the assurance level from the token that GoTrue already issues.

EndpointPurpose
GET /api/auth/mfa/factorsList the factors of the current user.
POST /api/auth/mfa/enrollStart enrollment. Returns the QR code and the secret.
POST /api/auth/mfa/verifyConfirm a code and step the session up.
POST /api/auth/mfa/unenrollRemove a factor.
GET /api/users/[id]/mfa-statusRead the enrollment state of another user. Administrators only.

Each user manages their own factors, because factor management is bound to the session of that user. An administrator can read the state of another user, but cannot enroll or remove a factor for them.

After enrollment, requests need assurance level 2

A user with a verified factor must reach assurance level 2. A request from a level 1 session gets 403:

{ "error": "Multi-factor authentication required", "code": "MFA_REQUIRED", "current_aal": "aal1", "required_aal": "aal2" }

A client can use this to show a step-up prompt instead of a full sign-in. POST /api/auth/login also returns an mfa_required hint, so the login page can ask for the one-time code at once.

A user who enrolls no factor is not affected. The gate applies only after a factor is verified.

Org-wide mandatory MFA

Set MFA_FORCE_ENABLED=true to require MFA for every user. A user who has not enrolled is blocked from every route except the enroll and verify endpoints. The login page shows the enrollment step directly after the password. A forced user cannot remove their own factor. Static tokens are not affected.

docker-compose.yml does not list MFA_FORCE_ENABLED. Add the variable to your own environment. It reads at runtime, so a change does not need a new image. See .env.local.example for the full description.


Added — the activity log records logins, logouts, and your own events

Who is affected: Anyone who reads the audit log, and anyone who wants to record actions that are not database writes.

The audit log covered database create, update, and delete operations only. It now also records login and logout. This includes a sign-in that the browser performs directly against Supabase, which does not pass through the login route.

Log your own events

POST /api/activity/event records an action that is not a database operation:

{ "action": "report_exported", "collection": "reports", "item": "42" }

Any authenticated caller can log an event for themselves. An administrator can log an event for another user, and the log records the real actor in performed_by. The action is free text, so the Action filter on the Activity page is now a text input with the known values as presets.

The server reserves the actions that it writes itself: create, update, delete, login, and logout. A client cannot forge an audit row that looks like a system row. GET /api/revisions excludes events that are not database operations.

Rate limit

The endpoint accepts 60 requests per 60 seconds for each caller. ACTIVITY_EVENT_RATE_LIMIT and ACTIVITY_EVENT_RATE_WINDOW_MS change these values. A caller over the limit gets 429 with the code REQUESTS_EXCEEDED and a Retry-After header.

The server charges the budget directly after authentication, so a flood of bad payloads is also bounded.

Each worker process counts separately, so the true ceiling is the limit multiplied by the number of workers. This bounds abuse. It is not an exact quota.


Fixed — a replaced file no longer shows the old version

Who is affected: Anyone who replaces a file with a new version.

The assets route caches a file for one year. A replacement that keeps the same extension also keeps the same storage path, so every URL stayed the same. Browsers kept the old bytes.

Every asset URL now carries a version taken from the modification time of the file. This covers the preview, the thumbnails in the file list, both Download menu items, the “Open in new tab” and “Download file” links, and the image editor source.

The image editor case risked data loss. The editor re-uploads the image that it loads, so a stale read wrote edits of the old image over the replacement.

The assets route now sends Cache-Control: private. A shared cache no longer holds a copy of a file that permissions protect.


Fixed — long field names no longer break foreign keys and junction tables

Who is affected: Anyone who creates relation fields on collections with long names.

PostgreSQL truncates an identifier at 63 bytes. The server built constraint and index names by joining strings, so two names that agree inside that window produced one identifier.

The two paths failed differently:

  • Junction tables (M2M and M2A). The second constraint failed, and the whole junction table creation rolled back with 400.
  • Many-to-one. This path logs the error and continues, because the column already exists. The request answered 201, the metadata recorded the relation, and the database held no foreign key.

The many-to-one case was silent. GET /api/fields reports the relation from the metadata, so a field looks correct while the database holds no foreign key. Check relation fields on collections with long names.

A shared helper now hashes and truncates any name over the limit, and every path that builds a constraint name uses it. Nine call sites were affected.


Fixed — JSON fields accept typed text again

Who is affected: Anyone who types a JSON value by hand.

A JSON field committed a value only when the text parsed. Every intermediate keystroke of an object is not valid JSON, so the field reverted on each keystroke. It was not possible to type a JSON value by hand.

The code editor and the Filter fields in the Data Model editor now hold the typed text locally. They send the parsed value upward only when it parses. The code editor shows the parse error below the field instead of discarding it.


Fixed — other

  • After a user enrolls or removes a factor, the page reloads. A selective refetch left other components on the page with 403 MFA_REQUIRED answers until a manual refresh.
  • useRelationM2O no longer fails when a field has no physical foreign key. It falls back to the related collection in the field options.
  • The orphaned /account/security page is removed. The MFA controls live on the user detail page.

Changed — Buildpad UI components upgraded to 2.1.0

47 components and 6 library modules moved from the 1.x line to 2.1.0, and buildpad.json moved to schema v3 for content-based update tracking.

Four upstream changes were deliberately not taken. Each one replaces a local decision rather than fixes a defect:

  • The logout route keeps the Bearer-token flow for API clients.
  • The login page keeps its current design.
  • A new upstream collection route collides with the existing one.
  • The upstream middleware files conflict with proxy.ts.

Documentation

  • docs/API_REFERENCE.md gains an Activity API section, which it did not have. It covers List Activity, Purge Activity, and Log Activity Event.
  • The OpenAPI spec documents POST /api/activity/event.
  • The OpenAPI spec no longer describes the API as Directus-compatible or Directus-inspired.