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.
# 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}
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
^[A-Za-z_][A-Za-z0-9_]*$ before they are allowed
near a statement.
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.
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.
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;
| Helper | Returns | In a policy |
|---|---|---|
| auth.uid() | uuid | The signed-in user, NULL for anon |
| auth.role() | text | anon, authenticated or service_role |
| auth.email() | text | The verified address on the token |
| auth.jwt() | jsonb | All claims, including app_metadata |
| auth.is_admin() | bool | auth.users.is_admin for the caller |
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.
/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.
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.
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.
/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.
-- 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.
// 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.
room_id=eq.42 narrows what crosses the wire; the RLS re-read already decided
what was allowed to.
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.
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.
| Page | What it does |
|---|---|
| / | Instance health, counts, largest tables, recent audit |
| /database | Table list, data grid, row editor, column and policy editing |
| /sql | SQL editor with schema-aware autocompletion, results grid, saved snippets |
| /auth | User list, create, edit, delete, provider and email settings |
| /storage | Buckets, file browser, upload, preview, signed URLs |
| /realtime | Enabled tables and a live event inspector over a WebSocket |
| /api | Per-table API reference, project keys, copyable snippets |
| /ai | Ask-your-database and SQL generation, hidden when no key is set |
| Template editor with live preview and an SMTP test send | |
| /import | Connect a source, inspect it, tick what to copy, watch it stream |
| /settings | Instance settings, keys, audit log |
| /team | Studio operators and their roles: owner, admin, viewer |
| /account | Your own operator account and password |
| /login | Operator sign-in. Rate limited, and its own token type |
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.
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
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.
| Route | Who | Sent to DeepSeek |
|---|---|---|
| POST /sql | Operator | Your schema — table, column and type names. No row data. |
| POST /explain | Operator | The statement you pasted. No row data. |
| POST /ask | Operator | Schema, the question, and up to 200 result rows |
| POST /chat | Any signed-in user | Exactly the messages your app sends |
| GET /status | Anyone | Nothing — reports whether it is enabled |
/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.
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.
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.
A deploy target for your
own functions. Today: Postgres functions over /rest/v1/rpc/:fn.
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.
Files are on the local disk of the host running the app. Back up that volume.
Files come back exactly as they were uploaded.
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.
Cron with real timezone and daylight-saving handling, running SQL or an HTTP call. Same status as webhooks.
One
Postgres. Backups are pg_dump plus the storage volume, and there are scripts
for both.
One
pg pool, sized by DATABASE_POOL_MAX. Put PgBouncer in front
yourself if you outgrow it.
There is no Baselyra cloud, no account and no dashboard anywhere but on your own machine.
Nothing phones home. There is no analytics code in the source to disable.
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.