323 lines
10 KiB
Markdown
323 lines
10 KiB
Markdown
# Mercury — Design Spec
|
|
**Date:** 2026-06-16
|
|
|
|
## Overview
|
|
|
|
Mercury is a monolithic, high-performance universal CRUD API written in Rust. It accepts HTTP requests, maps them to PostgreSQL tables, generates and caches SQL dynamically, and exposes an admin suite (with a Vue 3 frontend) for managing queries, users, permissions, and route security. A JWT-based bitmask permission system gates all admin operations.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
HTTP Request
|
|
↓
|
|
Blacklist Middleware (in-memory glob match)
|
|
↓
|
|
Auth Middleware (JWT decode + permission bit check, admin routes only)
|
|
↓
|
|
Axum Router
|
|
↓
|
|
Query Cache (DashMap<String, (CachedQuery, Instant)>)
|
|
↓ cache miss
|
|
Query Registry (PostgreSQL: queries table)
|
|
↓ query not yet registered
|
|
Query Builder (auto-generates SQL from route + HTTP method)
|
|
↓
|
|
PostgreSQL (sqlx PgPool)
|
|
```
|
|
|
|
**Stack:**
|
|
- `axum` — async HTTP framework (tokio-native)
|
|
- `sqlx` — async PostgreSQL driver, raw query support, compile-time checked migrations
|
|
- `DashMap` — concurrent hashmap for the query cache
|
|
- `glob` — compiled pattern matching for the route blacklist
|
|
- `jsonwebtoken` — JWT encode/decode
|
|
- Vue 3 + Vite + `@nychthemeron/library` — frontend, served as static files by Rust
|
|
- `tower-http::ServeDir` — serves `ui/dist/` at `/`
|
|
|
|
---
|
|
|
|
## Endpoints
|
|
|
|
### Public
|
|
|
|
```
|
|
POST /auth/login → returns JWT
|
|
```
|
|
|
|
### CRUD (no auth, blacklist-checked)
|
|
|
|
```
|
|
GET /api/{table} → SELECT * FROM {table} [?col=val filters]
|
|
GET /api/{table}/{id} → SELECT * FROM {table} WHERE id = {id}
|
|
POST /api/{table} → INSERT INTO {table}
|
|
PUT /api/{table}/{id} → UPDATE {table} WHERE id = {id}
|
|
DELETE /api/{table}/{id} → DELETE FROM {table} WHERE id = {id}
|
|
```
|
|
|
|
`users` and `permissions` are blacklisted by default — only accessible via the admin suite.
|
|
|
|
### Admin — Query Registry (JWT + `ADMIN_QUERY` bit)
|
|
|
|
```
|
|
GET /admin/queries → list all registered queries
|
|
POST /admin/queries → register raw SQL template
|
|
GET /admin/queries/{identifier} → get query by slug identifier
|
|
PUT /admin/queries/{identifier} → update SQL template or metadata
|
|
DELETE /admin/queries/{identifier} → remove from registry + evict from cache
|
|
GET /admin/queries/{identifier}/execute → execute query with ?param=val bindings
|
|
```
|
|
|
|
`{identifier}` is the human-readable slug (e.g. `get-user-orders`), not the internal UUID. The UUID is used internally only.
|
|
|
|
### Admin — Cache (JWT + `ADMIN_CACHE` bit)
|
|
|
|
```
|
|
GET /admin/cache/stats → current size, hit count, miss count
|
|
DELETE /admin/cache → flush entire cache
|
|
```
|
|
|
|
### Admin — Users (JWT + `SUPER_ADMIN` bit)
|
|
|
|
```
|
|
GET /admin/users → list users
|
|
POST /admin/users → create user
|
|
GET /admin/users/{id} → get user
|
|
PUT /admin/users/{id} → update user
|
|
DELETE /admin/users/{id} → delete user
|
|
POST /admin/users/{id}/permissions/grant/{bit_value} → OR bit into mask
|
|
DELETE /admin/users/{id}/permissions/revoke/{bit_value} → AND NOT bit from mask
|
|
```
|
|
|
|
### Admin — Permissions (JWT + `SUPER_ADMIN` bit)
|
|
|
|
```
|
|
GET /admin/permissions → list all permission definitions
|
|
POST /admin/permissions → create custom permission (auto-assigns next available bit_value)
|
|
PUT /admin/permissions/{id} → update name/description
|
|
DELETE /admin/permissions/{id} → remove permission definition
|
|
```
|
|
|
|
### Admin — Blacklist (JWT + `SUPER_ADMIN` bit)
|
|
|
|
```
|
|
GET /admin/blacklist → list all entries
|
|
POST /admin/blacklist → add route pattern
|
|
PUT /admin/blacklist/{id} → update entry
|
|
DELETE /admin/blacklist/{id} → remove entry
|
|
```
|
|
|
|
Any write to the blacklist immediately reloads the in-memory blacklist from the DB.
|
|
|
|
### Frontend (served by Rust)
|
|
|
|
```
|
|
GET / → serves ui/dist/index.html (SPA entry)
|
|
GET /assets/* → static assets
|
|
```
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
### `users`
|
|
| column | type | notes |
|
|
|---|---|---|
|
|
| id | SERIAL PRIMARY KEY | |
|
|
| username | VARCHAR UNIQUE NOT NULL | |
|
|
| password_hash | TEXT NOT NULL | bcrypt |
|
|
| permissions_mask | NUMERIC NOT NULL DEFAULT 0 | u128 bitmask |
|
|
| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
|
|
|
### `permissions`
|
|
| column | type | notes |
|
|
|---|---|---|
|
|
| id | SERIAL PRIMARY KEY | |
|
|
| name | VARCHAR UNIQUE NOT NULL | e.g. `READ` |
|
|
| bit_value | NUMERIC UNIQUE NOT NULL | power of 2, up to 2^127 |
|
|
| description | TEXT | |
|
|
|
|
### `queries`
|
|
| column | type | notes |
|
|
|---|---|---|
|
|
| id | UUID PRIMARY KEY DEFAULT gen_random_uuid() | |
|
|
| identifier | VARCHAR UNIQUE NOT NULL | human-readable slug |
|
|
| sql_template | TEXT NOT NULL | raw SQL with :param placeholders |
|
|
| description | TEXT | |
|
|
| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
|
| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
|
|
|
### `blacklist`
|
|
| column | type | notes |
|
|
|---|---|---|
|
|
| id | SERIAL PRIMARY KEY | |
|
|
| pattern | VARCHAR NOT NULL | glob-style, e.g. `/api/users/**` |
|
|
| method | VARCHAR | NULL = all methods |
|
|
| reason | TEXT | |
|
|
| active | BOOLEAN NOT NULL DEFAULT true | |
|
|
| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
|
|
|
### Seed Data (migration)
|
|
Default permission definitions:
|
|
|
|
| name | bit_value |
|
|
|---|---|
|
|
| READ | 1 |
|
|
| WRITE | 2 |
|
|
| DELETE | 4 |
|
|
| ADMIN_QUERY | 8 |
|
|
| ADMIN_CACHE | 16 |
|
|
| SUPER_ADMIN | 32 |
|
|
|
|
Default user: `admin` / `admin`, `permissions_mask = 63` (all bits set).
|
|
|
|
Default blacklist entries: `/api/users/**` and `/api/permissions/**` (method: NULL).
|
|
|
|
---
|
|
|
|
## Query Cache
|
|
|
|
**Structure:** `DashMap<String, (CachedQuery, Instant)>`
|
|
|
|
- Key: deterministic string derived from `{METHOD}:{table}:{params}` for auto-generated queries, or query `identifier` for named queries
|
|
- Value: the compiled query + timestamp of last access
|
|
- Hit/miss counters: two `AtomicU64` values on the shared state struct
|
|
|
|
**Eviction:** a tokio background task runs every `CACHE_SWEEP_INTERVAL_SECS`. It iterates the map and removes entries where `last_accessed.elapsed() > CACHE_IDLE_TIMEOUT_SECS`.
|
|
|
|
**Capacity:** before inserting a new entry, if `cache.len() >= CACHE_MAX_CAPACITY`, the entry with the oldest `last_accessed` is evicted first.
|
|
|
|
**Cache invalidation:** deleting or updating a query via the admin suite immediately removes it from the DashMap.
|
|
|
|
---
|
|
|
|
## Blacklist Cache
|
|
|
|
**Structure:** `Arc<RwLock<Vec<(BlacklistEntry, glob::Pattern)>>>`
|
|
|
|
- Loaded from DB at startup
|
|
- Reloaded (write lock, full rebuild from DB) on any admin write to the `blacklist` table
|
|
- Checked in middleware on every request before routing: if any active entry matches `(method, path)`, return `403 Forbidden`
|
|
|
|
---
|
|
|
|
## Permission System
|
|
|
|
Permissions are u128 bitmasks. The JWT claims embed the user's current `permissions_mask`. Middleware extracts the JWT and performs a bitwise AND check — no DB lookup per request.
|
|
|
|
```
|
|
READ = 1
|
|
WRITE = 2
|
|
DELETE = 4
|
|
ADMIN_QUERY = 8
|
|
ADMIN_CACHE = 16
|
|
SUPER_ADMIN = 32
|
|
```
|
|
|
|
Custom permissions auto-assign the next unused power-of-2 bit_value. Up to 128 distinct permission bits supported.
|
|
|
|
`permissions_mask` stored as PostgreSQL `NUMERIC` (arbitrary precision), converted to/from `u128` in the Rust data layer.
|
|
|
|
---
|
|
|
|
## JWT
|
|
|
|
- Algorithm: HS256
|
|
- Claims: `sub` (username), `permissions` (u128 as string), `exp`
|
|
- Secret: `JWT_SECRET` env var
|
|
- Passed as `Authorization: Bearer <token>` header
|
|
- Token expiry: configurable via `JWT_EXPIRY_SECS`
|
|
|
|
---
|
|
|
|
## Configuration (env vars)
|
|
|
|
```env
|
|
DATABASE_URL=postgres://mercury:mercury@db:5432/mercury
|
|
JWT_SECRET=changeme
|
|
JWT_EXPIRY_SECS=3600
|
|
CACHE_MAX_CAPACITY=10000
|
|
CACHE_IDLE_TIMEOUT_SECS=300
|
|
CACHE_SWEEP_INTERVAL_SECS=60
|
|
```
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
Mercury/
|
|
src/
|
|
main.rs -- startup: config, DB pool, cache init, router mount
|
|
config.rs -- Config struct loaded from env
|
|
auth/
|
|
mod.rs -- JWT encode/decode, Claims struct
|
|
middleware.rs -- axum middleware: validate JWT, check permission bit
|
|
cache/
|
|
query_cache.rs -- DashMap cache + sweep task + hit/miss counters
|
|
blacklist_cache.rs -- Arc<RwLock<...>> + glob::Pattern, reload fn
|
|
db/
|
|
pool.rs -- sqlx PgPool init
|
|
migrations/ -- .sql files, run via sqlx::migrate!()
|
|
001_initial.sql -- all tables + seed data
|
|
routes/
|
|
crud.rs -- generic /api/{table} handler + query builder
|
|
auth.rs -- /auth/login
|
|
admin/
|
|
queries.rs
|
|
cache.rs
|
|
users.rs
|
|
permissions.rs
|
|
blacklist.rs
|
|
models/
|
|
query.rs
|
|
user.rs
|
|
permission.rs
|
|
blacklist.rs
|
|
ui/
|
|
.npmrc -- @nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/
|
|
package.json
|
|
vite.config.ts
|
|
src/
|
|
main.ts -- dark mode default, router, pinia init
|
|
App.vue
|
|
router/index.ts -- route guards: redirect /login if no JWT
|
|
stores/auth.ts -- JWT storage, permissions bitmask helpers
|
|
views/
|
|
Login.vue
|
|
admin/
|
|
Queries.vue
|
|
Users.vue
|
|
Permissions.vue
|
|
Blacklist.vue
|
|
Cache.vue
|
|
Cargo.toml
|
|
docker-compose.yml
|
|
Dockerfile -- multi-stage: Node build UI, Rust build API
|
|
.env.example
|
|
```
|
|
|
|
---
|
|
|
|
## Docker
|
|
|
|
**Multi-stage Dockerfile:**
|
|
1. Stage 1 (node): install `@nychthemeron/library`, run `vite build` → `ui/dist/`
|
|
2. Stage 2 (rust): compile API, copy `ui/dist/` into final image
|
|
3. Final image runs the Rust binary; `tower-http::ServeDir` serves `ui/dist/` at `/`
|
|
|
|
**docker-compose.yml:**
|
|
- `db` service: `postgres:16-alpine`, persistent volume, health check
|
|
- `api` service: built from Dockerfile, depends on `db`, `mem_limit` set, env vars from `.env`
|
|
- Migrations run at API startup via `sqlx::migrate!()`
|
|
|
|
---
|
|
|
|
## Frontend Contract
|
|
|
|
- Dark mode is the default; set via `@nychthemeron/library` theme config in `main.ts`
|
|
- Login page at `/login` calls `POST /auth/login`, stores JWT in `localStorage`
|
|
- All admin views require `SUPER_ADMIN` bit in JWT; route guard redirects to `/login` if absent
|
|
- Permission bitmask helpers in `stores/auth.ts`: `hasPermission(bit: number): boolean`
|
|
- Admin views wire directly to their respective admin endpoints
|