Release Notes
v0.1.80 — 12 Jun 2026
Bug Fix — Content Detail forms now handle numeric, non-PK M2O, and input-code values correctly
Who is affected: Users editing records in Platform Studio Content Detail
pages where fields include numeric inputs, M2O relations keyed by non-primary
columns (for example resource_uri), or input-code interfaces for JSON/CSV.
What was wrong
- Numeric values could be treated as string-only in parts of the input mapping.
- M2O dropdown resolution assumed primary-key based lookup and could fail for non-PK target columns.
input-codehandling for non-string/structured values was inconsistent, which could produce incorrect payload values.
What changed
- Input mapping now preserves numeric behavior instead of forcing string-only assumptions.
- M2O lookup/selection now supports filter-based resolution for non-PK targets.
input-codenow normalizes non-string input and emits parsed JSON for structured values where applicable.
Before / After
| Scenario | Before this fix | After this fix |
|---|---|---|
| Numeric field editing in Content Detail | ❌ Could be coerced through string-only mapping | ✅ Correct numeric-aware handling |
| M2O keyed by non-PK column | ❌ Selection/lookup could fail | ✅ Filter-based non-PK lookup works |
Structured JSON in input-code | ❌ Inconsistent emitted values | ✅ Normalized + correctly parsed payload |
v0.1.79 — 12 Jun 2026
Bug Fix — Cookie-based SSR auth now works when internal and public Supabase URLs differ
Who is affected: Self-hosted deployments that set both
SUPABASE_INTERNAL_URL and NEXT_PUBLIC_SUPABASE_URL to different values
(for example, internal service DNS for server code and public HTTPS URL for
browser access).
What was wrong
After introducing SUPABASE_INTERNAL_URL, server-side requests could fail to
pick up browser auth cookies in some environments. Sessions appeared valid in
the browser but were not consistently recognized by middleware/SSR code.
Root cause
Supabase SSR derives the cookie name from the URL passed to
createServerClient. When server code used SUPABASE_INTERNAL_URL, that
derived cookie name could differ from the browser cookie name derived from
NEXT_PUBLIC_SUPABASE_URL.
What changed
Server-side clients now explicitly set the cookie name derived from
NEXT_PUBLIC_SUPABASE_URL via cookieOptions.name, while still connecting to
Supabase through SUPABASE_INTERNAL_URL for server-to-server traffic.
Before / After
| Scenario | Before this fix | After this fix |
|---|---|---|
| Internal URL differs from public URL | ❌ Cookie-based SSR auth could fail intermittently | ✅ Cookie-based SSR auth resolves consistently |
| Server network path | ✅ Internal URL used | ✅ Internal URL still used |
| Browser auth cookie compatibility | ❌ Could mismatch | ✅ Explicitly aligned |
v0.1.78 — 11 Jun 2026
Bug Fix — Added SUPABASE_INTERNAL_URL support for server-side Supabase connections
Who is affected: Operators running BuildPad in Docker/Kubernetes or private network topologies where app servers should reach Supabase over an internal hostname instead of the public endpoint.
What was wrong
Server-side code paths (API routes, middleware, session helpers, and auth
flows) always used NEXT_PUBLIC_SUPABASE_URL, which is browser-facing and may
route through a public load balancer.
What changed
SUPABASE_INTERNAL_URL is now supported as a server-only override across
server-side Supabase and auth paths. When set, server code uses the internal
URL; when absent, behavior falls back to NEXT_PUBLIC_SUPABASE_URL.
Before / After
| Scenario | Before this fix | After this fix |
|---|---|---|
| API route / middleware Supabase target | ❌ Always public URL | ✅ Uses SUPABASE_INTERNAL_URL when configured |
| Browser Supabase target | ✅ Public URL | ✅ Still public URL |
| Self-hosted internal network optimization | ❌ Not possible without code changes | ✅ Supported via env config |
Bug Fix — Docs site hard reload no longer breaks due to early widget script execution
Who is affected: Users browsing the docs site who encountered broken hard reload behavior while menu navigation still worked.
What was wrong
The feedback widget script could execute before hydration and mutate the DOM that React expected, causing hard reload instability.
What changed
The widget script loading strategy was changed from beforeInteractive to
lazyOnload, so script execution happens after hydration.
v0.1.77 — 28 May 2026
Bug Fix — Nested M2M relations now resolve correctly at arbitrary depth
Who is affected: Anyone whose API queries traverse two or more levels of M2M relations — for example, fetching a user’s roles and then the policies assigned to each role in a single request.
What was wrong
Querying through two consecutive M2M relations returned an empty array for the second level. For example:
GET /api/users/{id}?fields=id,roles.role_id.policies.*Returned the correct junction rows for roles, but every nested role_id
object had policies: [] — the second-level M2M was silently dropped.
The same failure affected M2O → M2M chains. Querying:
GET /api/items/daas_user_roles?fields=id,role_id.policies.*Returned a 500 with a PostgREST parse error because the query builder generated
role_id(id,policies(*)), treating policies as if it were a real FK column
in the database.
Root cause
Two independent bugs combined to produce the failure:
Bug 1 — Virtual alias fields on system collections were invisible.
The query builder discovered alias fields (like policies on daas_roles) by
looking them up in daas_fields. For system collections — daas_roles,
daas_users, etc. — that table has no rows. The policies alias exists only
in daas_relations (one_field = 'policies'). When daas_fields came up
empty, the builder fell back to a raw PostgREST embedded select (policies(*))
which PostgREST rejected because there is no FK column by that name.
Bug 2 — Admin M2O was never post-processed.
For admin users, M2O fields were fetched via PostgREST embedded joins in the
SELECT string. PostgREST has no knowledge of virtual alias relations, so nested
alias fields on the related collection were silently dropped. The
processM2OResults post-processing path — which correctly handles virtual
alias fields — was only used for non-admin users.
What changed
- The query builder now consults
daas_relationsdirectly for any nested field that isn’t found indaas_fields, so virtual alias fields on system collections are correctly identified and routed through the relation pipeline. - Admin M2O fields with nested alias relations are now processed through
processM2OResultsinstead of PostgREST embedding, consistent with how non-admin users have always been handled. - M2M and M2O post-processors now recursively resolve nested relations on related-collection items, enabling arbitrarily deep chains (M2M → M2M, M2O → M2M → M2O, etc.).
Before / After
| Query pattern | Before this fix | After this fix |
|---|---|---|
fields=roles.role_id.policies.* (M2M → M2M) | ❌ policies silently empty | ✅ Junction rows returned |
fields=roles.role_id.policies.policy.* (expand FK at level 2) | ❌ Empty | ✅ Policy objects returned |
fields=role_id.policies.* on junction table (M2O → M2M) | ❌ 500 parse error | ✅ Junction rows returned |
fields=*.*.* (wildcard three levels deep) | ❌ Level-2 M2M empty | ✅ Full expansion |
| Non-admin with permissions on all collections in chain | ❌ Same failures | ✅ Correct, with permission enforcement |
Permission enforcement is unchanged
The fix does not relax any access controls. Non-admin users must still hold
read permission on every collection in the chain — the junction table, the
related collection, and any further-nested collection. A missing permission at
any level returns [] for that field rather than an error, matching the
behaviour of all other relation types.
v0.1.76 — 25 May 2026
Bug Fix — Cron jobs triggered from extensions now execute the current code
Who is affected: Anyone whose runtime extensions call
services.cron.trigger() or context.services.cron.trigger() to fire a cron
job.
What was wrong
Cron jobs triggered from an extension ran with the code that was compiled when
the server last started. Edits made to the cron job after startup were ignored
by extension-triggered runs, even hours later. Running the same job via the UI
“Run Now” button or the MCP run_now action always used the current code.
Root cause
Next.js splits server-side code into multiple webpack chunks at build time. The
CronManager module was evaluated separately in the startup chunk (which
initialises the scheduler) and in the API-route chunk (which handles saves and
manual triggers). This produced two independent CronManager instances, each
with its own compiled-handler cache.
When a cron job was saved, the save API called reloadJob() on the
API-route instance — updating that cache — while the startup instance
(used by the scheduler and by services.cron.trigger()) retained the old
compiled code from startup.
The RuntimeExtensionManager (for extensions) and the event emitter already
used a globalThis singleton pattern specifically to prevent this. CronManager
was missing the same treatment.
What changed
CronManager is now stored on globalThis, matching the pattern already used
by RuntimeExtensionManager. All execution paths — scheduled ticks, UI/MCP
triggers, and extension-triggered runs — share the same instance, so saving a
cron job always updates the cache used by every caller.
Before / After
| Trigger path | Before this fix | After this fix |
|---|---|---|
| Scheduled tick | ✅ Current code (startup cache) | ✅ Current code |
UI “Run Now” / MCP run_now | ✅ Current code (API cache) | ✅ Current code |
services.cron.trigger() from extension | ❌ Startup-time code (stale) | ✅ Current code |
Improvement — Timeout field now accepts any value above the minimum
Affected pages: Extension detail, Cron job detail.
The Timeout (ms) input previously enforced a hard upper limit in the UI
(30 s for extensions, 5 min for cron jobs). This cap has been removed.
The field now accepts any value above its minimum — 100 ms for extensions,
1 000 ms for cron — so operators can configure whatever execution window their
workloads require.
v0.1.75 — 22 May 2026
Bug Fix — Filter extensions on daas_users now correctly block user creation via POST /api/users
Affected endpoint: POST /api/users
Who is affected: Anyone who attached a filter type extension to the
daas_users.items.create event and expected a rejected filter to prevent the
user from being created.
What was wrong
When a filter extension threw an error (e.g. new HookError('Not allowed', 400))
the API returned a 400 response as expected — but the user was still created.
Subsequent GET /api/users calls would show the new user, and their Supabase
Auth entry was fully active.
Root cause
The endpoint called supabase.auth.admin.createUser() before running the
filter. Supabase’s auth API fires a database trigger on success that immediately
inserts the user into daas_users. By the time the filter ran, the creation
was already committed and could not be reversed.
What changed
The handler now runs the filter first — before any write to Auth or the
database. If the filter rejects the operation, nothing is created. Only after
the filter approves the payload does the handler call auth.admin.createUser(),
stamp the returned id onto the record, and persist it.
Before / After
| Scenario | Before this fix | After this fix |
|---|---|---|
Filter throws HookError(400) | ❌ 400 returned but user created | ✅ 400 returned, user not created |
| Filter modifies email / name | ❌ Original values used for auth user | ✅ Filter-modified values used |
| Filter approves payload | ✅ User created | ✅ User created |
Bug Fix — POST /api/utils/import/[collection] now enforces permissions and runs extension filters
Affected endpoint: POST /api/utils/import/{collection}
Who is affected:
- Anyone who attached a
filteroractionextension toitems.createon a collection — those extensions were silently skipped for all imported records. - Anyone who relied on collection-level permission policies to restrict write access — any authenticated user could import data into any collection.
What was wrong
Extensions skipped: The endpoint wrote records directly to the database using a raw upsert, bypassing the entire extension and hook pipeline. Filter extensions, action hooks, and validation rules were never called.
No permission check: The endpoint only verified that the caller was
authenticated. It did not check whether the caller had create permission on
the target collection.
What changed
- All writes now go through
ItemsService, which runs the full filter and hook pipeline. A filter extension that blocksitems.createwill now also block import, and the per-record error is captured in theerrorsarray in the response. - Non-admin users must have
createpermission on the target collection; permission errors return 403. - Scope (
X-Resource-Uri) is respected, consistent with every other write endpoint.
Before / After
| Scenario | Before this fix | After this fix |
|---|---|---|
Filter extension on items.create during import | ❌ Silently skipped | ✅ Executed per record |
Non-admin without create permission imports data | ❌ Allowed (auth check only) | ✅ 403 Forbidden |
| Import by admin | ✅ Worked | ✅ Still works |
| Extension error for one batch | ❌ N/A (bypassed) | ✅ Captured in errors[]; other batches continue |
Behaviour change
If you were importing data as a non-admin user without an explicit create
permission policy on the collection, those imports will now return 403. Add a
create permission entry for the relevant role to restore access.
Additionally, the updated field has been removed from the response body —
the response now contains inserted and total only.
v0.1.74 — 20 May 2026
v0.1.74 — 20 May 2026
Improvement — Platform Studio is now usable on mobile
Who is affected: Anyone using Platform Studio on a phone or small tablet.
What was wrong
On mobile widths the header wrapped and overflowed, the sidebar was stuck in icon-only mode inside the burger drawer (so navigation labels weren’t visible), the Content module had no way to switch collections without going back to the data-model page, and wide tables could not be horizontally scrolled.
What changed
- The header now stays on a single row at every viewport width.
- Opening the burger drawer on mobile shows the fully expanded sidebar with all navigation labels visible.
- The drawer auto-closes on navigation, so it’s not in the way after picking a page.
- The Content module has its own mobile collection drawer, opened from a burger icon next to the page title.
- Wide tables scroll horizontally, and the pagination footer is sticky so it stays visible while you scroll.
Before / After
| Scenario | Before | After |
|---|---|---|
| Studio header on a phone | ❌ Wraps / overflows | ✅ Single row |
| Burger drawer sidebar | ❌ Icon-only | ✅ Fully expanded |
| Drawer after navigating | ❌ Stays open | ✅ Auto-closes |
| Switching collections in Content (mobile) | ❌ No entry point | ✅ Burger drawer |
| Wide tables on mobile | ❌ Clipped / unreadable | ✅ Horizontal scroll + sticky pagination |
Improvement — Dark mode is now consistent and legible
Who is affected: Anyone using Platform Studio in dark mode.
What was wrong
Header action icons — the burger toggle, sidebar collapse button, colour scheme toggle, and scope switcher — rendered with very low contrast and were effectively invisible. The data-model field-layout cards used raw greys that inverted in dark mode, producing washed-out cards. The Buildpad logo did not swap for a dark background.
What changed
- All header action icons now use a
header-subtlestyle that wins against Mantine’s inline colour tokens, so they have proper contrast in both light and dark mode. - Field-layout cards in the data-model UI now use semantic surface tokens and render correctly in dark mode.
- The Buildpad logo automatically swaps to its white variant in dark mode.
Before / After
| Element | Before (dark mode) | After (dark mode) |
|---|---|---|
| Burger / sidebar / scope / theme toggle | ❌ Near-invisible | ✅ Properly visible |
| Field-layout cards | ❌ Washed-out / inverted greys | ✅ Correct surface colours |
| Buildpad logo | ❌ Dark logo on dark background | ✅ Light logo variant |
Improvement — Datetime picker arrows render at the correct size
Affected fields: All datetime fields in the Content module.
The month-navigation arrows in the datetime picker are no longer oversized.
Documentation — Major accuracy pass across every public docs section
Who is affected: Anyone reading the public documentation site.
Every section of the docs has been audited against the codebase. Routes, type signatures, status values, config keys, environment variable names, and migration filenames have all been verified against the actual implementation and corrected where they had drifted.
Sections updated
- Connect API — authentication, files, filter-rules, items, schema
- Platform Studio — overview, data-model, content, users, roles & policies, files, settings
- Automate — workflows, extensions, cron jobs, custom services, event hooks, versioning, logs
- AI / MCP — available tools, connecting clients, overview
- Self-hosting — API reference, setup guide, environment variables, runtime env whitelisting, CORS, Docker, AWS, security, migrations
- Tutorials — headless blog, role-based access, workflow automation
Highlights worth calling out:
- MCP client setup (macOS): the Claude Desktop config path has been
corrected to
~/Library/Application Support/Claude/.... The previous~/.claude/...path was wrong on macOS. - MCP client setup (Cursor): the config key in
mcp.jsonismcpServers— the previousmcp.serverswould silently fail to register the server. - New MCP clients documented: Claude (Web) via custom connectors,
VS Code / GitHub Copilot (
.vscode/mcp.json), and Cline. - Self-hosting API reference now documents 8 additional endpoint groups
that previously existed in the codebase but weren’t listed in the docs:
activity,cron,logs,revisions,services,scope,workflow, andhealth. - Setup guide: the RLS helper function is
apply_rls_to_collection(notenable_rls_for_table); the admin-user script is a shell script that requires CLI args;supabase linkis required beforedb push. - Environment variables: the SMTP block has been corrected — the actual
variable names are
SMTP_PASSWORD,SMTP_FROM_EMAIL,SMTP_FROM_NAME,SMTP_ENABLED, andSMTP_IGNORE_TLS.NEXT_PUBLIC_APP_NAMEhas been removed because it does not exist. - Platform Studio → Settings has been rewritten: project name and URL
fields do not exist in the General tab — the only field there is
activity_retention_days. A previously undocumented CORS tab has been added. - Platform Studio → Users: access tokens are stored in plain text (not hashed), and inviting a user does not automatically send an email. These were both documented incorrectly before.
- Workflow automation tutorial has been rewritten end to end against the
actual workflow schema. Previous versions described a flat
transitionsarray with role names; the real schema usesinitial_stateplus per-state commands referencing policy UUIDs. The built-in “Send Email” action shown previously did not exist — the tutorial now demonstrates the real custom-extension pattern. The transition endpoint isPOST /api/workflow/transitionwith{ workflowInstanceId, commandName }; the history endpoint is/api/workflow-instances/:id/history.
Documentation site — Feedback widget on every page
The Buildpad feedback widget is now embedded in the docs site. You can leave feedback on any docs page directly, without leaving the site.
v0.1.73 — 15 May 2026
Bug Fix — MCP-created collections now have RLS and permission policies applied
Who is affected: Anyone who used the MCP collections tool to create
collections. All such collections were created without Row Level Security,
making their data accessible to any authenticated Supabase session regardless
of the DaaS permission model.
What was wrong
The MCP tool executed CREATE TABLE but skipped the two post-creation steps
that the UI has always performed:
change_table_owner— transfers table ownership so theSECURITY DEFINERfunction can create policies.apply_rls_to_collection— enables RLS and creates the full set of DaaS access policies (admin bypass, permission-based CRUD, and optional self-access for tables with auser_idcolumn).
Additionally, the resource_uri performance index was not created, degrading
query speed on every scoped API call for affected collections.
What changed
The MCP tool now performs all three steps immediately after CREATE TABLE,
matching the UI behaviour exactly. New collections created via MCP are
indistinguishable from those created via the UI with respect to RLS.
Upgrading — existing collections
A backfill migration is included:
supabase/migrations/20260508000001_backfill_rls_mcp_collections.sql
It automatically applies RLS and the standard policies to every collection
registered in daas_collections that currently has RLS disabled. Run it via
supabase db push (or your normal migration pipeline).
Action required
If you have collections created via MCP, run the backfill migration before your next production deployment to close the permission gap.
supabase db pushBefore / After
| Property | Before this fix | After this fix |
|---|---|---|
| RLS enabled on MCP-created table | ❌ Off | ✅ On |
| Admin bypass policy | ❌ Missing | ✅ Created |
| Permission-based policies (read/create/update/delete) | ❌ Missing | ✅ Created |
Self-access policies (tables with user_id) | ❌ Missing | ✅ Created |
resource_uri index | ❌ Missing | ✅ Created |
AWS RDS environments
On RDS there is no true superuser, so the change_table_owner step may log a
warning: must be able to SET ROLE "postgres". This is harmless —
apply_rls_to_collection is independently SECURITY DEFINER and has
sufficient privileges to enable RLS and create policies on its own.
Bug Fix — services.custom(name, { elevated: true }) now correctly elevates permissions in nested services
Who is affected: Anyone who calls services.custom('name', { elevated: true }) from a
runtime extension or cron job, or anyone who experienced Custom service "name" not found or inactive errors from extensions in production.
What was wrong
Issue 1 — opts ignored: Passing { elevated: true } as the second argument to
services.custom() had no effect. The nested custom service always ran with the calling user’s
original permissions, so restricted writes inside the service were still blocked.
Issue 2 — production cache miss: In production Next.js builds, calling services.custom()
from an extension could throw Custom service "name" not found or inactive even when the
service was active. This happened because Next.js evaluates module singletons in separate
chunk contexts — after a service was updated via the API, the extension runtime’s cache was
empty and the lookup failed.
What changed
{ elevated: true } is now properly forwarded into the nested service’s accountability,
bypassing its permission checks while the calling user is still recorded in the audit trail
(user_created/user_updated and daas_activity).
The cache-miss issue is resolved with a database fallback: on a cache miss, the runtime now
queries daas_custom_services directly and compiles the service on demand.
Before / After
| Scenario | Before this fix | After this fix |
|---|---|---|
services.custom('svc', { elevated: true }) from extension | ❌ opts ignored — policies still enforced | ✅ admin acc propagated — permission checks bypassed |
services.custom() from extension after service update (production) | ❌ May throw “not found or inactive” | ✅ Falls back to DB lookup and compiles on demand |
Triggering user in user_created / daas_activity when elevated | ✅ Preserved | ✅ Still preserved |
v0.1.72 — 14 May 2026
Bug Fix — Logout now properly invalidates Bearer token sessions
Affected endpoint: POST /api/auth/logout
Who is affected: Any API client that authenticates with a Bearer token from
POST /api/auth/login and relies on logout to revoke access.
What was wrong
Calling POST /api/auth/logout with a Bearer token had no effect. The token
stayed fully usable — requests to GET /api/auth/user, GET /api/roles, and
all other protected endpoints returned 200 after logout as if the logout
never happened.
What changed
The session is now revoked server-side immediately. After a successful logout, the token is rejected across all endpoints — even before its natural JWT expiry time.
Action required for API clients
You must include the access token in the Authorization header when calling
logout. Without it the server has no session to revoke.
POST /api/auth/logout
Authorization: Bearer <access_token>Before / After
| Scenario | Before this fix | After this fix |
|---|---|---|
GET /api/auth/user after logout (Bearer) | ✅ 200 — token still worked | ❌ 401 — token rejected |
GET /api/roles after logout (Bearer) | ✅ 200 — token still worked | ❌ 401 — token rejected |
| Browser session logout | ✅ Worked (cookie cleared) | ✅ Still works (+ all sessions revoked globally) |
Logout without Authorization header | ✅ Returned 200 | ✅ Still returns 200 (cookie fallback) |
Upgrade notes
No schema migrations or configuration changes are needed. The only change
required in your client code is to include the Authorization header on logout
requests if you are using Bearer token authentication.
// Before — logout without a token header (broken for Bearer clients)
await fetch('/api/auth/logout', { method: 'POST' })
// After — pass the token so the session is revoked server-side
await fetch('/api/auth/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})Older releases will appear above as they are tagged.