Features

Everything it does, and how it does it.

Nothing on this page is aspirational. Where something is still landing it says so, and where Baselyra simply does not do a thing there is a section at the bottom that names it. The version this describes is 0.1.0.

/rest/v1

An API you do not write

Baselyra introspects the catalog and serves every table, view and function in public. Create a table in the SQL editor and it is queryable a moment later, with filters, ordering, pagination, upserts and embedded resources. It is a PostgREST-compatible subset, deliberately: the client patterns people already have transfer unchanged.

filters and embeds
# One level of embedded resources, resolved through the foreign key
GET /rest/v1/posts?select=id,title,author:profiles(id,name)

# Aliases and casts
GET /rest/v1/posts?select=headline:title,views::text

# Nested boolean logic, bounded so a hostile query cannot blow the stack
GET /rest/v1/posts?or=(status.eq.draft,and(views.gte.100,pinned.is.true))

# Ordering with null placement, and a row window
GET /rest/v1/posts?order=created_at.desc.nullslast&limit=20&offset=40

# Full-text search, four tsquery parsers
GET /rest/v1/posts?title=wfts(english).two%20containers

# A Postgres function, arguments bound by name
POST /rest/v1/rpc/search_posts   {"q": "postgres", "limit_to": 10}
operators26

Comparison, pattern, array, range and full-text, each mapping to the Postgres operator of the same meaning.

eq neq gt gte lt lte like ilike match imatch in is isdistinct fts plfts phfts wfts cs cd ov sl sr nxr nxl adj not

Every value is a bound parameter. The only identifiers ever interpolated into SQL are checked against the catalog first — a column name, a sort column, a cast — and casts must match ^[A-Za-z_][A-Za-z0-9_]*$ before they are allowed near a statement.
An unfiltered DELETE is refused. So is an unfiltered PATCH. Ask for it explicitly with unsafeMutation() in the client, because deleting a whole table by forgetting a .eq() should take a second decision.

REST reference: filters, embeds, upserts, RPC, errors

Authorisation

Row level security is the whole authorisation model

There is no permission table, no rules DSL and no middleware deciding who sees what. A request arrives, its token is verified, and the claims are published to Postgres for the length of one transaction. Policies then decide. The consequence worth understanding: the same policy governs a REST read, a storage download and a realtime event, because all three take the same path into the database.

what every query is wrapped in
BEGIN;
  -- LOCAL: both settings vanish at COMMIT, so a pooled
  -- connection can never leak one request's identity into
  -- the next request that borrows it.
  SET LOCAL ROLE authenticated;
  SELECT set_config('request.jwt.claims', $1, true);

  -- your query, with every policy on the table applied
  SELECT id, title FROM public.posts WHERE published;
