Anatomy of Adminium
本頁內容尚未翻譯。
What is Adminium? explains the model: Adminium interprets your schema at runtime rather than generating a codebase, it keeps three separate connections, and it reads structure without reading rows. This page assumes you have that and goes one level down — into the machinery.
It answers, in order: what that one command downloads and runs, how a Node process becomes an admin panel, which of the twenty-one workspaces does what, how a schema turns into pages, what happens on a single click, and where everything you change is stored.
1. What npx @adminiumjs/adminium actually runs
Section titled “1. What npx @adminiumjs/adminium actually runs”One command, one binary and, from 0.3.0, one package. The package you name holds the server, the CLI, the dashboard build and the internal packages the server loads:
npm registry└── @adminiumjs/adminium@0.3.36 ← the published name of apps/server ├── dist/ compiled server + CLI ├── dashboard/ the pre-built React SPA (static files) ├── vocabulary/ LLM allow-lists snapshotted at pack time ├── assets/ the PNG marks email templates use ├── node_modules/@adminium/ the 11 internal packages the server loads ├── README.md └── LICENSEThe 11 are engine, meta, i18n, llm, schema-import, manifest,
add-on-contracts, widgets and the three adapters. ui, charts and
tokens are not among them, because the server never loads them (see
the dependency graph). The package lists the 11 in
bundleDependencies, so npm installs them from the tarball and never asks the
registry for them. They keep their source names, so
node_modules/@adminium/engine holds @adminiumjs/engine
(how @adminium becomes @adminiumjs
explains why).
Their copies declare no dependencies of their own. The package declares the
third-party libraries they load instead, found by tracing the built code
(scripts/release/server-runtime-deps.mjs). So what widgets declares for the
dashboard (React, Radix, MapLibre and the rest) is never installed. A 0.3.0
install comes to about 170 packages and 150 MB. Up to 0.2.9, npm downloaded 14
separate Adminium packages and everything they declared: about 245 packages and
220 MB.
None of Adminium’s packages runs an install script. The only one in the whole tree is argon2’s. On x64 and ARM Linux, Apple-silicon macOS and x64 Windows it picks a prebuilt binary; elsewhere it compiles one.
bin maps the command adminium to dist/cli/index.js. That entry file does
exactly two things: await runCli(process.argv.slice(2)), then assign the
result to process.exitCode. It never calls process.exit, because a clean
exit must not tear down a server that is now listening.
The SPA ships inside the server tarball. That is the whole reason one
command is a complete install — there is no separate frontend to deploy, no
CDN, no build step on your machine. Two scripts fill those directories before
the package is packed: bundle-dashboard.mjs copies apps/dashboard/dist into
apps/server/dashboard, and bundle-allowlists.mjs snapshots the widget
vocabulary into apps/server/vocabulary. Neither directory is in git.
The command set
Section titled “The command set”runCli is a pure argv-to-exit-code dispatcher over fourteen commands, listed
here in registry order — the same order --help prints:
| Command | What it does |
|---|---|
new |
Create a project folder, or make this folder one. |
dev |
Run the project, watching it. |
build |
Compile the project’s config and code into .adminium/build. |
start |
Boot the server against existing configuration — a project’s, or the environment’s. |
check |
Check a project without starting it, for CI. |
pull |
Write pages and schema changes into the project. |
eject |
Turn a page file into a page written in React. |
try |
The interactive setup wizard, with no project. init is a hidden alias. |
migrate |
Apply pending meta-store migrations; --status reports without applying. |
introspect |
Read a connection’s schema into a new snapshot. |
generate-prompt |
Emit the LLM enrichment prompt for copy-paste. |
apply-llm-response |
Validate and apply a model’s reply. |
export-zip |
Write the whole instance configuration to a bundle. |
import-zip |
Restore one. |
With no arguments a fifteenth, hidden command runs: inside a project it
lists that project’s commands, and anywhere else it asks for a folder name and
creates a project there. Up to 0.2.9 it was the setup wizard, which is now
try.
Flags are declared as data (FlagSpecs) and --help is rendered from that same
data, so a flag that exists is documented and a documented flag exists. Parsing
is Node’s built-in node:util parseArgs in strict mode — no CLI framework,
so the published tarball carries no extra dependency for the first thing you
run.
Exit codes are a closed set of five: 0 success, 1 error, 2 validation
failed, 3 nothing accepted, 78 the environment is in a state Adminium
refuses to act on. 3 is meaningful only for apply-llm-response, and 2 for
it and check. 78 is sysexits(3)’s EX_CONFIG, chosen so a restart loop can
tell “fix your setup” apart from “it crashed”: it covers a meta store migrated
by a newer Adminium, a pre-upgrade snapshot that could not be written, and a
Node too old to load the database driver.
The one required variable
Section titled “The one required variable”ADMINIUM_SECRET is the single hard requirement. It derives, via HKDF-SHA256,
the keys that encrypt every stored DSN and API key. Without it — or with fewer
than 16 characters — startup stops before anything else happens:
ADMINIUM_SECRET is required — it derives the key that encrypts every stored DSN and API key.
Generate one and re-run, e.g. export ADMINIUM_SECRET=$(openssl rand -hex 32)followed by a variable | problem | hint table. Nothing is written to stdout;
the run never starts.
One ordering detail worth knowing: the commands that need a person check for an
interactive terminal before they load the environment. So adminium try with
no secret and no TTY reports “The setup wizard needs an interactive terminal”
and never mentions the secret, and a bare adminium there says “Tell Adminium
what to do.” with the three commands that would work. adminium start has no
such gate, so there the secret error is the first thing you see.
ADMINIUM_SECRET is the only required variable; PORT defaults to 4600,
HOST to 0.0.0.0, and ADMINIUM_DATA_DIR to the relative ./data — except
on a CLI run outside a project, where it becomes ~/.adminium so an instance
started from a home directory can be found again from anywhere. Inside a
project it is always resolved against the project root. The complete list is
envSchema, rendered as
Environment variables.
2. The first run
Section titled “2. The first run”From 0.3.0, adminium with no command creates a project (adminium new), and
the wizard described here runs as adminium try. Its first question is which
front door you want:
How would you like to set this up? › In your browser ← default Here in the terminalThe browser door asks nothing further. It boots the server immediately,
prints the URL, spawns your platform’s opener (open / cmd /c start /
xdg-open), and hands the rest of setup to the Studio wizard in the browser.
--no-open skips the launch and prints “Open that URL to continue” instead.
The terminal door runs the whole flow as a questionnaire: pick an intent →
give a DSN → probe it (Connected — 41 ms · PostgreSQL 16.2 · read-only role)
→ enforce meta placement → name the connection → introspect → tick the tables
you want → generate → boot.
A terminal that cannot host a redrawing menu — TERM=dumb, piped stdout, most
CI — is not asked at all and takes the terminal path, even if you passed
--browser. In that environment the first prompt you see is the meta-placement
question, not the front-door question.
Neither door creates your admin account. That happens in the browser, at
/setup, via POST /api/v1/setup/super-admin. The account is once-only by
construction: inside one transaction the server inserts a
system.superAdminCreatedAt row into adminium_settings, whose key is a
primary key, before creating the user. A concurrent second attempt loses the
insert on a duplicate-key violation and rolls back entirely.
What lands on disk
Section titled “What lands on disk”Everything goes under ADMINIUM_DATA_DIR (./data beside a project, ~/.adminium otherwise):
data/meta.db— an embedded SQLite meta store, if you configured nothing else. This prints a warning on every boot: “Using embedded SQLite meta store at … — setADMINIUM_META_URLfor production.”data/adminium.json— the bootstrap file, written0600, holding{ v, metaUrl?, createdAt, instanceId }. AnymetaUrlin it is AES-256-GCM ciphertext. Written only by the terminal door, only when you chose an external meta database.
Where the meta store lives is a strict three-rung precedence:
ADMINIUM_META_URL- the decrypted
metaUrlinsidedata/adminium.json sqlite:<dataDir>/meta.db
3. The running process
Section titled “3. The running process”One Node process. No Redis, no external queue, no separate scheduler, no sidecar. It is assembled in two layers.
buildServer() builds the skeleton: a pino logger with path-based redaction,
req_-prefixed request ids, Zod validator and serializer compilers, a global
error envelope, three plugins (core → auth → static), an SPA-aware
not-found handler, and the handful of /api/v1 namespaces that need no injected
services.
composeServer() is the composition root. It takes an already-opened meta
store, a ConnectionManager, the LLM services and the widget allow-lists, calls
buildServer, registers the RBAC plugin, wires jobs and realtime, and then
registers the remaining route factories under /api/v1. Registration order is
load-bearing: the auth plugin’s onRequest hook must run before the RBAC
plugin’s, and the RBAC plugin must be registered before any route that calls
app.rbac.require at registration time.
The end result, in one picture:
┌──────────────────────────────────────────┐ Browser ────────►│ one SPA build (@adminium/dashboard) │ │ Studio + Generated App │ └────────────────┬─────────────────────────┘ REST /api/v1 · WS /ws · SSE /api/v1/events ┌────────────────┴─────────────────────────┐ │ @adminium/server — Fastify 5 │ │ auth · RBAC · CRUD · jobs · realtime │ └───┬──────────────┬─────────────────┬─────┘ │ │ │ @adminium/ @adminium/ adapter-postgres engine meta adapter-mysql introspect → adminium_* adapter-sqlite classify → tables generate │ │ meta store your databaseThree cron schedules are registered at compose time: a telemetry ping
(0 4 * * *, with an hour of jitter), a scheduled-reports poll, and an exports
retention sweep (30 4 * * *). An onClose hook disposes every source-database
pool.
The three connections, as implemented
Section titled “The three connections, as implemented”| Role | Lifetime | What it may do |
|---|---|---|
| introspect | short-lived, created per introspection | reads system catalogs only |
| data | a pooled Kysely handle, cached per connection id | the CRUD and widget-data path |
| meta | a separate Kysely, never handed to an adapter | Adminium’s own tables |
The separation is enforced at the type level, not by convention. The adapter
interface is generic over its role: introspect() is typed
this: DatabaseAdapter<'introspect'> while the row-reading methods are typed
this: DatabaseAdapter<'data'>. Calling one on the other is a compile error,
and every adapter re-checks the role at runtime as well.
4. The twenty-one workspaces
Section titled “4. The twenty-one workspaces”Sixteen packages and five apps, all versioned together. Four are published to
npm: the CLI (@adminiumjs/adminium, built from apps/server), and
public-client, manifest and add-on-contracts, which other repos install
on their own. Every other workspace is private, and the CLI carries the ones it
needs inside its tarball
(what that one command runs).
Source names are @adminium/*; the published names are @adminiumjs/* (see
one codebase, four ways to run it).
This section is the map. For what is actually inside each package — its public surface, its internal decisions, and the rules that keep it in its lane — see The packages, one by one.
Packages
Section titled “Packages”| Package | What it is |
|---|---|
engine |
The brain. Owns the schema IR, the adapter contract, classification, snapshot hashing/diffing, and page generation. No database drivers. |
adapter-postgres |
Postgres introspection via pg_catalog, plus the Kysely query engine. |
adapter-mysql |
MySQL/MariaDB introspection via information_schema. Refuses MySQL < 8.0 and MariaDB < 10.5. |
adapter-sqlite |
SQLite introspection via sqlite_master and pragma_* table-valued functions. |
meta |
The adminium_* tables: 34 tables, 12 migrations, typed repositories. A leaf — depends only on kysely and zod, and declares no drivers. |
schema-import |
Eight schema-file parsers (SQL DDL, Prisma, Drizzle, TypeORM, Sequelize, Rails, Django, JSON IR) producing the same IR. This is how you use Adminium with no live database. |
llm |
LLM assist as a headless library: four provider clients, one deterministic prompt builder, a seven-stage response validator. |
widgets |
The widget registry — 177 widget definitions across 13 families — plus page templates and the dashboard grid. |
ui |
The component library: 66 components on 11 Radix primitives, CVA variants, ThemeProvider. Owns no copy; every string arrives as a prop. |
charts |
SVG chart primitives. No charting library — d3-scale and d3-shape for math only, React renders the <svg>. |
tokens |
Design tokens as CSS custom properties: theme, 8 accents, density, viz palette, motion, self-hosted fonts, and the Tailwind v4 mapping. Zero dependencies. |
i18n |
8 locales × 5 namespaces, ICU messages, RTL derived from the locale registry, split into framework-free / React / Node entry points. |
manifest |
The micro-SaaS manifest schema, validator and installer. |
add-on-contracts |
The add-on slot registry (12 slots) and provider contracts (3), each shipping a conformance suite an implementation must pass. |
config |
Shared tsconfig, Prettier, and the flat ESLint config — including a six-rule in-house plugin. |
| App | What it is |
|---|---|
server |
The Fastify server and the adminium CLI. This is the published npm package. |
dashboard |
The React SPA — Studio and the Generated App in one build. Private; ships as static files inside server. |
desktop |
The Electron shell. Runs the same server in a utilityProcess. |
docs |
This site (Astro Starlight). |
e2e |
The Playwright matrix. |
The dependency graph
Section titled “The dependency graph”Runtime dependencies only, A → B meaning A depends on B:
tokens → (nothing)i18n → (nothing)meta → (nothing)add-on-contracts → (nothing)config → (nothing)
ui → tokenscharts → i18n, tokensmanifest → add-on-contractswidgets → charts, i18n, tokens, uiengine → widgetsadapter-postgres → engineadapter-mysql → engineadapter-sqlite → engineschema-import → enginellm → engine, widgets
server → engine, meta, i18n, llm, schema-import, adapter-postgres, adapter-mysql, adapter-sqlitedashboard → engine, widgets, ui, charts, tokens, i18ndesktop → server, adapter-postgres, adapter-mysql, adapter-sqliteFive packages are true leaves with no internal dependencies: tokens, i18n,
meta, add-on-contracts, config.
The single most counter-intuitive edge is engine → widgets. The engine
is server-side and the widgets package is a React library, so the arrow looks
backwards. It exists because the engine’s generator needs the widget catalog
rules — which widget accepts which data shape, how a page template’s slots are
laid out — and those rules live with the widgets. The edge is confined to two
deliberately pure subpaths, @adminium/widgets/page-config and
@adminium/widgets/generate, and the engine never imports the widgets barrel.
So the server never loads React.
From 0.3.0, npm does not install it either. The published CLI carries only
the internal packages the server loads (widgets among them, for those two
subpaths), with no dependency lists of their own, and declares only the
third-party libraries that code imports. ui, charts, tokens, React, React
DOM, Radix, lucide-react, MapLibre, dnd-kit and TanStack Table stay out. Up to
0.2.9, the dependency on widgets brought the whole package and everything it
declared: about 80 MB of a 220 MB install, most of it third-party browser code
(lucide-react alone is 41 MB).
The layering is written down as twenty forbidden edges in
.dependency-cruiser.cjs and checked by pnpm check-deps in CI. A related
constraint runs the other way: @adminium/engine/config is a browser-safe
subpath — no node: imports, only zod and the pure widgets leaf — which is
what lets the browser, the server and the manifest package validate stored
configuration against literally the same Zod objects.
5. Schema to app
Section titled “5. Schema to app”This is the pipeline the wizard runs, and the one the Studio re-runs whenever you ask it to.
Introspect
Section titled “Introspect”Each adapter issues a fixed number of catalog statements that does not grow
with your table count — 7 for Postgres, 6 for MySQL, a set of pragma joins for
SQLite — under a total time budget (30 s by default), and assembles the same
normalized DatabaseModel.
The dialects genuinely differ in what they can see. Postgres alone reports
multiple schemas, materialized views, comments, row-level security, write
activity, table size, partial and expression indexes, native enums, domains and
array columns. MySQL reports none of those and always marks its row estimates
approximate. SQLite has no native enum type at all, so enums are synthesized
by hand-parsing CHECK (col IN ('a','b')) out of the stored DDL. Each
degradation is recorded as a typed warning code rather than silently dropped.
Introspection reads no rows. Every Postgres statement targets pg_catalog,
every MySQL statement targets information_schema (a test regex-checks every
FROM/JOIN target), and every SQLite statement targets sqlite_master,
pragma_* or sqlite_stat1. The one exception is documented and narrow: on
SQLite files under 100 MB with no ANALYZE data, a single UNION ALL of
COUNT(*) supplies exact row counts. It reads no column values. Row counts
everywhere else are catalog estimates — pg_class.reltuples,
information_schema.TABLES.TABLE_ROWS — never a COUNT.
Classify
Section titled “Classify”Classification is deterministic and rule-based, not statistical, and it never looks at your data.
Columns run through 30 ordered rules, r01-secret through r30-plain,
first match wins. Every regex runs against a normalized name — userId becomes
user_id, HTMLBody becomes html_body — never the raw identifier. Two rules
are pair detectors rather than single-column matches: a start/end date pair
becomes a date range, and a lat/lng pair becomes a geo point.
PII detection is an independent layer that runs after the primary rule and can
fire regardless of which tag won. Nine kinds — email, phone, ip, person-name,
address, dob, gov-id, payment-id, geo-precise — and any hit sets
maskedByDefault. The government-ID and payment-ID patterns are anchored on
token boundaries for a reason recorded in the source: unanchored, vat matched
inside avatar_url (“a-VAT-ar”), and every avatar column in every generated app
was flagged as a government ID and masked.
Tables get two outputs: a persisted role (one of entity, join-table, log,
people, messages, line-items, system) and a generator-facing shape (one of
people, workflow, events, catalog, join, log, settings, geo, generic), each with
human-readable reasons. Precedence is structural first — a join table is
detected before a name pattern is consulted.
Generate
Section titled “Generate”generatePages(model, { connectionId, intent }) emits pages in three passes:
- Dashboards, capped at 3. Domains are connected components over the relation graph (only edges with confidence ≥ 0.8 count). A domain earns a dashboard only if it has a time axis; otherwise it is skipped with a warning.
- One CRUD page per included table. Which columns appear is a fixed priority ladder — display column, status, money, timestamps, foreign keys, category, person/email, boolean, and so on — capped at 8, with any missing primary-key columns appended as hidden specs so row identity still resolves. Which editor a field gets is decided the same way, per logical type.
- At most one extra archetype page per table — kanban board, calendar, inbox, log viewer, directory and so on — chosen by highest score, and only when the template’s required slots can actually be filled from that table.
System tables and join tables never get a page, though join tables stay in the graph so many-to-many relations still connect.
Generation is deterministic by contract: everything is sorted, slugs are allocated from one registry, and scores are rounded so float association cannot reorder ties. A baseline test pins the output byte-for-byte.
Four intents shape the result: full-admin (the default), read-only-analytics,
crud and support-console. crud skips dashboards and archetypes entirely.
Every emitted page is stamped with a generatedHash — a canonical-JSON SHA-256
of the envelope with the hash itself excluded — and validated against
pageEnvelopeSchema before it leaves the engine. That hash is what makes
regeneration safe: a stored page whose hash no longer matches was edited by a
human, so it is skipped on update and kept rather than pruned on deletion.
6. The meta store
Section titled “6. The meta store”Adminium’s own database is 50 tables, all prefixed adminium_, created by 34
numbered migrations, plus the adminium_migrations ledger the runner creates
itself. The same schema runs on PostgreSQL, MySQL/MariaDB and SQLite.
Portability comes from one module. columnHelpers(dialect) maps eight logical
column types onto each dialect’s physical types, and the choices are opinionated:
every timestamp is epoch milliseconds in an integer column (never a native
datetime type), JSON is always written as a serialized string, and booleans are
coerced on both read and write. Foreign keys are always named table-level
constraints, because MySQL parses column-level REFERENCES and silently discards
them — which would leave a MySQL meta store with no referential integrity at all.
Migrations are up-only and append-only. There is no down, no revert
command, and no rollback path anywhere in the codebase. Each migration is
recorded in an adminium_migrations ledger with a SHA-256 checksum of its own
function source. Before anything is applied, the whole ledger is validated:
editing an already-applied migration aborts with a checksum-drift error, and a
ledger row this build does not recognize aborts with “the database was migrated
by a newer Adminium”.
The tables you will care about most:
| Table | Holds |
|---|---|
adminium_users, _sessions, _api_keys |
accounts (argon2id), opaque sessions (only the SHA-256 of the token is stored), API keys (only the hash, plus a display prefix) |
adminium_roles, _role_permissions, _user_roles |
the RBAC matrix |
adminium_connections |
one row per source database, both DSNs encrypted |
adminium_schema_snapshots |
one immutable row per introspection, with a checksum |
adminium_schema_overrides |
every correction you make to the generated app |
adminium_pages |
every page of the generated app, dashboards included |
adminium_project_files |
one row per project file applied: its hash, when it was applied, and what changed on this server since |
adminium_project_apps |
one row per app the project folder carries: the manifest last applied, why the newest one was not, and a removal waiting for an answer |
adminium_views |
saved filters and per-user dashboard layouts |
adminium_jobs |
the job queue — table-backed, no Redis |
adminium_audit_log |
the append-only audit trail |
There is no adminium_dashboards table; a dashboard is an adminium_pages row
whose template is page-dashboard.
Five columns hold ciphertext, and each is encrypted under a different key
derived from ADMINIUM_SECRET with a purpose-specific HKDF salt — source DSNs,
the bootstrap file’s meta URL, the LLM API key and TOTP secrets can never be
decrypted with one another’s key. A wrong secret produces a dedicated,
self-explaining error rather than a bare crypto failure.
Primary keys are type-prefixed monotonic ULIDs — usr_01J8ME7Q2RZX4V9T6W3YB0KD5N
— incremented within a millisecond so ids from one process always sort in
creation order. Cursor pagination depends on that.
7. Where your customizations live
Section titled “7. Where your customizations live”This is the part that makes the runtime-interpretation model concrete.
When you rename a column, hide a table, mask a field or add a relation the
database never declared, the snapshot is not edited. Snapshots are
immutable. Instead a row is written to adminium_schema_overrides, and the
effective model you see is the snapshot with the active overrides layered on
top.
There are exactly eleven override operations:
table.label table.exclude table.keyFieldcolumn.label column.semanticType column.enumLabelscolumn.pii column.hiddenrelation.add relation.remove relation.labelEach override carries a provenance tier — user, llm or auto — and they
rank in that order. The auto tier exists because of a real bug: the
introspector’s auto-proposed PII masks were originally stored as user, and the
LLM apply step’s user-lock then made every machine guess permanently
uncorrectable. (The value is spelled auto rather than something longer because
the column is varchar(6).)
The practical consequences:
- Re-running introspection does not eat your edits. A new snapshot is a new row; your override rows still layer over it.
- Re-running generation does not eat your page edits either — that is what
the
generatedHashcomparison in Generate is for. - Introspection proposes masks for you: every column the classifier flagged
maskedByDefaultgets acolumn.piioverride written at theautotier, so PII is masked before you have looked at it, and a later human or LLM correction outranks it.
The same rows, as files
Section titled “The same rows, as files”A project adds a second copy of exactly these two things — pages
and override rows — as files in a folder you commit: pages/<address>.json and
schema/<database>.json. The database stays the working copy the server reads
on every request; the files are the copy a pull request can review.
Three rules make that safe rather than a second source of truth:
- Files name nothing that is local to one install. A database is named by
its key in
adminium.config.ts(adminium_connections.project_key), a page by its address. No page id, connection id or destination id is ever written, andadminium checkrefuses a file that carries one — that is what lets the same folder apply to a server that has never seen your laptop. adminium_project_filesremembers each file’s hash as last applied, plus when a change on this server was first seen. Comparing the file, the database and that row is how the server knows whether a file is new, a page was edited here, or both happened — the last being the conflict Studio offers two buttons for.- Hashes compare meaning, not text. Key order, formatting,
$schema, restated defaults and row order are all ignored, so a file edited by hand still counts as equal to Adminium’s own copy.
Which side wins depends on how the process runs: in adminium dev the folder
does, and Studio edits are written back into it; on a server the deploy does,
and an edit made there is kept and flagged until someone pulls it. The
auto-tier masks above are deliberately not written to files: every install
derives its own from its own snapshot.
Schema drift is detected by re-introspecting and structurally diffing two
snapshots — added and removed tables, per-table column changes, type and
nullability changes, relation changes, plus a breaking flag set only when the
change touches something your pages or overrides actually reference. Two caveats
worth stating plainly: column rename detection is not implemented (a rename
reads as a drop plus an add), and in this build the diff is an API call you make
yourself — there is no dashboard surface for it.
8. One request, end to end
Section titled “8. One request, end to end”Clicking a row in a generated table is a useful worked example, because it touches nearly every layer.
1 DataGrid.onRowOpen → rowIdOf(columns, row) single-column PK → the bare value composite PK → a JSON array of the PK values 2 router.history.push("/p/<slug>/r/<recordId>") 3 RecordDetail → GET /api/v1/data/<conn>/<table>/<recordId>?include=inboundCounts─────────────────────────────────────────────────────────────────── server 4 cookie parsed and unsigned; session token hashed and looked up 5 manager.mustFind(connectionId) → 404 if unknown 6 viewFor(connectionId) → latest snapshot + active overrides 7 view.table("<table>") → 422 if not in the snapshot 8 request.can("table:<conn>:<table>:read") → 403 if denied (audited) 9 parseRecordId → fetchByPk → referenceCounts10 maskRow: secret columns dropped, masked columns nulled + `_masked` marker11 reply, with x-request-id echoed on the way outThe invariant that matters is step 7 before step 8. Every identifier that
reaches SQL is a string taken from the stored snapshot, not from the request.
The client’s spelling is resolved to a snapshot table first, and the permission
check is then made against that canonical name. Write payloads get the same
treatment: keys are allow-listed one by one and re-spelled from the snapshot
before they reach an INSERT. Every value binds as a parameter; every column
reference is db.dynamic.ref(<snapshot name>).
Authorization on the data path lives inside the handler rather than in a route
preHandler, precisely because it cannot be decided until the table has been
resolved.
Layered on top:
- Read-only guards. A connection marked read-only, or a table with no primary key, or a view, rejects any non-read action.
- PII masking applies to everyone.
secretcolumns are dropped even for super admins;maskedcolumns are nulled unless the caller may see the personal columns of the table the value lives in:table:…:read_piifor that table, orsystem:connections:managefor every table. Audit before/after images are themselves masked. - A cursor-leak defence. Keyset pagination refuses with a 422 when any sort key is masked for the caller, because the cursor carries the raw pre-mask sort tuple.
- Snapshot-view caching keyed on the snapshot id plus the override count and the newest override’s timestamp — so a new override invalidates immediately.
Dashboards take a different route. A page’s widgets each carry a declarative
binding — a query descriptor, never SQL — and the client extracts them all,
dedupes byte-identical ones, and issues a single POST /api/v1/widget-data/batch
(up to 40 descriptors). The server compiles each descriptor under the same
identifier and masking rules, with a hard 1000-row cap and a 30-second cache
keyed by role scope so two users with different grants can never share a cached
result.
RBAC in one line
Section titled “RBAC in one line”Permissions are a string grammar, matched deny-by-default with a super-admin
bypass:
system:<area>:<verb> e.g. system:connections:managetable:<connectionId>:<schema.table>:<action>page:<pageId>:<view|edit>Granularity is table-level for rows and column-level only for PII masking and secret hiding. There is no row-level filtering.
Authentication is a signed, httpOnly adminium_session cookie carrying an
opaque token whose SHA-256 hash is the only stored form. Passwords are argon2id
at OWASP parameters. TOTP secrets are encrypted; recovery codes are argon2id
hashes. API keys are an alternative principal that acts with exactly one role’s
grants.
9. From stored config to pixels
Section titled “9. From stored config to pixels”The SPA is one build serving every surface. There is no build flag separating
Studio from the Generated App — both are ordinary routes in one code-based
TanStack Router tree, mounted in the same shell. The only difference is a role
check that renders a forbidden state instead of the Studio screen.
Boot is deliberately ordered:
- An inline script in
<head>— before any stylesheet paints — reads localStorage and stampsdata-theme,data-accent,data-density,dirandlangon<html>. This is why there is no theme flash. main.tsxexchanges any desktop boot token, captures a bridge ticket, and awaits the i18next instance (capped at 2 s, with an English fallback that hot-swaps when the real bundle lands).- One
GET /api/v1/bootstrapprimes the session user, roles, resolved theme axes and the nav tree, cached withstaleTime: Infinity.
Generated pages do not create N routes. A single dynamic /p/$slug route:
slug → nav tree lookup (miss → branded 404, no request at all) → GET /api/v1/pages/:pageId → runConfigMigrations (client-side, on every page load) → pageEnvelopeSchema.safeParse → resolvePageTemplate(envelope.template) — 14 page templates → the binding componentFor a dashboard, that binding hands the layout to PageDashboard, which turns
each layout.items[].widget id into a component through widgetRegistry.get()
— a 177-entry map whose component fields are React.lazy references, so each
widget family is its own chunk and you download only the widgets your pages
actually use.
Every failure mode in that chain renders a card rather than crashing: unknown slug, config too new for this build, invalid envelope, unknown template, a throwing template, an unknown widget id. A stored widget config that no longer matches its schema has the offending fields dropped and re-parsed against defaults, emitting a console warning — it never throws at render time.
Widgets never navigate or mutate directly. They emit events — drill-through,
record-open, mutate — which the host turns into router pushes or CRUD calls,
then invalidates the right query keys and pushes an undo toast. Server-side
changes arrive over WebSocket and re-enter the same cache, so a regeneration
refreshes the nav and the open page without a reload.
One note on validation asymmetry, because it surprises people: the server does
not re-validate a page envelope when it reads one. It validates at the write
boundaries — generation and zip import — and the browser validates on read. Both
use the same Zod objects from @adminium/engine/config, which is the whole
point of that subpath being browser-safe.
10. One codebase, four ways to run it
Section titled “10. One codebase, four ways to run it”| Process | Meta store | Notes | |
|---|---|---|---|
| npm / self-host | adminium start in a plain Node process |
your choice | A VPS without Docker |
| A project | adminium dev, or start against a project folder — the same server, told where the folder is |
your choice | Projects |
| Docker | the same command as the image’s CMD, under dumb-init as an unprivileged user |
SQLite volume or external Postgres/MySQL | Docker |
| Desktop | Electron forks the same server as a utilityProcess on 127.0.0.1:0 |
embedded SQLite, offline | — |
A project is not a fourth build. dev is a supervisor that runs the same server
as a child with ADMINIUM_PROJECT_MODE=dev, watches the folder, and rebuilds
the parts that changed; a project image is the published image with the folder
copied in and ADMINIUM_PROJECT_DIR set. Project code — hooks, actions, pages
and widgets — runs in that server process with full access to it, which is why
neither the desktop app nor adminium try ever loads it.
The desktop shell is worth a sentence because the architecture is not what people
expect. Electron does not bundle the server or talk to it over IPC. It forks
the same server files, waits for a handshake message carrying the OS-assigned
port, and points a locked-down BrowserWindow at http://127.0.0.1:<port>. The
renderer is an ordinary browser talking HTTP to a local server. Both native
addons (better-sqlite3, argon2) are Node-API, so the same binary loads under
Node and under Electron — there is no ABI rebuild step.
How @adminium/* becomes @adminiumjs/*
Section titled “How @adminium/* becomes @adminiumjs/*”At pack time the release script (scripts/release/publish-npm.mjs) rewrites
the manifests it packs: @adminium/server becomes @<scope>/adminium, every
other package keeps its basename under the new scope, and internal
workspace:* edges become npm aliases —
"@adminium/engine": "npm:@adminiumjs/engine@0.3.36"— so the compiled import '@adminium/engine' specifiers keep resolving without
touching a line of source.
The CLI goes one step further. Each internal package it loads is packed on its
own with its dependency lists removed, then unpacked into
node_modules/@adminium/<name>/ next to the CLI’s own files. The CLI lists them
in bundleDependencies and still names each one as an alias dependency,
because npm only packs a bundled package that a dependency points at. Its
third-party dependencies are its own plus the ones in
scripts/release/server-runtime-deps.json, a list traced from the built code
that fails CI when it falls out of date.
Every tarball is X-rayed before anything is published, and the four published packages go up in topological order. Authentication is npm trusted publishing over Actions OIDC; there is no long-lived token.
All twenty-one workspaces share one version number, because changesets is configured
with a single fixed group. A patch to one package moves all twenty-one, published
or not.
11. What is deliberately not here
Section titled “11. What is deliberately not here”An honest anatomy includes the absences.
- No code generation. There is no emitted codebase to own or maintain, and
export-zipgives you the server plus its configuration bundle — never something that looks like a generated app. A project writes settings as files, andadminium ejectwrites one small React file that hands the same settings toGeneratedPage. Nothing expands a page into components. - No email provider integrations. This bullet used to say the broader
thing — that nothing in the repository sends mail — and that was wrong end to
end. Mail ships:
email.smtpresolves to a real nodemailer transport (lazily imported, so a process that never sends never loads it),email.sendis a registered job kind onadminium_jobs, and password resets, user invitations, notification emails and the template-editor’s test send all queue through it. What is genuinely absent is one end of it: there is no SES/Postmark/Resend adapter — SMTP or nothing. The transport itself is configured on /studio/settings (the “Email (SMTP)” card, super admin only), which writesPUT /api/v1/settings/email; a config-bundle import sets the same key.smtpConfiguredis a read-only consequence of that setting rather than the whole of it: it exists so the UI can grey out a Send button instead of offering one that would queue nothing. - No outbox table. A queued message is an ordinary
adminium_jobsrow, so retry, backoff and dead-lettering come from the queue that already has them. The rendered body is sealed with AES-256-GCM under a purpose-scoped key becauseGET /jobs/:idcan read a payload, and the rendered body of a reset mail contains the plaintext single-use token thatadminium_password_resetsdeliberately stores only as a hash. - No PDF or PNG report rendering. Scheduled reports produce a CSV through
the export pipeline and an in-app notification; the stored
pdf/pngformat is preserved intent for a later release. - No automatic retention sweeps beyond exports. Garbage-collection methods
exist for the audit log, jobs, sessions and password resets, but nothing calls
them on a schedule. Only
retention.exportsDaysis actually read. - No row-level security in the permission model, and no column-level permissions beyond PII masking and secret hiding.
- No rollback for meta migrations. Forward-only, by design.
- No statement timeout on the CRUD query path — session limits are configured on the introspection adapters, which the CRUD path does not use.
Where to go next
Section titled “Where to go next”- The packages, one by one — the same system taken apart package by package.
- Projects — the folder a developer owns: page files, hooks, actions, React pages, and deploying one.
- Where to put the meta store — the one placement decision with a hard rule.
- CLI reference and REST API.
- Import a schema file — the path with no live database.
- LLM assist — how the enrichment round-trip validates a model’s reply before trusting any of it.
- Monorepo setup — if you want to build this from source.
Adminium is AGPL-3.0-only. If you plan to host it for other people, read the license before you plan anything else.