This file describes the architecture, conventions, and rules for working on the BLite.Server repository. Read it in full before making any changes.
| Repository | Role |
|---|---|
github.com/EntglDb/BLite (sibling at ../BLite) |
Core storage engine (BLite.Core), BSON library (BLite.Bson), client SDK (BLite) |
github.com/EntglDb/BLite.Server (this repo) |
gRPC server, REST API, Blazor management Studio |
The server never copies the engine source — it references it via a local
ProjectReference. Do not modify files under ../BLite unless explicitly asked.
BLite.Server — ASP.NET Core 10 host (gRPC + REST + Blazor Studio)
BLite.Proto — Protobuf contracts + generated gRPC stubs
BLite.Core — (sibling) storage engine, not modified here
BLite.Bson — (sibling) BSON serialization, not modified here
BLite — (sibling) client SDK, not modified here
| Endpoint | URL | Protocol |
|---|---|---|
| gRPC | https://*:2626 |
HTTP/2 only |
| REST API | https://*:2627 |
HTTP/1.1 + HTTP/2 |
| Studio (Blazor) | https://*:2628 |
HTTP/1.1 + HTTP/2 |
REST and Studio run on dedicated ports when both Kestrel:Endpoints:Rest and
Kestrel:Endpoints:Studio are configured with different URLs. If only Studio is
configured (legacy single-port mode), all non-gRPC traffic is served on that one port
without any RequireHost restriction — backward-compatible with older deployments.
The Studio is enabled via "Studio": { "Enabled": true }. It is off by default
in production images.
┌─────────────────────────────────────────────────────────────────┐
│ BLite.Server │
│ │
│ ┌──────────────┐ ┌───────────────────┐ ┌───────────────┐ │
│ │ gRPC layer │ │ REST /api/v1 │ │ Blazor Studio │ │
│ │ DynamicSvc │ │ (Minimal APIs) │ │ (port 2627) │ │
│ │ DocumentSvc │ │ BLQL + CRUD │ │ │ │
│ │ AdminSvc │ │ │ │ StudioService│ │
│ │ MetadataSvc │ │ PermissionFilter │ │ StudioSession│ │
│ │ TxnSvc │ │ RestAuthFilter │ │ │ │
│ └──────┬───────┘ └────────┬──────────┘ └───────┬───────┘ │
│ │ │ │ │
│ └──────────┬─────────┘ │ │
│ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ EngineRegistry │◄───────────────────────┘ │
│ │ (singleton) │ │
│ └────────┬────────┘ │
│ │ one BLiteEngine per database │
│ ▼ │
│ ┌─────────────────┐ │
│ │ BLiteEngine │ ../BLite (sibling repo) │
│ │ + Collections │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
| Type | Role |
|---|---|
EngineRegistry |
Maps database_id → BLiteEngine. System engine (key "") hosts _users. |
UserRepository |
In-memory ConcurrentDictionary over the _users collection. Hot path: O(1) key hash lookup. |
ApiKeyValidator |
Resolves an API key to a BLiteUser. In dev mode (no users), returns a synthetic DevRoot. |
AuthorizationService |
Checks BLiteOperation flags against a user's PermissionEntry list. |
TransactionManager |
One active transaction per database at a time (enforced by a per-db SemaphoreSlim). |
| Type | Scope | Role |
|---|---|---|
RestAuthFilter |
Scoped (per request) | IEndpointFilter that resolves API key and stores BLiteUser in HttpContext.Items. |
StudioService |
Scoped (per circuit) | Blazor façade over EngineRegistry + UserRepository. |
StudioSession |
Scoped (per circuit) | Carries Studio authentication state for one Blazor Server circuit. |
Every gRPC request goes through ApiKeyMiddleware (reads x-api-key header).
Every REST request goes through RestAuthFilter (reads x-api-key or
Authorization: Bearer <key>). Both store the resolved BLiteUser in
HttpContext.Items[nameof(BLiteUser)].
[Flags]
public enum BLiteOperation
{
None = 0,
Query = 1,
Insert = 2,
Update = 4,
Delete = 8,
Drop = 16,
Admin = 32,
Write = Insert | Update | Delete,
All = Query | Write | Drop | Admin
}Each BLiteUser has a IReadOnlyList<PermissionEntry> where PermissionEntry
is (string Collection, BLiteOperation Ops). Collection "*" matches any
collection.
Users with a non-null Namespace field work inside a transparent prefix.
The physical collection name is "<namespace>:<logical_name>".
NamespaceResolver.Resolve(user, logicalName) does this translation.
Always use physical names when calling the engine directly.
Users with a non-null DatabaseId are restricted to one tenant engine.
EngineRegistry.GetEngine(user.DatabaseId) returns the correct engine.
NullIfDefault(dbId) maps the "default" URL sentinel to null (system engine).
All endpoints live under /api/v1 and are grouped in separate extension classes:
src/BLite.Server/Rest/
RestApiExtensions.cs — MapBliteRestApi() entry, shared utilities
PermissionFilter.cs — IEndpointFilter: auth check before handler
RestApiDatabasesExtensions.cs — /databases
RestApiCollectionsExtensions.cs — /{dbId}/collections
RestApiDocumentsExtensions.cs — /{dbId}/{collection}/documents
RestApiBlqlExtensions.cs — /{dbId}/{collection}/query (BLQL)
RestApiUsersExtensions.cs — /users
BLiteErrors.cs — ErrorOr factory + IResult mapper
RestAuthFilter.cs — IEndpointFilter: API key resolution
- Add the
MapXxx(this RouteGroupBuilder g)method in the appropriate file. - Add
.AddEndpointFilter(new PermissionFilter(op, collection?, checkDb:))to the endpoint.- Admin-only endpoints: use group-level filter with
BLiteOperation.Admin, "*". - Collection-scoped endpoints: use per-endpoint filter with
checkDb: true.
- Admin-only endpoints: use group-level filter with
- Add
.WithSummary(…).WithDescription(…)for OpenAPI documentation. - Retrieve the current user inside the handler via:
var user = (BLiteUser)ctx.Items[nameof(BLiteUser)]!;
- Map errors using
BLiteErrors.Xyz().ToResult()orresult.ToResult(v => Results.Ok(v)). - Call
MapXxx()fromRestApiExtensions.MapBliteRestApi().
new PermissionFilter(
BLiteOperation op, // required: operation to check
string? collection, // null → read from {collection} route param (fallback "*")
bool checkDb = false // true → validate {dbId} against user.DatabaseId
)// Factory (BLiteErrors.cs) → returns ErrorOr<T>
return BLiteErrors.DocumentNotFound(id).ToResult(); // Error → IResult
return result.ToResult(v => Results.Created("/…", v)); // Success path
return Results.ValidationProblem(new Dictionary<string, string[]>{ … });src/BLite.Server/Services/
BLiteServiceBase.cs — shared helpers: GetCurrentUser(), AuthorizeWithUser()
DynamicServiceImpl.cs — schema-less: Insert, Update, Delete, Query, FindById, InsertBulk
DocumentServiceImpl.cs — typed (BSON payload): same operations + typed streaming
AdminServiceImpl.cs — user management, database management (Admin only)
MetadataServiceImpl.cs — collection/index introspection
TransactionServiceImpl.cs — Begin, Commit, Rollback
All services inherit BLiteServiceBase. Authorization is checked via
AuthorizeWithUser(context, collection, operation) which:
- Resolves the
BLiteUserfromHttpContext.Items - Calls
AuthorizationService.CheckPermission - Calls
NamespaceResolver.Resolvefor the physical collection name - Returns
(physicalCollection, user)
Both DynamicService.Query and DocumentService.Query delegate to:
QueryDescriptorExecutor.ExecuteAsync(engine, descriptor, ct)
// returns IAsyncEnumerable<BsonDocument>The QueryDescriptor is deserialized from QueryDescriptorSerializer.Deserialize(bytes).
It contains: WHERE filter, ORDER BY, Skip, Take, SELECT projection, collection name.
When request.TransactionId is non-empty, writes go to the transaction engine:
var session = _txnManager.RequireSession(request.TransactionId, user);
id = await session.Engine.GetOrCreateCollection(col).InsertAsync(doc, ct);src/BLite.Server/Components/
App.razor — root, sets InteractiveServer globally
Routes.razor — router, default layout = StudioLayout
Layout/
StudioLayout.razor — sidebar + auth gate (checks StudioSession)
SetupLayout.razor — used by /setup and /login (centered card)
StudioNavMenu.razor — sidebar navigation links
Pages/
Setup.razor — first-run wizard (/setup)
Login.razor — Studio login (/login)
Dashboard.razor — /
Databases.razor — /databases
Collections.razor — /collections
Documents.razor — /documents
Users.razor — /users
StudioLayout.OnInitializedchecksStudio.IsSetupComplete→/setupif false.- Then checks
Session.IsAuthenticated→/loginif false. Login.razorcallsStudio.ValidateStudioKey(key)which requires a valid key andAdminpermission on"*".- On success:
Session.Login(username)+Nav.NavigateTo("/"). - Logout:
Session.Logout()+ navigate to/login.
StudioSession is scoped per Blazor Server circuit — it resets on page reload.
StudioService is the single façade for all Studio UI operations. Inject it as
@inject StudioService Studio in Blazor pages. Do not inject EngineRegistry
or UserRepository directly into components.
The Studio uses a custom dark theme defined in wwwroot/css/studio.css.
Key CSS variables:
--bg, --bg-card, --bg-hover, --bg-input
--border, --text, --text-dim
--accent, --accent-bg
--green, --red, --orange
--radius: 6px
--mono, --sansKey layout classes:
.studio-root— flex row (sidebar + main).studio-sidebar— 220 px wide, flex-column.studio-main— flex:1, scrollable.doc-split— two-column list + editor panel.data-table— standard table.toolbar— flex row of controls withgap.panel/.panel-body— collapsible card (<details>).btn,.btn-primary,.btn-danger,.btn-small— buttons.input— text inputs and selects.badge,.badge-active,.badge-idle— status badges.alert-ok,.alert-error— feedback messages.modal-backdrop/.modal— modal dialogs.source-badge— sidebar footer links/buttons.sidebar-divider— thin<hr>separator in sidebar
- C# 14, net10.0
- Nullable reference types enabled (
<Nullable>enable) - Implicit usings enabled
- Types, methods, properties:
PascalCase - Private fields:
_camelCase - Local variables and parameters:
camelCase - Constants:
PascalCase
// BLite.Server — <short description>
// Copyright (C) 2026 Luca Fabbri — AGPL-3.0
//
// <longer description if needed>
using ...;
namespace BLite.Server.<SubNamespace>;Do not add comments that paraphrase the code. Only add comments that explain
why, describe a non-obvious invariant, or serve as section separators
(e.g., // ── Auth helpers ──────).
- REST layer: use
ErrorOr+BLiteErrorsfactory. Never throw exceptions for expected business errors; returnIResultvia.ToResult(). - gRPC layer: throw
RpcExceptionfor expected errors; let unhandled exceptions propagate to the gRPC interceptor. - Blazor components: catch exceptions in event handlers and set an
_errorMessagefield displayed in the UI. Neverthrowfrom a Blazor event handler.
- All I/O methods must be
async Task/async ValueTaskwithCancellationToken. - Do not use
.Resultor.Wait(). - Pass
CancellationTokenfrom the gRPCServerCallContext.CancellationTokenor the BlazorCancellationToken(where available).
- Prefer constructor injection.
- Singletons can only depend on other singletons.
- Scoped services (e.g.
StudioService) can depend on singletons — not vice versa. - Never resolve services manually with
GetRequiredServiceinside application code (only inProgram.csbootstrap).
{
"BLiteServer": {
"DatabasePath": "blite.db", // system database file path
"MaxPageSizeBytes": 16384, // BLiteEngine page size
"DatabasesDirectory": "data/tenants" // tenant .db files
},
"Studio": {
"Enabled": true // enable Blazor Studio + REST API
},
"Kestrel": {
"Endpoints": {
"Grpc": { "Url": "https://*:2626", "Protocols": "Http2" },
"Studio": { "Url": "https://*:2627", "Protocols": "Http1AndHttp2" }
}
},
"Transactions": {
"TimeoutSeconds": 60
},
"Telemetry": {
"Enabled": true,
"ServiceName": "blite-server",
"Console": null,
"Otlp": { "Endpoint": "" } // empty = disabled; set to your collector URL to enable
},
"License": {
"SourceUrl": "https://github.com/EntglDb/BLite.Server"
}
}-
Never store a plaintext API key. Keys are stored as SHA-256 hex hashes in
_users. The plaintext is returned once at creation/rotation only. -
Always resolve physical collection names via
NamespaceResolver.Resolvebefore calling the engine. Storing a logical name in the engine is a data isolation bug. -
Always include
dbIdin cache/cache-invalidation keys. Missing it causes cross-tenant data leaks. -
Never access
EngineRegistry.GetEngine(null)directly for tenant data. UseGetEngine(user.DatabaseId)so the correct engine is selected. -
The
_userscollection lives in the system engine only. Never store users in a tenant engine, and never pass a tenant engine toUserRepository. -
Collections whose names start with
_are reserved. The REST layer filters them out from list responses. Never expose them to end users withoutAdminpermission. -
StudioSession.IsAuthenticatedis circuit-scoped (in-memory only). It is not a substitute for API-level authentication on the gRPC/REST layer. -
The
TransactionManagerholds aSemaphoreSlim(1,1)per database. Any code that awaits inside a transaction must eventually callCommitAsyncorRollbackAsync, or the semaphore will never be released (server deadlock on that database).
# Build
dotnet build src/BLite.Server/BLite.Server.csproj
# Run (development)
dotnet run --project src/BLite.Server
# Open Studio
# https://localhost:2627
# gRPC endpoint
# https://localhost:2626After a clean start with no server-setup.json file, navigate to https://localhost:2627/setup
to create the root user.
- Create
src/BLite.Server/Components/Pages/MyPage.razorwith@page "/my-page". - Inject
@inject StudioService Studio— do not inject engine/repository directly. - Use the existing CSS classes; do not add inline styles except for minor layout tweaks.
- Add the navigation link in
StudioNavMenu.razor. - Auth is handled by
StudioLayout— no need to checkSession.IsAuthenticatedinside the page itself.
- Add the new flag to
BLiteOperationinsrc/BLite.Server/Auth/Permission.cs(keep it a power of 2). - Update
AuthorizationService.CheckPermissionif the new flag needs special semantics (e.g., reserved-collection access). - Update the
_allOpsarray inUsers.razorso the Studio shows the new checkbox. - Update the OpenAPI description of the
PermissionRequestDTO.