Decision log
Historical decisions made while building Nelcota, with the why. Newest at the end. Earlier milestones are snapshots: current server source requires Rust 1.99, the Rust SDK supports 1.88, and a bare CLI without server configuration prints help. Use current operating guides and release notes over superseded choices. Decision numbers and historical rationale remain intact.
Milestone 1
D1. Apache-2.0 license. Chosen by the maintainer (2026-10-06).
D2. Name and binary. The project is called Nelcota; the single binary is
nelcota (crate nelcota-server). In Milestone 4 it gains subcommands
(nelcota init, nelcota up...); without arguments it starts the server.
D3. The admin and cli crates only arrive in Milestones 4/5. Avoids
empty crates.
D4. Integration tests in crates/server/tests/. The workspace is virtual
(there is no package at the root), and Cargo integration tests must belong to a
package. server exposes the Router in a lib so the tests use the same code
as production.
D5. A single URL. NELCOTA_DATABASE_URL points to a role that owns the
schema (the postgres superuser in Docker) and is used only for migrations and
to set the authenticator password. The API uses the same host/database,
swapping user/password for authenticator / NELCOTA_AUTHENTICATOR_PASSWORD.
D6. The authenticator password stays out of migrations. Migrations hold
no secrets. At startup the server computes the SCRAM-SHA-256 verifier on the
client side and runs ALTER ROLE ... PASSWORD 'SCRAM-SHA-256$...': the plain
password never travels over the wire nor shows up in Postgres' statement log.
D7. set_config('role', $1, true) instead of SET LOCAL ROLE x. Both are
equivalent (SET LOCAL ROLE is sugar for set_config('role', ..., true)), but
set_config takes a parameter, sets role and claims in a single round trip and
avoids any SQL built as a string. The role value comes from a closed enum.
D8. Pool with RecyclingMethod::Fast. Role and claims are
transaction-scoped and die on COMMIT/ROLLBACK (including a drop without commit,
which sends ROLLBACK). No DISCARD ALL is needed on each checkout. Covered by
the pool-leak and rollback tests in crates/server/tests/rls.rs.
D9. Internal migrations in nelcota.schema_migrations. A schema of its
own, without GRANTs to the API roles, so it does not show up in public
(which the automatic API exposes). An advisory lock serializes bootstrap
across instances.
D10. The example table stays out of migrations. examples/todos.sql is
applied by the tests and optionally in dev. Nelcota's migrations create no
business tables in anyone's database.
D11. Extensions in the extensions schema. pgcrypto and
pg_stat_statements stay out of public. pg_stat_statements requires
shared_preload_libraries=pg_stat_statements (already in the dev compose and
in the tests).
D12. Provisional HS256 JWT (superseded by D20). Milestone 1 uses HS256
with a shared secret (≥ 32 characters) behind the JwtVerifier trait.
Milestone 2 adds EdDSA + JWKS as the default, and HS256 stays as a simple mode.
D13. Claim rules. role is required and must be anon, authenticated
or service_role. authenticated requires a uuid sub (so auth.uid() never
fails the cast). aud is not verified yet (it joins the contract in Milestone
2). 30 s leeway on exp.
D14. No token = anon; invalid token = 401. An Authorization header
that is present but invalid (expired, wrong signature, scheme other than
Bearer, unknown role) never silently becomes anon.
D15. 401 vs 403 on missing permission. insufficient_privilege (42501)
becomes 401 for anon (needs to sign in) and 403 for the other roles, as in
PostgREST.
D16. rsa ignored in cargo audit/cargo deny (RUSTSEC-2023-0071). It
comes through jsonwebtoken's rust_crypto backend. The Marvin Attack affects
operations with the RSA private key; Nelcota never signs with RSA. The
aws_lc_rs backend would avoid the dependency, but requires a C/CMake
toolchain in the build, which hurts the "simple static binary". Revisit if
RS256 is ever used for signing.
D17. Libraries outside the prompt's table.
uuid: validates thesubclaim and is Postgres' native type for ids.postgres-protocol: already a transitive dependency oftokio-postgres; used to compute the SCRAM verifier (D6).http-body-util(dev only): reads response bodies in tests.
D18. Postgres without TLS on the internal network. The database is only reachable over Docker's internal network (or localhost in dev). TLS to the database waits for a remote-database scenario.
D19. Edition 2024, MSRV 1.88. The current testcontainers-modules already
requires 1.88.
Milestone 2
D20. EdDSA (Ed25519) as the default; HS256 as legacy. The private key comes
from NELCOTA_JWT_PRIVATE_KEY (PKCS#8 PEM or the one-line base64, which fits
in a .env). With an EdDSA key and an HS256 secret configured, the server
signs with EdDSA and accepts both when verifying (zero-downtime migration). The
kid is the RFC 7638 thumbprint.
D21. Internal nelcota_auth role for the auth schema. Auth handlers run
with SET LOCAL ROLE nelcota_auth (a fixed string). No JWT can assume it (the
Role enum does not contain it), and the API roles have no GRANT on auth.*.
Discarded alternative: SECURITY DEFINER functions, which would spread logic
into PL/pgSQL with no real gain.
D22. 32-byte opaque refresh token, stored only as SHA-256. SHA-256 (not argon2) because the token already has 256 bits of entropy; lookup is through a unique index, with no comparison in the application.
D23. Reuse detection revokes the session (family), not the user. Other sessions stay valid. No grace window for now (documented).
D24. Logout revokes the session; the access JWT is valid until exp.
Access tokens are stateless and short (15 min by default). Checking revocation
on every request would cost an extra database round trip.
D25. Argon2id with RustCrypto's default parameters (m=19 MiB, t=2, p=1).
Runs in spawn_blocking, with at most min(cores, 4) concurrent hashes (peak
< 80 MiB). Logging in with an unknown email verifies against a dummy hash, so
the response time does not reveal whether the account exists.
D26. In-memory rate limit, fixed 1-minute window (the window became a GCRA in D69), per IP (signup/token)
and per email (login). One binary per install makes Redis unnecessary. The IP
comes from X-Forwarded-For (rightmost entry) only with
NELCOTA_TRUST_PROXY=true.
D27. Signing up with a duplicate email answers 409. Without email confirmation in the MVP, hiding the account's existence at signup would bring no real protection (login still does not reveal it).
D28. New libraries. argon2 (it was in the table); sha2, base64 and
getrandom were already transitive dependencies (jsonwebtoken/RustCrypto)
and are used directly at the same versions, without growing the tree. The ISC
license is allowed in cargo deny (it comes from simple_asn1, used to read
PEM).
Milestone 3
D29. Dynamic SQL with our own builder, no sea-query. Every URL value
becomes a text parameter, and Postgres converts it to the column type
($1::text::<catalog type>); inserts and updates use
json_populate_recordset/json_populate_record, and RPC uses json_to_record.
That way Postgres validates types as it would a literal, and the application
does not need to map types. sea-query binds each value to a Rust type (and
sea-query-postgres would require that mapping), besides not expressing these
patterns well. The builder is ~400 lines with three auditable rules:
identifiers only from the catalog and always quoted; cast types only from the
catalog; values always parameters. Unit and integration tests cover injection
on both sides.
D30. OpenAPI built with serde_json, no utoipa. utoipa generates the
specification at compile time from Rust types; ours comes from the catalog at
run time. The document is filtered by the request role's privileges (like
PostgREST's follow-privileges mode).
D31. Catalog reload through an event trigger + LISTEN nelcota. The
trigger runs on ddl_command_end and sql_drop (including GRANT/REVOKE). The
listener batches bursts (100 ms), reconnects with backoff and reloads on every
reconnect. Without a superuser, the migration proceeds without the trigger and
reloading is manual (NOTIFY).
D32. PATCH/DELETE require a filter. Unlike PostgREST (which allows it and lets RLS limit it), we refuse with 400. The cost is an explicit filter when the intent really is to hit everything.
D33. Batches with different keys respect DEFAULT. Rows are grouped by
column set and each group becomes an INSERT (CTEs in one statement). In
PostgREST, without Prefer: missing=default, missing columns become NULL; we
find DEFAULT the least surprising behaviour.
D34. statement_timeout on the authenticator role. ALTER ROLE anon SET ... has no effect with SET ROLE (Postgres only applies a role's settings at
login). Bootstrap applies NELCOTA_STATEMENT_TIMEOUT_SECS (default 10 s) to
authenticator, and the error becomes 504.
D35. Postgres' default kept for EXECUTE on functions (PUBLIC). Changing
the default privileges would hide plain Postgres behaviour; the documentation
and the panel warn about it.
D36. New libraries. form_urlencoded, futures-util and bytes were
already transitive dependencies (axum/tokio-postgres); tower-http's
compression-gzip adds flate2.
Milestone 4
D37. One binary, two roles. nelcota without arguments (or serve)
starts the server; the subcommands operate the install. On a host,
migrate/types/token are forwarded to the app container (docker compose exec), which is what can reach Postgres on an internal: true network; with
NELCOTA_DATABASE_URL in the environment, they run directly.
D38. Postgres profiles become -c key=value in the compose file. The
deploy/postgres/profiles/*.conf files are the source; init turns them into
arguments of the postgres command. Using config_file would replace the
image's whole postgresql.conf (and change the default pg_hba.conf
directory).
D39. Backups with pg_dump -Fc; S3 uploads with the amazon/aws-cli
image. Nothing to install on the host and no home-grown S3 signing (SigV4).
Restore in a single transaction (--single-transaction --exit-on-error). PITR
with WAL-G is pending (daily dumps: an RPO of up to 24 h).
D40. Upgrade with rollback = previous image + restore of the pre-upgrade backup. The new version may have applied internal migrations the previous one does not know (refinery refuses to start with an "unknown" migration). That is why the rollback also restores the database and the matching disk-file snapshot. The proxy stays in maintenance from before the old app stops until the upgraded or restored app is healthy, so successful public writes cannot be lost between capture and rollback. Failure-path tests cover this sequencing.
D41. scratch image with a musl binary; built-in healthcheck. No shell or
curl in the image: nelcota healthcheck performs GET /health over plain TCP.
D42. User migrations in refinery's format (V<n>__<name>.sql), in
nelcota.user_migrations, separate from the internal ones
(nelcota.schema_migrations). CRLF is normalized before the checksum (a
Windows checkout does not diverge).
D43. install.sh installs Docker with the official script if it is
missing (as root and without NELCOTA_SKIP_DOCKER=1): that is what makes
the "3 commands" possible on a fresh VPS. The binary's SHA-256 checksum is
always verified.
D44. init does not enable the firewall without --firewall. Enabling
ufw remotely with the wrong SSH port locks the owner out of the machine. By
default it only recommends it.
Milestone 5
D45. Panel with server-side HTML + ~2 KB of our own JS, no HTMX
(superseded by D50). Pages are ordinary forms; the only interactive part (the
SQL editor) fits in a few lines of fetch. CSP script-src 'self' without
unsafe-inline. Assets embedded with rust-embed.
D46. Panel login separate from end users. Email + argon2id hash in the
.env (generated by init; the password is shown once). In-memory sessions
(restarting the server signs the admin out), HttpOnly; SameSite=Strict
cookie (Secure behind the proxy), login rate limit and an
Origin/Sec-Fetch-Site check on every POST (CSRF).
D47. The panel uses the admin connection. The admin owns the database, as
in psql. Table writes reuse the API's SQL builder (catalog identifiers,
parameterized values). The SQL editor opens a new connection per run:
BEGIN without COMMIT or SET ROLE do not contaminate the pool. The SQL
text does not go to the log.
D48. Panel values keep Postgres' text (RawValue), without going through
f64: numeric(30,10) shows and is edited with every digit.
Panel design
D49 (superseded by D50). A panel with a database-console look (dark by
default, emerald accent), inspired by Supabase but with its own name, brand
and icons. Inter + JetBrains Mono typography, embedded in the binary (~88 KB,
OFL, see crates/admin/assets/FONTS.md): no Google Fonts, to keep the CSP at
'self' and work offline. Light mode via prefers-color-scheme. Hand-drawn
SVG icons (no icon package). On the overview, the row count is exact for
tables under 10 thousand estimated rows and uses the planner's estimate (≈)
for large ones.
Panel in Svelte
D50. Panel in Svelte 5 + shadcn-svelte + Tailwind v4 + CodeMirror 6,
built with Vite (the maintainer's choice, 2026-10-06). The goal is the
interactivity server-side HTML did not deliver well: inline editing in the
grid, side sheets, multiple selection, column sorting, a SQL editor with
syntax highlighting and autocomplete for the database's tables and columns. An
SPA with Vite, no SvelteKit: it injects inline script into the page, which
would break the CSP script-src 'self'. Our own routing (~60 lines, history
API).
D51. The build (crates/admin/ui/dist) is versioned in git and embedded
with rust-embed. That way cargo build, cargo install and the Dockerfile
still need no Node: only whoever works on the panel needs Node. CI's
admin-ui job runs npm ci, npm audit, svelte-check, vite build and
fails if the versioned dist is out of date.
D52. CSP: style-src now accepts 'unsafe-inline'; script-src stays at
'self' only. Svelte transitions, CodeMirror and menu positioning
(floating-ui) inject styles at run time. Inline style runs no code; inline
scripts remain forbidden (there is a test).
D53. The panel becomes a JSON API at /admin/api/*. Same session, same
origin check (CSRF) and the same SQL builder as the REST API. Values travel as
Postgres' exact text (RawValue). Batch deletes in a single transaction. The
SPA is served at any /admin/* (fallback to index.html). Hashed assets get
immutable caching; index.html, no-cache.
D54. CodeMirror loaded on demand. The main bundle stays around 130 KB
gzip; the SQL editor (~145 KB gzip) is only downloaded when its page opens.
The Inter and JetBrains Mono fonts come from @fontsource-variable (OFL),
served by the binary itself.
D55. A panel without ornaments. Removed the generic template patterns: stat cards with icons, gradients, a glowing login, a logo in a colored square, pills and dots on every status, avatars with initials, skeletons, colored toasts, an icon before every title, subtitles explaining the obvious and the "Postgres 17" pill (which was hardcoded, not read from the database). Criterion: color only for problems (no RLS, RLS without policies, error, destructive action); short texts. Inter/JetBrains Mono swapped for IBM Plex Sans/Mono, less ubiquitous and with the feel of a technical tool. Security warnings stay, as plain text.
Several projects per host
D56. One host, N isolated projects. A shared Caddy (the only one to
publish 80/443) and, per project, an app and a Postgres on an exclusive
internal network. A single project is a host with one project: there are not
two models to maintain. The registry (nelcota-host.json) is the source of
truth; the Caddyfile and the public list (shared/projects.json) are
generated from it.
D57. One Postgres per project. Postgres roles apply to the whole server;
in a shared Postgres, projects would share anon, authenticated,
service_role and the authenticator password. The cost (~50 MB of RAM per
project) buys physical isolation, per-project backup/restore and leaving, and
independent upgrades.
D58. Own domain or a subdomain of the base domain. init api.shop.com
derives the name "shop"; init --project blog uses blog.<base-domain>.
Locally: <project>.localhost (browsers resolve *.localhost to the
machine).
D59. Single sign-on through an SSO handoff, with a per-project login
option. Cookies do not cross domains, so the origin panel issues an HS256
JWT (host's shared secret, aud = target project, 60 s, single-use jti)
that travels in the URL fragment and is traded for a session at the target.
In per-project mode there is no shared secret and the handoff does not exist.
Only panel_login.rs writes admin credentials into the .env files;
switching modes regenerates and restarts the apps.
D60. The project list is mounted as a folder and reread on every request.
The app receives shared/ read-only (a folder bind, not a file bind, so the
CLI's atomic rename is visible). New projects show up in the switcher without
restarting anyone. Each project's state comes from GET /health over the
nelcota_edge network, which now includes the version.
D61. Removal always with a final backup in archive/ before deleting
containers and volumes; the site leaves Caddy with caddy reload (without
taking the others down).
Identity and account in the panel
D62. The mascot is the panel's identity, the only exception to D55. It
comes from the NelcotaScreenShare atlas (src/assets/mascot/), cropped with
the poses anchored at the feet and right arm so the figure does not jump when
the expression changes. It is not decoration because it reacts to the login
screen's state: it waves on open, follows the pointer with its eyes, closes
its eyes while the password is typed and looks sad after a refused login. The
eyes are a vector layer over the PNG's (only the pupil moves); with
prefers-reduced-motion the gaze jumps straight to the target.
D63. Full-height sidebar. Project at the top (same height and border as
the topbar), pages in the middle, account at the bottom; the topbar covers
only the content area. Pinned, it takes 240px from xl up; collapsed, it
becomes an icon rail that expands over the content. "Projects" left the menu:
the project switcher already leads there.
D64. The admin photo lives in the project's database, in the nelcota
schema (migration V4, nelcota.admin_avatar). The admin login only exists
in environment variables, so there was nowhere to keep data about them; the
internal schema has no USAGE for any API role. The photo is per project (each
one has its own Postgres). The browser center-crops and scales it to 256px in
WebP; the server only accepts PNG, JPEG or WebP recognized by their leading
bytes, up to 256 KB. The URL carries the version (?v=), so the image can be
cached.
D65. nelcota-core recompiles when migrations/ changes.
embed_migrations! reads the folder without telling the compiler; a
build.rs with rerun-if-changed avoids incremental builds with the old
list.
D66. Password recovery by email, with the project's SMTP. Nelcota runs no
mail server: NELCOTA_SMTP_URL, NELCOTA_SMTP_FROM and
NELCOTA_PASSWORD_RECOVERY_URL (all three or none; half-configured, the
server does not start). The link goes to the app's page with the token in the
fragment; tokens live in auth.one_time_tokens (migration V5) only as
SHA-256, are valid for 1 hour and once, with at most one email per minute per
account. Sending happens in the background so the response time does not
reveal whether the account exists. Using the link changes the password, ends
the sessions and marks the email as confirmed. Sent with lettre over rustls
- ring, with Mozilla's roots embedded (
webpki-roots): thescratchimage has no system certificates and the musl build stays free of OpenSSL. This brought two permissive licenses intodeny.toml: CDLA-Permissive-2.0 (the root list, which is data) and 0BSD (quoted_printable).
D67. Panel DDL becomes a migration, registered as already applied. Every
DDL the panel applies goes through apply.rs and is recorded in
nelcota.panel_changes (migration V6), in the same transaction. "Generate
migration" bundles the pending ones into a file and inserts the matching row
into nelcota.user_migrations with refinery's checksum
(Migration::unapplied(..).checksum()): migrate does not reapply the file
and, with abort_divergent, refuses an edited copy. The generated text is
stored (nelcota.panel_migrations) so it downloads again byte for byte. The
version goes past the highest number in the database, in the folder and among
earlier exports; the folder comes from NELCOTA_MIGRATIONS_DIR or from the
/migrations mount in the container. The SQL editor is left out: it can run
anything, including DML, and separating schema changes would require parsing
the SQL.
D68. English codebase, translated panel. Code, identifiers, comments,
tests, server messages (REST, auth and admin API errors), CLI output, the
recovery email, the README and these docs are English. Only the panel
interface is translated: Portuguese (pt-BR) and English, through typed
per-area catalogs (crates/admin/ui/src/lib/i18n/messages) where the English
object is typed against the Portuguese one, so a missing key fails the type
check. The panel picks the browser language on the first visit and remembers
the choice made in the account menu. Admin API errors carry code and
params ({"error", "code", "params"}), so the panel shows them in the
chosen language and falls back to the server's English text (raw Postgres
errors, for instance). Migrations V1–V6 keep their Portuguese file names and
comments: refinery checksums name and content, and editing them would make
every existing install refuse to start.
D69. Libraries instead of four hand-written pieces. A review of what the code reimplemented kept the deliberate ones (the SQL builder of D29, the OpenAPI of D30, the PostgREST-style parser, TypeScript generation, CSV, secret generation, image sniffing) and replaced four:
- Rate limit:
governor's keyed GCRA instead of a fixed window. The budget refills continuously, so a client can no longer spend almost twice the limit across the turn of a minute, andRetry-Afteris the real wait. Still in memory per process; only thestdanddashmapfeatures. - Session cookie: the
cookiecrate parses theCookieheader and buildsSet-Cookiein one place, instead ofsplit(';')andformat!. No signing or encryption features: the value is an opaque token. - Health probe:
hyper-util's client instead of a hand-written HTTP/1.1 request and response split; a chunked body no longer breaks it. hyper was already in the tree through axum. - Identifier quoting:
postgres_protocol::escape::escape_identifier, already a dependency. String literals in generated DDL keep the hand-written''doubling:escape_literalwould emitE'...'for backslashes and change the text of panel-generated migrations.
D70. or=/and= as PostgREST logic trees. The request's filters are
a tree (Condition): leaves are the existing filters and groups join their
items with OR/AND, optionally negated; the top level stays ANDed, so existing
URLs mean the same. Each group renders as a parenthesized expression, so
precedence never depends on its surroundings, and leaves keep the D29 rules
(catalog columns, quoted identifiers, values as parameters). Depth is capped at
8 and a tree at 100 filters, which bounds the recursive parser on hostile
input. As in PostgREST, or/and are reserved query keys.
D71. Upsert through Prefer: resolution and on_conflict, as in
PostgREST. Each INSERT group (D33) gets ON CONFLICT (key): DO UPDATE SET
for the columns the object sent, except the key, or DO NOTHING; a merge with
nothing left to update falls back to DO NOTHING. The key is the primary key
or on_conflict, whose columns are checked against the catalog; whether they
form a unique key is left to Postgres, and its 42P10 and 21000 errors now
map to 400 instead of 500. RLS needs no special handling: Postgres applies the
INSERT policy to the new row and the UPDATE policy to the existing one.
D72. Relation embedding through foreign keys, one level, as correlated
subqueries. select= accepts [alias:]table[!hint](columns). The relation
comes only from the catalog's foreign keys inside the exposed schema, in
either direction: this table's key gives an object (to_json of a subquery
that matches at most one row, since the key references a unique one), the
other table's key gives an array (json_agg). Each embed is a subquery in the
select list, so it runs in the same transaction with the request's role and
the related table's RLS filters it like a direct read; there is no separate
permission logic. Ambiguous links ask for a hint (column or constraint name);
nesting and self-references are refused for now. The panel's parser keeps
refusing embeds (parse_request), the API uses
parse_request_with_relations.
D73. Nested embeds and per-embed filters, as in PostgREST. An embed's
column list is a full select, so embeds nest, at most 4 levels (each level
is one more correlated subquery per parent row; the cap bounds what a single
URL can stack). Parameters prefixed with an embed's key (items.qty=gt.1,
orders.items.order=...) go to that embed: filters and or/and trees join
the foreign key in the subquery's WHERE, and order/limit/offset page
the embedded array (refused on a single-row embed). They narrow the embedded
rows only; the parent rows stay, with null or []. select is parsed first,
so the parameter order in the URL does not matter. Filters now carry their
column type, so the same SQL builder serves any row alias. Nested subqueries
are aliased by their path (_e0_1) so a child never shadows its parent.
Self-references stay refused, and filtering parents by their embeds
(PostgREST's !inner) is not implemented.
D74. PITR with pgBackRest, inside the project's Postgres container.
pgBackRest instead of WAL-G: Alpine packages it (2.58), so the image stays
postgres:17-alpine plus one apk add. WAL-G only ships glibc binaries, and
moving to a Debian image would change the libc and with it text collation,
silently invalidating the indexes of existing projects. The image is built
locally from postgres/Dockerfile (no second image to publish). Settings
travel as PGBACKREST_* variables from pgbackrest.env (mode 600) through
env_file with required: false, so no config file has to be readable by the
container's postgres user and "PITR on" is just that file existing; archiving
itself is ALTER SYSTEM (archive_mode, archive_command, archive_timeout = 60s), which the profile's -c flags do not touch. Postgres gets a
per-project egress network: the internal one has no route out, and nothing
can connect in through either. Base backups ride on the existing daily
backup --all (full on Sundays, differential otherwise, two fulls kept). A
restore recreates the Postgres container, so its restart count tells a failed
recovery (a target past the archive makes Postgres exit) apart, and ends with
a full backup: the new timeline forks before any later backup on the old one,
which pgBackRest would otherwise refuse to restore from.
D75. Hosts without Docker: one project, systemd units, apt packages.
init --runtime systemd (Debian/Ubuntu, as root) gets Postgres 17 and
pgBackRest from PGDG and Caddy from its repository: the versions match the
Docker hosts and security updates come with apt upgrade. One project per
machine: N isolated projects would mean N clusters on N ports and N system
users, and that isolation is what Docker already gives. The runtime lives in
the registry ("runtime", Docker when absent), and every operation goes
through Project, which turns "start the app", "run this as postgres",
"is Postgres healthy" into docker compose or systemctl/runuser/
journalctl; ops and pitr no longer know which. The app runs a copy of
the binary (/usr/local/lib/nelcota/nelcota) so upgrade can swap it and
keep the previous one for the rollback, in a unit with DynamicUser,
ProtectSystem=strict, no capabilities and only the loopback port. Commands
that need the app's configuration (migrate, types, token) run this
binary as a child process with the project's .env, rather than setting
variables in the CLI's own process. pgBackRest reads the same settings as on
Docker, written as /etc/pgbackrest/pgbackrest.conf (archive_command runs
under the Postgres unit, which does not get the .env).
D76. The image is ghcr.io/nelcota/nelcota-server. The name that
matches the repository, ghcr.io/hanielcota/nelcota, is a package from an
earlier project (now NelcotaScreenShare) whose production still pulls its
latest tag. Pushing there would overwrite that tag, and deleting it would
break that deploy, so this project publishes under another name. The release
image is the released musl binaries copied into scratch
(deploy/release.Dockerfile), linked to this repository by the
org.opencontainers.image.source label.
Storage
D77. Metadata in Postgres under RLS, bytes in an object store. A
storage schema holds storage.buckets and storage.objects (bucket, name,
owner DEFAULT auth.uid(), size, type, etag). Who may read, write or delete
a file is decided by RLS policies on storage.objects, written in plain SQL
like any other policy; the server never decides it. The bytes live behind the
object_store crate (Apache Arrow) with two backends: the local disk (the
default, nothing to configure after nelcota up) and any S3-compatible
service (recommended for production: R2, B2, AWS...). object_store is used
with aws-base + ring, never aws-lc-rs, for the same reason as D16. No
bundled object server: Garage on a single node keeps the files on the same
disk as a plain directory would, for one more service to run, and MinIO's
community edition went into maintenance mode in 2025. The routes follow the
shape of Supabase Storage (/storage/v1/object/{bucket}/{path}), without
promising compatibility with its client; the route words (public, sign,
list) cannot be bucket names.
D78. Uploads stream through the server; keys are versioned. The client
sends the file to Nelcota, which streams it to the store: one endpoint, the
usual Bearer, no CORS on a second origin, and size, type and quota checked
in the same flow. Before taking the bytes, a rolled-back INSERT under the
caller's role checks the policy, so a caller without permission cannot fill
the disk. The bytes go to {bucket}/{version} (a UUIDv7), not to the
object's name; only after the upload completes does a short transaction
insert or update the row under RLS. Holding no connection while bytes flow
keeps slow clients from draining the pool; versioned keys make an overwrite
atomic (readers see the old file or the new one, never half) and keep a
failed upload from clobbering anything. Deleting or replacing a file removes
the old bytes right after the commit: a delete means the file is gone, which
also means a database restore does not bring deleted files back. A periodic
collector removes bytes no row points to (left by a crash mid-upload) once
they are a day old, so it never races an upload in flight. A plain upload
needs only an INSERT policy; overwriting and deleting also need SELECT,
as UPDATE/DELETE with a WHERE do in Postgres. Storage routes run
outside the request timeout and the gzip layer.
D79. User files are served as inert content. Every object response
carries X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox; only raster images, audio, video and plain text are served
inline, everything else (HTML, SVG, PDF...) as an attachment. The type is
sniffed from the first bytes, not taken from the client. Paths reject ..,
empty segments, control characters and over-long names, and are normalized
to Unicode NFC. Signed URLs are short-lived JWTs signed with the project's
keys and bound to one bucket and path. On the S3 backend a download is a
302 to a presigned URL on the provider's domain; on disk the server
streams it with Range and ETag. NELCOTA_STORAGE_PUBLIC_URL can move
public files to their own origin (e.g. files.shop.com).
D80. Storage cannot take the database down. On a single VPS the files and
Postgres share a disk. Every upload has a size cap (per bucket, and a global
NELCOTA_STORAGE_MAX_FILE_SIZE, 50 MiB by default); an optional total quota
covers all buckets; and on the disk backend an upload is refused when free
space would fall under NELCOTA_STORAGE_MIN_FREE_BYTES (1 GiB by default).
In-flight uploads reserve their capacity, and publication checks the resulting
total under a database transaction lock across instances. Replacements credit
their old logical size while reserving disk space for the new version.
Per-user quotas are left to policies, which see the final row (with its
size) on insert. Image transformations and resumable (TUS) uploads stay out:
decoding untrusted images is a steady source of CVEs.
D81. New projects store files on their disk; backups mirror them
(the shared backup mirror is superseded by D82).
nelcota init writes NELCOTA_STORAGE_BACKEND=disk: on Docker the app
mounts projects/<name>/storage (owned by the image's user 65532), on
systemd the unit's StateDirectory. Storage works right after nelcota up,
and moving to S3 is two lines in .env. A file on the same disk as the
database is lost with it, so backup --upload also runs aws s3 sync --delete of that directory to <bucket>/<project>/storage/: keys never
change once written (D78), so each run only sends new files, and
restore --files brings them back. Caddy stops compressing /storage/*,
which would break byte ranges. Projects created before storage keep it off
until they add the two lines and the volume (docs/storage.md).
Panel and backup reliability
D82. Disk backups capture immutable files beside each dump. A shared
mirror loses old versions when later uploads replace or delete them, so it
cannot restore the files referenced by a historical database dump. Each dump
now has its own .files/ directory and SHA-256 manifest. An exported Postgres
snapshot and a SHARE lock on storage.objects keep the copied versions and
dump metadata consistent. Upload files before publishing the dump; retain and
archive the pair together. Restore validates the manifest before stopping the
app and copies immutable versions before restoring the database. Older dumps
remain usable for database-only restores (docs/backup.md).
D83. The panel validates wire contracts generated from Rust. ts-rs and
schemars export TypeScript and JSON Schema from the server DTOs; Ajv emits
standalone browser validators so the existing CSP needs no runtime code
generation. Drift checks run in CI. Database cell values stay text or NULL
(D48). Resource state and upload queues own cancellation, result ordering and
the captured upload destination, with unit and browser regression tests.
D84. SQL display limits bound memory without changing batch semantics.
Dedicated editor connections (D47) allow at most four runs with a 30-second
statement timeout. The client retains up to 1000 rows per result, 8 MiB of
values and 32 displayed results, while the server drains the remaining batch
so later statements still execute. Dropping a request sends Postgres
cancellation and closes its connection. DDL reports applied separately from
catalog_pending: a failed refresh after COMMIT retries with backoff instead
of asking the user to replay an already committed mutation.
D85. Modules follow independent reasons for change. Authentication shares session/token operations and auth-role transactions independently of its HTTP handlers. Panel route composition does not own cookies, response headers or asset serving. Storage operations accept a byte stream and explicit options and return typed results; the public routes and panel share the HTTP adapter. CLI lifecycle, backup sequencing, remote transport and version upgrades have separate owners. The table editor's row module owns loading and write reconciliation while the page owns navigation and presentation. Existing transaction, RLS and snapshot guarantees remain coordinated inside their operation modules (docs/architecture.md).
D86. Operation interfaces own complete lifetimes. Recovery and migration workflows return data without HTTP extraction or response construction. Their atomic database changes stay under one transaction owner. The SQL executor owns its connection, cancellation and display budgets behind one execution method; the route chooses the HTTP response. REST execution returns the JSON already built by PostgreSQL and response metadata, preserving RLS and error ordering. Catalog model, introspection and refresh workers remain internal to the existing facade. Panel SQL and storage modules accept transport adapters and own cancellation, captured destinations and mutation refreshes. Native provisioning and PITR configuration/process adapters are separate from restore sequencing, with no new deployment runtime or command contract.
D87. Feature ownership determines source location. Panel pages, state, adapters, components and unit tests live with their feature. The shell and shared schema/presentation helpers have named locations; generated wire contracts remain authoritative. Admin backend facades group table, policy and account handlers and explicitly export their interface. Integration tests share panel request helpers and keep feature scenarios under one admin suite. This changes source navigation without introducing another runtime, crate layer, HTTP contract or transaction owner (docs/architecture.md).
D88. Facades expose complete operations in the modular monolith. The existing capability crates and single binary remain the architecture. Bucket reads and mutations now have one storage owner shared by the public API and panel, with typed records and errors. HTTP adapters keep their existing wire contracts and input normalization; PostgreSQL still enforces privileges, RLS and nonempty-bucket deletion through its foreign key. Panel account operations own validation, SQL and password/session transactions independently of HTTP. Reuse the existing disk/S3 and mail adapters where implementations vary; add no generic repository, dependency container or extra crate layer. Integration tests exercise bucket policies through the operation interface and prove that a failed session revocation rolls back the password change.
Email links
D89. Signup confirmation and magic links reuse the recovery link. Two
optional pages, NELCOTA_EMAIL_CONFIRMATION_URL and NELCOTA_MAGIC_LINK_URL,
need the D66 SMTP and turn on one flow each; without them nothing changes.
Their tokens share auth.one_time_tokens (V9 widens the kind CHECK to
signup and magiclink), the token in the URL fragment, the one email per
minute per account and kind, and background sending. A token only works as
the kind it was issued for. With confirmation on, signup answers 201 {user}
without a session, and password sign-in answers 400 email_not_confirmed
(only after a correct password, so it reveals nothing) until a signup, magic
or recovery link is opened; POST /auth/v1/resend {type: "signup"} sends a
new link to unconfirmed accounts only. Turning confirmation on in a project
with unconfirmed accounts holds them too: they confirm through resend or a
magic link, or the operator runs UPDATE auth.users SET email_confirmed_at = now(). A magic link only signs in existing accounts: creating accounts from
an unauthenticated email request would let anyone fill auth.users and
squat addresses before their owners sign up. Lifetimes: recovery 1 hour,
confirmation 24 hours, magic link 15 minutes (it signs in by itself). The
links module owns the link lifecycle (token storage, email text, sending,
sign-in by link); recovery keeps only the password change, confirmation
the signup policy and verify the routing by type.
D90. Panel accounts count as confirmed. An account the administrator
creates is marked confirmed at once: the operator vouches for the address,
and with signup confirmation on it would otherwise be held until a magic
link or resend. The users page marks unconfirmed accounts and offers
"Confirm email" for them (POST /admin/api/users/{id}/confirm, idempotent),
which replaces the SQL UPDATE of D89 for projects that turn confirmation
on with older accounts. It sends no email: the panel has no mailer and an
invitation flow is a separate decision.
Sign-in providers
D91. OAuth/OIDC sign-in with PKCE on both legs, and verified-email
linking. Google (OpenID Connect userinfo) and GitHub (REST profile plus its
email list, the only place that says an address is verified) sit behind one
Provider type, each in its own module; another provider is another module.
The app starts at GET /auth/v1/authorize with its own S256 challenge;
Nelcota keeps the sign-in in auth.flow_states (V10) with a separate PKCE
verifier toward the provider, redeems the provider's code server to server,
and sends the person to redirect_to?code=.... The app trades that code and
its verifier for a session at POST /auth/v1/token?grant_type=pkce, so a code
read from a URL, a log or a Referer is useless alone; a code is tried once.
redirect_to must sit under one of NELCOTA_OAUTH_REDIRECT_URLS (same origin,
path below), so /authorize is no open redirect; the callback is
NELCOTA_API_URL/auth/v1/callback. A provider account is identified by its
own id (auth.identities, unique per provider), never by email. A first
sign-in links an existing account only when the provider verified the email:
a confirmed account is linked as is; an unconfirmed one is taken over by the
verified owner, losing the password, sessions and links someone else may have
set up with the address (the pre-registration takeover); an unverified email
that matches an account is refused (email_conflict). New accounts get no
password and are confirmed when the provider verified the email. Refusals and
provider errors go back to the app as ?error=<code>, and an unknown state
is an error page, since there is no trusted place to return to. The HTTPS
client is the reqwest already in the tree for S3, on rustls with ring
(D16), with a 10 s timeout, no redirects and bounded response bodies.
Panel
D92. Code gets syntax colours, the second exception to D55. The SQL
editor colours keywords, function calls, types, strings, numbers and
comments; table and column names keep the text colour. Colour there carries
meaning (it is how people read code) rather than decorating the page. The
palette follows GitHub's light and dark syntax themes, a tested and
readable reference, as --syntax-* tokens next to the other theme colours.
The SQL grammar does not mark function calls, so a small view plugin tints an
identifier right before ( and a built-in keyword glued to it (count(),
but not keywords that open a clause or a list (in (, exists(). Only the
lazily loaded editor changes (D54); read-only SQL blocks stay plain.
D93. Plain language first, technical on demand; the overview guides.
Access rules read as what they mean for the app's users: a table is
Protected, Locked (protection on, no rules) or Unprotected; grants become
"Read only" or "Read and write" for Visitors and Signed-in users; a policy is
a sentence ("Each user only sees and changes their own rows") when it matches
a known shape (public or signed-in reading, owner by auth.uid()), and
"Custom rule" with its SQL in view otherwise. A "Technical details" toggle,
remembered per browser, brings back RLS, roles, grants and SQL. The overview
opens with what needs action (unprotected and locked tables, each linked to
its policies) and a next-steps list that is only ever ticked by the project's
state: a first table, every API table protected, a user, and a first sign-in
through the API (signed_in_users), which means an app is connected. Counts
shrink to one line. Navigation and the accent colour stay (D55, D63).
D94. The SQL editor can run as a visitor or a signed-in user. By default
it still runs as the database owner, without RLS, which the toolbar shows as an
amber "Full access" notice. The notice is also the picker: "Visitor" runs as
anon, and "Signed-in user..." runs as authenticated with the claims of a
real account chosen by email (sub, email), so auth.uid(), auth.jwt(),
grants and policies behave as they do for that person through the API. The
editor's connection is dedicated to one run, so the executor sets the role and
request.jwt.claims for the session before the SQL, as the API does per
request. It is a way to test rules, not a security boundary: the administrator
can always run anything as the owner, and the SQL may even RESET ROLE. The
choice is not persisted, so a reload goes back to the owner.
Client SDK
D95. Nelcota ships its own TypeScript client, a thin layer over HTTP.
@nelcota/client lives in sdk/typescript/ so its contract tests run in the
same CI against the real binary; it borrows no third-party BaaS client.
Every method is one HTTP request with no hidden cache, and access stays with
RLS: the client never decides permissions. It has no runtime dependencies
(only fetch, URL, Web Crypto, AbortSignal, Web Locks and
BroadcastChannel), so it runs in browsers, Node 22+, Deno, Bun and edge
runtimes, is ESM with subpath exports and side-effect free, and keeps the
supply chain to the TypeScript compiler. Results are values ({ data, error })
carrying the server's code untouched; only programming mistakes throw.
Untrusted input never becomes syntax: identifiers are validated, values in
or/and groups and in lists are always quoted, storage paths are encoded
per segment with .. refused, and a service_role token in a browser is
refused because it bypasses RLS. Refresh is single-flight in a process and,
in browsers, serialized across tabs with a Web Lock, because refresh rotation
ends a session whose token is reused (D23). Retries only cover idempotent
requests on 429/503 and honour Retry-After. Types come from
nelcota types, which now also emits each table's Relationships so a
select string can be typed down to its embeds. The server exposes
Retry-After, Preference-Applied and Last-Modified through CORS so a
browser can read them. The SDK follows its own semver and states the minimum
server version it needs.
Tooling
D96. Tooling follows the newest stable releases. The MSRV moves from
1.88 (D19) to 1.99, the stable toolchain CI, the Docker image and the
release build already use, so cargo update picks the newest crates
instead of the newest that build on an old compiler. TypeScript is 7, the
native compiler: the SDK and the panel's plain TypeScript projects check
with it. svelte-check still needs the TypeScript 6 compiler API (TS 7 has
no stable JS API yet), so the panel keeps it as typescript, from
@typescript/typescript6, while tsc comes from typescript-7 and the
scripts call it by path, since both packages ship a tsc binary. Drop the
TS 6 package once svelte-check supports 7. Updates still wait seven days
after release (Dependabot cooldown), so "newest" means newest that has
been out for a week.
Connecting apps and access
D97. The panel connects apps and explains refusals. The API page leads
with the SDK: install, a database.ts download generated by the same code as
nelcota types, and examples typed for the chosen table. Three tools answer
"why can't my app see this?": the table grid can be read as a visitor or a
user (the D94 executor's role and claims, in a rolled-back transaction,
read-only); each bucket lists the storage.objects policies that name it (or
no bucket at all) and adds new ones from five server-side templates, so no
browser text becomes SQL and the panel recognizes them back in plain words;
and the server records API calls answered with 401, 403 or 429 in a
100-entry ring buffer for the policies page. That log is in memory on
purpose: no write per refused request, nothing to retain or purge, and it is
per process, which matches one panel per project. It keeps the path, not the
query string or token, and reads the role by verifying the token again only
for refused calls. The sign-in page is read-only because those settings come
from the environment. Pages off the main path (policies, a bucket, sign-in)
load on demand so the main chunk stays inside its 900 kB budget.
Panel redesign
D98. A dashboard look with real traffic, replacing D55's restraint and D63's sidebar. The panel follows a reference dashboard the user maintains: its tokens (oklch page and card tones, a bright green for actions, a 0.75rem base radius giving 26.4px cards), a three-column top bar with the pages as short pills and an account chip, full-width pages with 32px gutters, 24px titles inside and a large one on the overview, borderless cards, pill searches and lighter tables. The overview mirrors the reference layout: one period selector for the page, the period's traffic as the headline with meters, stat cards beside a chart, and three cards below (rows per table, recently blocked calls, what needs attention). The charts need data over time, so the server counts API requests per minute (refused, server errors, a latency histogram for p95) for 24 hours in memory, alongside the D97 log: no write per request, gone on restart, which the overview says. As in the reference, requests are a green area and refused calls a grey one; a grey series alone would fail a colour-vision check, so values stay reachable through the series filters, the tooltip, a keyboard crosshair (a slider for assistive technology) and a table view. The mascot and the syntax colours (D62, D92) stay; plain language first (D93) stays.
Later passes settled the rules new screens follow:
- Surfaces, darkest to lightest in dark mode: page (graphite, a step lighter than the reference at the user's request), card (rounded-3xl), well (rounded-2xl insets inside a card: code, tiles, rows, empty states), field (inputs, a step above the well). In light mode the page is grey, cards and fields white, wells light grey. Boxes are filled, not outlined; a dashed border marks only drop zones and "add" tiles, and a tinted fill (destructive or warning at 10%) marks danger and alerts.
- Top bar: a project chip (mascot, project, environment, a dot for whether the server answers) and the account chip balance the six centred pills; search is a round button. Tables, SQL and Migrations share the Database pill, with tabs between them inside.
- Page grid: pages cap at 1440px. Figures lead pages that have them, on the same tracks as the columns below; side columns are 24rem. Grids start at the container's width, so nothing scrolls sideways on phones.
- Contrast: text tones are checked against every surface in both themes (4.5:1 or more for body text), not eyeballed.
Established libraries for protocols and panel infrastructure
The panel uses TanStack Query for concurrent read deduplication and optional freshness windows. Explicit reloads still fetch from the server. Mutations invalidate reads; session changes clear both cached and pending results, and callers receive independent clones so editing a row cannot edit shared data. TanStack Table owns row selection and column visibility using stable primary keys; the existing keyboard editing, server ordering and permissions remain part of the grid.
Uppy and XHRUpload handle raw-body storage uploads and progress. Automatic
retries are disabled because a failed connection can follow an already committed
upload. Destination snapshots and conflict decisions stay in the upload queue.
Shiki replaces regular-expression highlighting with lazy-loaded language
grammars and its JavaScript engine. Tokens remain escaped Svelte text; no
runtime eval, WebAssembly or Node APIs are needed in the browser.
Scalar renders the anonymous OpenAPI catalog in a same-origin frame, isolating its styles and hash navigation from the panel. Its assets are included in the Rust binary; external fonts, telemetry, the agent and request proxy are disabled. Only that reference document permits same-origin framing. The rest of the panel keeps its frame restrictions. Scalar is loaded only when the reference is opened.
The Rust oauth2 library builds authorization URLs, S256 PKCE and token exchanges; openidconnect parses Google's typed user-info claims. Both use the existing bounded reqwest transport, without adding a second HTTP stack. Nelcota retains its one-time state, account linking and session policies. These dependencies do not add a JavaScript server: Rust serves the compiled panel and backend.
D99. Client conversions preserve Postgres value identity. JSON numbers
remain numeric when conversion preserves their decimal value. The TypeScript
SDK keeps unsafe integers and decimals that would lose digits as strings,
including nested JSON values and RPC results. Generated numeric types that
can exceed JavaScript precision use number | string, so callers must handle
both forms and can reuse exact values in filters and writes. This preserves
the existing HTTP protocol and needs no runtime dependency or JSON reviver
source support. PostgreSQL array elements can be null even in NOT NULL columns;
generated scalar and enum arrays therefore use (T | null)[]. Column
nullability remains separate. Regenerate schema types after updating the SDK.
D100. Panel database work has bounded budgets and indexed storage pages. Table previews reuse one admin connection and roll back the caller's role before reading owner metadata. Small-table counts probe at most 10,001 rows with a 250 ms statement timeout. When a table outgrows its small estimate, its total becomes unknown until statistics provide a larger estimate.
Table exports use two dedicated connection slots shared by all panel sessions, with a 30-second statement timeout and a two-minute response lifetime. Dropping a response cancels its query. Completion checks the deadline directly and arbitrates atomically with interruption, so a delayed watchdog cannot report a late export as complete. Admission stays occupied during bounded cancellation.
Storage directory pages seek through a generated parent-path byte key and skip each returned folder's subtree. Immediate files use an ordered generated scope-fingerprint/name index; exact bucket and parent checks reject collisions. Every seek remains subject to the caller's object RLS. Listing transactions disable bitmap scans locally to favor ordered seeks before statistics catch up. The pure key helpers allow public execution to preserve custom SQL writers; their generated values convey no additional access privileges.
V14 indexes the stable creation-time/ID user order. V15 computes stored directory keys and scope fingerprints and builds their indexes under the migration lock. Large installations need time for this one-time metadata rewrite. Upgrade tests verify original rows, schema objects, policies and files, including legacy SQL paths whose multibyte names exceed the HTTP byte limit.
D101. Updates use a separately enrolled native control plane. The same
binary runs an unprivileged HTTP/UI role and a restricted root IPC worker, on a
management origin independent of project traffic. Project/SSO/service-role
credentials grant no host authority; root-owned enrollment binds operator scope
to a project incarnation. Only official, ready, digested and attested stable
artifacts are eligible. The deployment capability owns typed receipts, shared
host leases and durable transitions; CLI adapters own runtime effects and the
admin crate owns HTTP/embedded UI. Both public-traffic commit and restarting a
restored version are irreversible data boundaries. Candidate routes/background
effects stay gated by an operation identity. Production installation starts
disabled until exact-binary VPS fault/offsite evidence passes. See
the repository-only operating guide at docs/panel-updates.md and G03 at
docs/goals/panel-updates-and-recovery.md. These are not registered panel guides.
Known pending items
- Filtering parent rows by their embeds (
!inner) and self-referencing embeds. nelcota.com/installshould redirect tohttps://raw.githubusercontent.com/Nelcota/Nelcota/main/scripts/install.sh.