COMMIT;
HelperReturnsIn a policy
auth.uid()uuidThe signed-in user, NULL for anon
auth.role()textanon, authenticated or service_role
auth.email()textThe verified address on the token
auth.jwt()jsonbAll claims, including app_metadata
auth.is_admin()boolauth.users.is_admin for the caller
Your app’s admin role is your data, not ours. Put it in the user’s app_metadata and read it in a policy: (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'. A Studio account is a different population entirely and can never appear in auth.users.

The security model, plus five complete policy sets

/auth/v1

Sessions that rotate, and sign-in people recognise

Email and password

Sign-up with optional confirmation, sign-in, magic links, six-digit one-time codes, password recovery, email change and invitations. Passwords are scrypt; imported bcrypt hashes are verified by Postgres and quietly re-hashed on first successful sign-in.

Rotating refresh tokens

An access token lasts an hour by default. Refreshing rotates the refresh token and marks the old one used; presenting a used token is treated as theft and the session family is revoked. The client renews in the background and emits an auth-state event either way.

Throttling that means it

Two layers. Per-IP limits protect the process; a per-account failure counter in auth.attempts protects one user’s password from being guessed from a thousand addresses. A Retry-After header comes back with the 429.

Third-party sign-in

Seven providers, each configured by its own credentials and enabled only when they are set: Google, GitHub, LinkedIn, Facebook, Instagram, TikTok and Envato. PKCE where the provider supports it, single-use state, and a redirect allow-list rather than an open redirect.

Providers and setup

Phone sign-in by one-time code

A code to a number, verified against a hash bound to that number. Six SMS back ends: Twilio, Vonage, MessageBird, Amazon SNS, Plivo, or your own webhook. Per-number cooldowns and hourly caps are enforced before the message is paid for.

SMS providers

Two populations, kept apart

Studio operators live in control.platform_users, in the control database. Application users live in auth.users, in the project database. Postgres will not join across databases without FDW, so this is a boundary, not a convention.

Auth reference

No user enumeration. /recover, /magiclink, /otp and /resend answer identically whether or not the address exists, including in the body text. The same is true of the phone routes.

/storage/v1

Files, governed by the same policies as rows

Objects live on a Docker volume; their metadata lives in storage.objects, which is an ordinary table with row level security on it. A policy that hides a row hides the file, because the download path reads that row as the caller’s own role before it opens a file descriptor.

Buckets

Public or private, with a MIME allow-list and a per-bucket size limit. Deleting a non-empty bucket is refused rather than cascading.

Uploads

Multipart or a raw body. The bytes are hashed and size-checked as they stream, and the metadata row only becomes visible once the file is completely on disk — a half-written upload is never listed.

Downloads

Range requests, ETag and 304 handling, so a video seeks and a repeat visit is a conditional request rather than a re-download.

Signed URLs

An HMAC bound to one object and an expiry. Tampering with either the path or the deadline invalidates it, and it needs no authorization header, which is what makes it usable in an <img> tag or an email.

a per-user folder, enforced by Postgres
-- Objects named "<uid>/anything" belong to that user, and
-- nobody else can read, write or overwrite them.
create policy avatars_own on storage.objects
  for all to authenticated
  using (
    bucket_id = 'avatars'
    and (storage.foldername(name))[1] = auth.uid()::text
  )
  with check (
    bucket_id = 'avatars'
    and (storage.foldername(name))[1] = auth.uid()::text
  );

Object keys are checked for traversal and absolute paths before they are resolved, and the resolved path must still be inside the bucket’s directory. Storage reference

ws /realtime/v1

One socket: change feeds, broadcast, presence

Database changes arrive over a NOTIFY trigger you enable per table. The notification carries the primary key; the row itself is re-read as each subscriber’s own Postgres role before it is delivered. There is no policy evaluation in JavaScript anywhere in the path, so a subscription can never show more than a select would.

the wire protocol, in full
// client → server
{"type":"subscribe","channel":"public:messages","filter":"room_id=eq.42"}
{"type":"unsubscribe","channel":"public:messages"}
{"type":"broadcast","channel":"room:42","event":"typing","payload":{}}
{"type":"presence","channel":"room:42","state":{"name":"Ada"}}
{"type":"ping"}

// server → client
{"type":"subscribed","channel":"public:messages"}
{"type":"postgres_changes","event":"INSERT","new":{},"old":{}}
{"type":"broadcast","channel":"room:42","event":"typing"}
{"type":"presence_state","channel":"room:42","state":{}}
{"type":"pong"}
DELETE events are not RLS-checked. A deleted row cannot be re-read, so a delete goes to every subscriber of that table whose filter matches, carrying the old row. If a table’s contents are confidential, soft-delete instead, or publish tombstone keys to a table of their own. Inserts and updates have no such caveat.
Filters are an optimisation, never a permission. room_id=eq.42 narrows what crosses the wire; the RLS re-read already decided what was allowed to.
Large rows degrade rather than fail. pg_notify hard-fails above 8000 bytes, and that failure would abort the transaction that wrote the row. Over roughly 7.5 KB the notification carries the primary key and truncated: true instead.

Realtime reference, including the reverse-proxy rule

The Studio

The console, served at / by the same process

React 19 and Vite, compiled into the image. It talks only to /admin/v1, and a Studio session is a platform token that no application user can ever hold. Pages for the features listed as in progress land in the Studio alongside them, so treat the list below as the settled core rather than a fixed total.

PageWhat it does
/Instance health, counts, largest tables, recent audit
/databaseTable list, data grid, row editor, column and policy editing
/sqlSQL editor with schema-aware autocompletion, results grid, saved snippets
/authUser list, create, edit, delete, provider and email settings
/storageBuckets, file browser, upload, preview, signed URLs
/realtimeEnabled tables and a live event inspector over a WebSocket
/apiPer-table API reference, project keys, copyable snippets
/aiAsk-your-database and SQL generation, hidden when no key is set
/emailTemplate editor with live preview and an SMTP test send
/importConnect a source, inspect it, tick what to copy, watch it stream
/settingsInstance settings, keys, audit log
/teamStudio operators and their roles: owner, admin, viewer
/accountYour own operator account and password
/loginOperator sign-in. Rate limited, and its own token type
baselyra.sarimtools.comBaselyrabaselyrapostgres:17OverviewBUILDDatabaseSQL EditorAuthenticationStorageRealtimeAPI DocsAI AssistantCONFIGUREImportEmailSettingsdatabase upSQL EditorRun Cmd+EnterFormat1select p.id, p.title, count(c.*) as comments2from public.posts p3left join public.comments c on c.post_id = p.id4where p.published and p.created_at > now() - interval '30 days'5group by 1, 26order by comments desc limit7;_schema-aware autocompletionResult10 rows · 8.4 msidtitlecomments2d77…10bbRow level security, plainly8f1c…a24eShipping two containerse0b2…d489Importing from Supabase55ae…3f70Realtime without a broker1a6b…ff93Eight dependenciesc48e…b117The control database6ccd…2e58A policy set for chat71fd…6a02Signed URLs in practice9b31…c605Presence without Redis34c7…8e1aRefresh token rotation
Cmd+Enter runs, Cmd+K jumps anywhere. Every statement is written to the audit log.
The SQL editor cannot read the control database. select * from control.platform_users fails with relation does not exist, which is the correct and honest outcome: the thing that grants access to the console does not live inside the thing the console administers.
Operators are not all one thing. A Studio account holds a role, and a role is a set of 20 named capabilities across 10 areas — browse data, run SQL, manage storage, read logs, manage the team. owner, admin and viewer ship built in; you can define your own. Every /admin/v1 route declares the capability it needs, the token carries the account’s list in a cap claim, and the last owner can neither be deleted nor lose team.manage — so an instance cannot lock itself out.

/admin/v1/import

Import from what you are already running

SupabasePostgresAppwriteFirebaseSQL dumpPOST /admin/v1/import/testconnect and report the versionPOST /admin/v1/import/inspectlist tables, users, buckets, policiesPOST /admin/v1/import/runcopy the selection, streaming progressGET /admin/v1/import/historyevery run, with its warningsYour Baselyra projectpublic.*tables, types, keys, indexesauth.userswith bcrypt hashes where thesource stored bcryptstorage.objectsfiles and their metadataRLSon, on every imported tablepoliciestranslated where possibleWhat does not come— argon2 and scrypt passwords— database functions and triggers— extensions— edge / cloud functions— scheduled jobs— provider-specific settingsThe run log names every one it skipped.
Progress streams back as text/event-stream, and every run is kept in control.import_runs with its warnings.

The migration guide, per source, including exactly what happens to passwords

/ai/v1

An assistant that is off until you pay for it

Baselyra ships a DeepSeek relay. With no DEEPSEEK_API_KEY set — the default — every AI route answers 503 ai_disabled, the Studio hides its AI panels rather than showing controls that would fail, and nothing leaves the machine.

RouteWhoSent to DeepSeek
POST /sqlOperatorYour schema — table, column and type names. No row data.
POST /explainOperatorThe statement you pasted. No row data.
POST /askOperatorSchema, the question, and up to 200 result rows
POST /chatAny signed-in userExactly the messages your app sends
GET /statusAnyoneNothing — reports whether it is enabled
The key never reaches a browser. /ai/v1/chat is a relay, so your app can give its own users an assistant without shipping a provider key to the client — and without you writing the proxy.
Spend is capped, not free. Chat is limited to 20 requests per minute per user id — not per IP, so a carrier NAT does not share one budget across thousands of people. DEEPSEEK_MAX_TOKENS is a hard ceiling per completion, a client disconnect aborts the upstream call, and there is no per-instance spend cap beyond that. If you expose chat publicly, watch your DeepSeek dashboard.

AI reference: routes, cost control, privacy

Roadmap and limits

What is landing, and what is not planned

An overstated feature list is found out on day one, so this is the honest version. If something here is a requirement for you, Supabase or Appwrite is the sincere recommendation and it is on the comparison page.

in progress being built
Edge functions

A deploy target for your own functions. Today: Postgres functions over /rest/v1/rpc/:fn.

Multi-project

The registry, the per-project pools and per-project JWT secrets are in the source, and the Studio has the switcher and the management page. What is missing is the endpoint behind them: nothing creates a second project yet, so one instance is one project.

S3 and object storage back ends

Files are on the local disk of the host running the app. Back up that volume.

Image transformations

Files come back exactly as they were uploaded.

Database webhooks

Signed, retried, dead-lettered HTTP calls on row changes, with a delivery log you can replay from. Working and under test behind /admin/v1/hooks, and there is a Studio page — but it is not yet part of the documented, released surface, so treat it as in progress until it has a documentation page of its own.

Scheduled jobs

Cron with real timezone and daylight-saving handling, running SQL or an HTTP call. Same status as webhooks.

not planned use something else
Read replicas, failover, branching

One Postgres. Backups are pg_dump plus the storage volume, and there are scripts for both.

A connection pooler at scale

One pg pool, sized by DATABASE_POOL_MAX. Put PgBouncer in front yourself if you outgrow it.

A hosted tier

There is no Baselyra cloud, no account and no dashboard anywhere but on your own machine.

Telemetry

Nothing phones home. There is no analytics code in the source to disable.

An ORM or a query builder library

Eight runtime dependencies is a constraint the project intends to keep.

Check the repository rather than this page before you commit to something listed as in progress. github.com/baselyra/baselyra

Try it

The fastest way to judge this is to run it.

Three lines of shell, or one click into a live instance with the Studio already up.