Initial Commit
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 3m1s
ci / publish (push) Successful in 2m38s

This commit is contained in:
Matthew McPeak 2026-06-17 16:21:16 -04:00
commit 05cb37730d
72 changed files with 13140 additions and 0 deletions

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
DATABASE_URL=postgres://mercury:mercury@db:5432/mercury
JWT_SECRET=change_me_in_production
JWT_EXPIRY_SECS=3600
CACHE_MAX_CAPACITY=10000
CACHE_IDLE_TIMEOUT_SECS=300
CACHE_SWEEP_INTERVAL_SECS=60
# Comma-separated allowed CORS origins, or * for permissive. Empty = no CORS headers.
CORS_ORIGINS=

96
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,96 @@
name: ci
on:
push:
tags:
- "v*"
pull_request:
jobs:
test:
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/rust-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy -- -D warnings
- name: Run tests
run: cargo test
build-ui:
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: |
printf '@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/\n//git.mcpeakdev.com/api/packages/McPeakDev/npm/:_authToken=%s\n' "$BUN_AUTH_TOKEN" > .npmrc
bun install --frozen-lockfile
rm .npmrc
working-directory: ui
env:
BUN_AUTH_TOKEN: ${{ secrets.BUN_AUTH_TOKEN }}
- name: Audit dependencies
run: bun audit || true
working-directory: ui
- name: Type check
run: bun run build
working-directory: ui
publish:
needs: [test, build-ui]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.mcpeakdev.com
username: ${{ github.actor }}
password: ${{ secrets.BUN_AUTH_TOKEN }}
- name: Extract image tag
id: meta
run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
git.mcpeakdev.com/mcpeakdev/mercury:${{ steps.meta.outputs.tag }}
git.mcpeakdev.com/mcpeakdev/mercury:latest
cache-from: type=registry,ref=git.mcpeakdev.com/mcpeakdev/mercury:latest
cache-to: type=inline
secrets: |
bun_auth_token=${{ secrets.BUN_AUTH_TOKEN }}

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

2956
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

32
Cargo.toml Normal file
View file

@ -0,0 +1,32 @@
[package]
name = "mercury"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "mercury"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
bcrypt = "0.15"
dashmap = "5"
glob = "0.3"
tower-http = { version = "0.5", features = ["fs", "cors"] }
tower = { version = "0.4", features = ["util"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sha2 = "0.10"
[dev-dependencies]
tower = { version = "0.4", features = ["util"] }
http-body-util = "0.1"

26
Dockerfile Normal file
View file

@ -0,0 +1,26 @@
# Stage 1: Build Vue UI
FROM oven/bun:1-alpine AS ui-builder
WORKDIR /ui
COPY ui/ .
RUN --mount=type=secret,id=bun_auth_token \
printf '//git.mcpeakdev.com/api/packages/McPeakDev/npm/:_authToken=%s\n@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/\n' \
"$(cat /run/secrets/bun_auth_token)" > .npmrc && \
bun install --frozen-lockfile
RUN bun run build
# Stage 2: Build Rust API
FROM rust:1.96-slim AS api-builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src/ src/
RUN cargo build --release
# Stage 3: Final image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=api-builder /app/target/release/mercury .
COPY --from=ui-builder /ui/dist ./ui/dist
EXPOSE 3000
CMD ["./mercury"]

174
README.md Normal file
View file

@ -0,0 +1,174 @@
# Mercury
A high-performance, monolithic Rust API with a dynamic CRUD engine, query registry cache, JWT bitmask permissions, and a Vue 3 admin frontend.
![Login](pics/Mercury-Login.png)
![Permissions](pics/Mercury.png)
## Quick Start
```bash
cp .env.example .env # set JWT_SECRET
docker compose up --build
```
API: http://localhost:3000/api
Admin UI: http://localhost:3000
Default credentials: `admin` / `admin`
---
## API Contract
### Authentication
```
POST /auth/login
Body: { "username": "...", "password": "..." }
Returns: { "token": "<JWT>" }
```
All admin routes require `Authorization: Bearer <token>`.
---
### CRUD — Dynamic Table Access
Requests are mapped to the named PostgreSQL table. The SQL is generated, cached, and executed automatically.
```
GET /api/{table} List all rows (supports ?col=val filters)
GET /api/{table}/{id} Get row by id
POST /api/{table} Insert row (JSON body)
PUT /api/{table}/{id} Update row by id (JSON body)
DELETE /api/{table}/{id} Delete row by id
```
**Notes:**
- `users` and `permissions` tables are blacklisted from public CRUD — use the admin suite.
- Filters are ANDed together: `GET /api/orders?status=open&priority=high`
---
### Admin — Query Registry
Requires JWT with `ADMIN_QUERY` permission (bit 8).
```
GET /admin/queries List all registered queries
POST /admin/queries Register a raw SQL template
GET /admin/queries/{identifier} Get query by slug
PUT /admin/queries/{identifier} Update SQL template or description
DELETE /admin/queries/{identifier} Remove query (evicts from cache)
GET /admin/queries/{identifier}/execute Execute query with ?param=val bindings
```
SQL templates use `:param_name` placeholders:
```sql
SELECT * FROM orders WHERE user_id = :user_id AND status = :status
```
---
### Admin — Cache
Requires JWT with `ADMIN_CACHE` permission (bit 16).
```
GET /admin/cache/stats Cache size, hit count, miss count
DELETE /admin/cache Flush entire cache
```
---
### Admin — Users
Requires JWT with `SUPER_ADMIN` permission (bit 32).
```
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} OR bit into permissions mask
DELETE /admin/users/{id}/permissions/revoke/{bit} AND NOT bit from permissions mask
```
---
### Admin — Permissions
Requires JWT with `SUPER_ADMIN` permission (bit 32).
```
GET /admin/permissions List permission definitions
POST /admin/permissions Create custom permission (auto-assigns next bit)
PUT /admin/permissions/{id} Update name/description
DELETE /admin/permissions/{id} Remove permission
```
---
### Admin — Route Blacklist
Requires JWT with `SUPER_ADMIN` permission (bit 32). Changes take effect immediately in memory.
```
GET /admin/blacklist List all entries
POST /admin/blacklist Add glob pattern
PUT /admin/blacklist/{id} Update entry (set active: false to disable)
DELETE /admin/blacklist/{id} Remove entry
```
Pattern syntax: `*` matches one path segment, `**` matches many.
Example: `/api/sensitive/**` blocks all methods under that path.
---
## Permission Bitmask
| Name | Bit | Value |
|-------------|-----|-------|
| READ | 0 | 1 |
| WRITE | 1 | 2 |
| DELETE | 2 | 4 |
| ADMIN_QUERY | 3 | 8 |
| ADMIN_CACHE | 4 | 16 |
| SUPER_ADMIN | 5 | 32 |
Custom permissions are added via the admin suite and assigned the next available power-of-2 bit. Masks support up to 128 bits (u128).
---
## Configuration
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | required | PostgreSQL connection string |
| `JWT_SECRET` | required | HMAC-HS256 signing secret |
| `JWT_EXPIRY_SECS` | 3600 | Token lifetime in seconds |
| `CACHE_MAX_CAPACITY` | 10000 | Max query templates in memory |
| `CACHE_IDLE_TIMEOUT_SECS` | 300 | Evict after N seconds idle |
| `CACHE_SWEEP_INTERVAL_SECS` | 60 | Sweep interval for eviction task |
---
## Development
```bash
# API only (requires local Postgres)
cargo run
# UI dev server (proxies to local API)
cd ui && npm install && npm run dev
# Full stack
docker compose up --build
```
## Stack
- **API:** Rust, Axum, SQLx, PostgreSQL, DashMap, jsonwebtoken, bcrypt
- **Frontend:** Vue 3, Vite, @nychthemeron/library (dark mode default)
- **Infra:** Docker multi-stage build, docker compose

15
dev.sh Executable file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd $SCRIPT_DIR/ui/ && bun i
cd "$SCRIPT_DIR"
cleanup() {
docker compose down
}
trap cleanup INT TERM
docker compose up --build

4
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,4 @@
# Reserved for future dev overrides.
# Port mapping omitted — dev.sh connects to the container's internal IP directly
# to avoid Docker Desktop WSL2 port-forwarding limitations.
services: {}

34
docker-compose.yml Normal file
View file

@ -0,0 +1,34 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: mercury
POSTGRES_PASSWORD: mercury
POSTGRES_DB: mercury
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mercury"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://mercury:mercury@db:5432/mercury
JWT_SECRET: ${JWT_SECRET:-change_me_in_production}
JWT_EXPIRY_SECS: ${JWT_EXPIRY_SECS:-3600}
CACHE_MAX_CAPACITY: ${CACHE_MAX_CAPACITY:-10000}
CACHE_IDLE_TIMEOUT_SECS: ${CACHE_IDLE_TIMEOUT_SECS:-300}
CACHE_SWEEP_INTERVAL_SECS: ${CACHE_SWEEP_INTERVAL_SECS:-60}
depends_on:
db:
condition: service_healthy
mem_limit: 512m
mem_reservation: 256m
volumes:
postgres_data:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,323 @@
# 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

BIN
pics/Mercury-Login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
pics/Mercury.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

117
src/auth/middleware.rs Normal file
View file

@ -0,0 +1,117 @@
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use crate::{
auth::{decode_jwt, resolve_api_key, Claims},
state::AppState,
};
/// Resolves a Bearer token to Claims, trying JWT then API key.
/// Inserts Claims into request extensions on success so downstream
/// middleware can reuse them without an additional DB round-trip.
async fn authenticate(token: &str, state: &AppState, req: &mut Request) -> Option<Claims> {
if let Some(existing) = req.extensions().get::<Claims>().cloned() {
return Some(existing);
}
let claims = if let Ok(c) = decode_jwt(token, &state.config.jwt_secret) {
c
} else {
resolve_api_key(token, &state.pool).await?
};
req.extensions_mut().insert(claims.clone());
Some(claims)
}
pub async fn blacklist_layer(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let method = req.method().as_str().to_uppercase();
let path = req.uri().path().to_string();
let caller_mask = if let Some(token) = extract_bearer(&req) {
authenticate(&token, &state, &mut req)
.await
.map(|c| c.permissions_mask())
.unwrap_or(0)
} else {
0
};
if state
.blacklist_cache
.is_blocked(&method, &path, caller_mask)
.await
{
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
}
pub fn extract_bearer(req: &Request) -> Option<String> {
req.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.to_string())
}
pub async fn require_auth(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(next.run(req).await)
}
pub async fn require_super_admin(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
}
pub async fn require_admin_query(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
}
pub async fn require_admin_cache(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
let claims = authenticate(&token, &state, &mut req)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
}

123
src/auth/mod.rs Normal file
View file

@ -0,0 +1,123 @@
pub mod middleware;
use anyhow::Result;
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
#[allow(dead_code)]
pub mod permissions {
pub const READ: u128 = 1;
pub const WRITE: u128 = 2;
pub const DELETE: u128 = 4;
pub const ADMIN_QUERY: u128 = 8;
pub const ADMIN_CACHE: u128 = 16;
pub const SUPER_ADMIN: u128 = 32;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub permissions: String, // u128 stored as decimal string
pub exp: usize,
}
impl Claims {
pub fn permissions_mask(&self) -> u128 {
self.permissions.parse().unwrap_or(0)
}
pub fn has_permission(&self, bit: u128) -> bool {
self.permissions_mask() & bit != 0
}
}
pub fn encode_jwt(username: &str, mask: u128, secret: &str, expiry_secs: u64) -> Result<String> {
let exp = (Utc::now().timestamp() as u64 + expiry_secs) as usize;
let claims = Claims {
sub: username.to_string(),
permissions: mask.to_string(),
exp,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?;
Ok(token)
}
/// Generates a new API key: `mrc_<32 random hex chars>`.
/// Returns `(plain_key, key_prefix, key_hash)`.
pub fn generate_api_key() -> (String, String, String) {
let raw = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let key = format!("mrc_{}", raw);
let prefix = raw[..8].to_string();
let hash = hash_api_key(&key);
(key, prefix, hash)
}
pub fn hash_api_key(key: &str) -> String {
format!("{:x}", Sha256::digest(key.as_bytes()))
}
/// Resolves an API key token against the database.
/// Updates `last_used_at` on success.
pub async fn resolve_api_key(token: &str, pool: &sqlx::PgPool) -> Option<Claims> {
if !token.starts_with("mrc_") {
return None;
}
let hash = hash_api_key(token);
let row: (String, String) = sqlx::query_as(
"UPDATE api_keys SET last_used_at = now()
WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > now())
RETURNING name, permissions_mask",
)
.bind(&hash)
.fetch_optional(pool)
.await
.ok()??;
Some(Claims {
sub: row.0,
permissions: row.1,
exp: usize::MAX,
})
}
pub fn decode_jwt(token: &str, secret: &str) -> Result<Claims> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)?;
Ok(data.claims)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_decode_roundtrip() {
let secret = "test_secret";
let token = encode_jwt("alice", 63u128, secret, 3600).unwrap();
let claims = decode_jwt(&token, secret).unwrap();
assert_eq!(claims.sub, "alice");
assert_eq!(claims.permissions_mask(), 63u128);
}
#[test]
fn test_has_permission() {
let claims = Claims {
sub: "bob".into(),
permissions: "9".into(), // READ (1) + ADMIN_QUERY (8)
exp: 9999999999,
};
assert!(claims.has_permission(permissions::READ));
assert!(claims.has_permission(permissions::ADMIN_QUERY));
assert!(!claims.has_permission(permissions::SUPER_ADMIN));
}
}

2
src/cache/mod.rs vendored Normal file
View file

@ -0,0 +1,2 @@
pub mod sweep;
pub use sweep::spawn_sweep_task;

15
src/cache/sweep.rs vendored Normal file
View file

@ -0,0 +1,15 @@
use crate::state::QueryCache;
use std::time::Duration;
pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) {
tokio::spawn(async move {
let interval = Duration::from_secs(sweep_interval_secs);
loop {
tokio::time::sleep(interval).await;
let now = crate::state::unix_now();
cache
.map
.retain(|_, entry| now.saturating_sub(entry.last_accessed()) < idle_timeout_secs);
}
});
}

57
src/config.rs Normal file
View file

@ -0,0 +1,57 @@
use anyhow::Result;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub jwt_expiry_secs: u64,
pub cache_max_capacity: usize,
pub cache_idle_timeout_secs: u64,
pub cache_sweep_interval_secs: u64,
/// Comma-separated allowed CORS origins, or "*" for permissive. Empty = no CORS headers.
pub cors_origins: Vec<String>,
}
impl Config {
pub fn from_env() -> Result<Self> {
Ok(Self {
database_url: std::env::var("DATABASE_URL")?,
jwt_secret: std::env::var("JWT_SECRET")?,
jwt_expiry_secs: std::env::var("JWT_EXPIRY_SECS")
.unwrap_or_else(|_| "3600".into())
.parse()?,
cache_max_capacity: std::env::var("CACHE_MAX_CAPACITY")
.unwrap_or_else(|_| "10000".into())
.parse()?,
cache_idle_timeout_secs: std::env::var("CACHE_IDLE_TIMEOUT_SECS")
.unwrap_or_else(|_| "300".into())
.parse()?,
cache_sweep_interval_secs: std::env::var("CACHE_SWEEP_INTERVAL_SECS")
.unwrap_or_else(|_| "60".into())
.parse()?,
cors_origins: std::env::var("CORS_ORIGINS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_defaults() {
std::env::set_var("DATABASE_URL", "postgres://test");
std::env::set_var("JWT_SECRET", "secret");
let cfg = Config::from_env().unwrap();
assert_eq!(cfg.jwt_expiry_secs, 3600);
assert_eq!(cfg.cache_max_capacity, 10_000);
assert_eq!(cfg.cache_idle_timeout_secs, 300);
assert_eq!(cfg.cache_sweep_interval_secs, 60);
assert!(cfg.cors_origins.is_empty());
}
}

View file

@ -0,0 +1,48 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
permissions_mask TEXT NOT NULL DEFAULT '0',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
bit_value TEXT UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE queries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
identifier VARCHAR(255) UNIQUE NOT NULL,
sql_template TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE blacklist (
id SERIAL PRIMARY KEY,
pattern VARCHAR(500) NOT NULL,
method VARCHAR(10),
reason TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed permissions
INSERT INTO permissions (name, bit_value, description) VALUES
('READ', '1', 'Can read via CRUD endpoints'),
('WRITE', '2', 'Can insert/update via CRUD'),
('DELETE', '4', 'Can delete via CRUD'),
('ADMIN_QUERY', '8', 'Can manage query registry'),
('ADMIN_CACHE', '16', 'Can manage cache'),
('SUPER_ADMIN', '32', 'Full access');
-- Seed blacklist (users and permissions tables are admin-only)
INSERT INTO blacklist (pattern, method, reason, active) VALUES
('/api/users/**', NULL, 'admin-only table', true),
('/api/permissions/**', NULL, 'admin-only table', true);

View file

@ -0,0 +1,9 @@
-- Block PostgreSQL system catalog tables from the public CRUD API.
-- The pg_ prefix covers pg_tables, pg_class, pg_user, pg_shadow, pg_authid, etc.
-- information_schema columns contain dots so they're already rejected by the
-- identifier validator, but we block them here for defense in depth.
INSERT INTO blacklist (pattern, method, reason, active) VALUES
('/api/pg_*', NULL, 'postgresql system catalog', true),
('/api/pg_*/**', NULL, 'postgresql system catalog', true),
('/api/information_schema', NULL, 'postgresql information schema', true),
('/api/information_schema/**', NULL, 'postgresql information schema', true);

View file

@ -0,0 +1,32 @@
-- Allow blacklist entries to be bypassed by callers who hold a specific permission bit.
-- bypass_mask NULL means the rule blocks everyone unconditionally.
ALTER TABLE blacklist ADD COLUMN bypass_mask TEXT;
-- Existing user/permission rules: SUPER_ADMIN (bit 32) can bypass them so the
-- admin UI can reach these tables via the generic /api/:table CRUD endpoint.
UPDATE blacklist
SET bypass_mask = '32'
WHERE pattern IN ('/api/users/**', '/api/permissions/**');
-- Add rules for queries and blacklist tables, which also need admin-only CRUD access.
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
('/api/users', NULL, 'admin-only table', true, '32'),
('/api/permissions', NULL, 'admin-only table', true, '32'),
('/api/queries', NULL, 'admin-only table', true, '32'),
('/api/queries/**', NULL, 'admin-only table', true, '32'),
('/api/blacklist', NULL, 'admin-only table', true, '32'),
('/api/blacklist/**', NULL, 'admin-only table', true, '32');
-- Automatically update updated_at on queries mutations so the generic CRUD
-- endpoint doesn't need to know about that column.
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER queries_updated_at
BEFORE UPDATE ON queries
FOR EACH ROW EXECUTE FUNCTION update_updated_at();

View file

@ -0,0 +1,2 @@
-- Widen method column to support comma-separated multi-method values e.g. "GET,POST"
ALTER TABLE blacklist ALTER COLUMN method TYPE TEXT;

View file

@ -0,0 +1,12 @@
CREATE TABLE api_keys (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
permissions_mask TEXT NOT NULL DEFAULT '0',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);

View file

@ -0,0 +1,5 @@
-- Prevent raw CRUD access to the api_keys table (would expose key_hash).
-- The proper endpoints live at /api/admin/api-keys and are guarded by require_super_admin.
INSERT INTO blacklist (pattern, method, reason, active) VALUES
('/api/api_keys', NULL, 'api key hashes must not be exposed via generic CRUD', true),
('/api/api_keys/**', NULL, 'api key hashes must not be exposed via generic CRUD', true);

2
src/db/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod pool;
pub use pool::create_pool;

11
src/db/pool.rs Normal file
View file

@ -0,0 +1,11 @@
use anyhow::Result;
use sqlx::{postgres::PgPoolOptions, PgPool};
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(database_url)
.await?;
sqlx::migrate!("src/db/migrations").run(&pool).await?;
Ok(pool)
}

170
src/main.rs Normal file
View file

@ -0,0 +1,170 @@
mod auth;
mod cache;
mod config;
mod db;
mod models;
mod routes;
mod state;
use std::io::{self, Write};
use std::sync::Arc;
use axum::{
extract::DefaultBodyLimit,
http::{header, HeaderValue, Method},
middleware,
routing::{get, post},
Router,
};
use tower_http::{
cors::CorsLayer,
services::{ServeDir, ServeFile},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
auth::middleware::{blacklist_layer, require_auth},
cache::spawn_sweep_task,
config::Config,
db::create_pool,
models::blacklist::BlacklistEntry,
routes::{admin::admin_router, auth::login, crud::handle_crud},
state::{AppState, BlacklistCache, QueryCache},
};
fn build_cors(origins: &[String]) -> CorsLayer {
if origins.is_empty() {
return CorsLayer::new();
}
if origins.iter().any(|o| o == "*") {
return CorsLayer::permissive();
}
let parsed: Vec<HeaderValue> = origins.iter().filter_map(|o| o.parse().ok()).collect();
CorsLayer::new()
.allow_origin(parsed)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mercury=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let config = Arc::new(Config::from_env()?);
let pool = create_pool(&config.database_url).await?;
// If no users exist, prompt to create the first admin interactively.
let user_count: i64 = sqlx::query_scalar::<_, Option<i64>>("SELECT COUNT(*) FROM users")
.fetch_one(&pool)
.await?
.unwrap_or(0);
if user_count == 0 {
println!("\nNo users found. Create the first admin account.");
print!("Username: ");
io::stdout().flush()?;
let mut username = String::new();
io::stdin().read_line(&mut username)?;
let username = username.trim().to_string();
if username.is_empty() {
anyhow::bail!("username cannot be empty");
}
print!("Password: ");
io::stdout().flush()?;
let mut password = String::new();
io::stdin().read_line(&mut password)?;
let password = password.trim().to_string();
if password.is_empty() {
anyhow::bail!("password cannot be empty");
}
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?;
sqlx::query(
"INSERT INTO users (username, password_hash, permissions_mask) VALUES ($1, $2, '63')",
)
.bind(&username)
.bind(hash)
.execute(&pool)
.await?;
tracing::info!("created first admin user: {}", username);
println!("Admin user '{}' created. Starting server...\n", username);
}
let query_cache = QueryCache::new();
let blacklist_cache = BlacklistCache::new();
// Load blacklist from DB into memory
let entries = sqlx::query_as::<_, BlacklistEntry>(
"SELECT id, pattern, method, reason, active, bypass_mask, created_at FROM blacklist ORDER BY id"
)
.fetch_all(&pool)
.await?;
blacklist_cache.load(entries).await;
// Start cache sweep
spawn_sweep_task(
query_cache.clone(),
config.cache_idle_timeout_secs,
config.cache_sweep_interval_secs,
);
let state = AppState {
pool,
query_cache,
blacklist_cache,
config: config.clone(),
};
let crud_routes = Router::new()
.route("/api/:table", get(handle_crud).post(handle_crud))
.route("/api/:table/", get(handle_crud).post(handle_crud))
.route(
"/api/:table/:id",
get(handle_crud).put(handle_crud).delete(handle_crud),
)
.route(
"/api/:table/:id/",
get(handle_crud).put(handle_crud).delete(handle_crud),
)
.layer(DefaultBodyLimit::max(1024 * 1024))
// require_auth is inner (added first); blacklist_layer is outer (added last, runs first).
// Order: blacklist check → auth check → handler.
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
.route_layer(middleware::from_fn_with_state(
state.clone(),
blacklist_layer,
));
let cors_layer = build_cors(&config.cors_origins);
let app = Router::new()
.route("/auth/login", post(login))
.merge(crud_routes)
.nest("/api/admin", admin_router(state.clone()))
.nest_service(
"/",
ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")),
)
.layer(cors_layer)
.with_state(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));
tracing::info!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

20
src/models/api_key.rs Normal file
View file

@ -0,0 +1,20 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ApiKey {
pub id: i32,
pub name: String,
pub key_prefix: String,
pub permissions_mask: String,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateApiKey {
pub name: String,
pub permissions_mask: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
}

30
src/models/blacklist.rs Normal file
View file

@ -0,0 +1,30 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct BlacklistEntry {
pub id: i32,
pub pattern: String,
pub method: Option<String>,
pub reason: Option<String>,
pub active: bool,
pub bypass_mask: Option<String>,
pub created_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct CreateBlacklistEntry {
pub pattern: String,
pub method: Option<String>,
pub reason: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct UpdateBlacklistEntry {
pub pattern: Option<String>,
pub method: Option<String>,
pub reason: Option<String>,
pub active: Option<bool>,
}

5
src/models/mod.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod api_key;
pub mod blacklist;
pub mod permission;
pub mod query;
pub mod user;

24
src/models/permission.rs Normal file
View file

@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Permission {
pub id: i32,
pub name: String,
pub bit_value: String,
pub description: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct CreatePermission {
pub name: String,
pub description: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct UpdatePermission {
pub name: Option<String>,
pub description: Option<String>,
}

28
src/models/query.rs Normal file
View file

@ -0,0 +1,28 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct StoredQuery {
pub id: Uuid,
pub identifier: String,
pub sql_template: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct CreateQuery {
pub identifier: String,
pub sql_template: String,
pub description: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct UpdateQuery {
pub sql_template: Option<String>,
pub description: Option<String>,
}

34
src/models/user.rs Normal file
View file

@ -0,0 +1,34 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: i32,
pub username: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub permissions_mask: String,
pub created_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct CreateUser {
pub username: String,
pub password: String,
pub permissions_mask: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct UpdateUser {
pub username: Option<String>,
pub password: Option<String>,
pub permissions_mask: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}

View file

@ -0,0 +1,70 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde_json::{json, Value};
use crate::{
auth::generate_api_key,
models::api_key::{ApiKey, CreateApiKey},
state::AppState,
};
pub async fn list_api_keys(State(state): State<AppState>) -> Result<Json<Vec<ApiKey>>, StatusCode> {
let keys = sqlx::query_as::<_, ApiKey>(
"SELECT id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at
FROM api_keys ORDER BY created_at DESC",
)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(keys))
}
pub async fn create_api_key(
State(state): State<AppState>,
Json(body): Json<CreateApiKey>,
) -> Result<Json<Value>, StatusCode> {
let (plain_key, key_prefix, key_hash) = generate_api_key();
let mask = body.permissions_mask.unwrap_or_else(|| "0".into());
sqlx::query(
"INSERT INTO api_keys (name, key_prefix, key_hash, permissions_mask, expires_at)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(&body.name)
.bind(&key_prefix)
.bind(&key_hash)
.bind(&mask)
.bind(body.expires_at)
.execute(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({
"key": plain_key,
"prefix": key_prefix,
"name": body.name,
"permissions_mask": mask,
"note": "Store this key securely — it will not be shown again."
})))
}
pub async fn revoke_api_key(
State(state): State<AppState>,
Path(id): Path<i32>,
) -> Result<StatusCode, StatusCode> {
let rows = sqlx::query("DELETE FROM api_keys WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.rows_affected();
if rows == 0 {
Err(StatusCode::NOT_FOUND)
} else {
Ok(StatusCode::NO_CONTENT)
}
}

24
src/routes/admin/cache.rs Normal file
View file

@ -0,0 +1,24 @@
use axum::extract::Extension;
use axum::{extract::State, http::StatusCode, Json};
use serde_json::{json, Value};
use crate::{auth::Claims, state::AppState};
pub async fn cache_stats(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
) -> Json<Value> {
Json(json!({
"size": state.query_cache.len(),
"hits": state.query_cache.hits(),
"misses": state.query_cache.misses(),
}))
}
pub async fn flush_cache(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
) -> Result<Json<Value>, StatusCode> {
state.query_cache.flush();
Ok(Json(json!({ "flushed": true })))
}

56
src/routes/admin/mod.rs Normal file
View file

@ -0,0 +1,56 @@
pub mod api_keys;
pub mod cache;
pub mod queries;
pub mod tables;
use axum::{
middleware,
routing::{delete, get},
Router,
};
use crate::{
auth::middleware::{require_admin_cache, require_admin_query, require_super_admin},
state::AppState,
};
pub fn admin_router(state: AppState) -> Router<AppState> {
let query_routes = Router::new()
.route("/:identifier/execute", get(queries::execute_query))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_query,
));
let cache_routes = Router::new()
.route("/stats", get(cache::cache_stats))
.route("/", delete(cache::flush_cache))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_cache,
));
let super_routes = Router::new()
.route(
"/tables",
get(tables::list_tables).post(tables::create_table),
)
.route(
"/tables/:name",
get(tables::get_table_preview).delete(tables::drop_table),
)
.route(
"/api-keys",
get(api_keys::list_api_keys).post(api_keys::create_api_key),
)
.route("/api-keys/:id", delete(api_keys::revoke_api_key))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_super_admin,
));
Router::new()
.nest("/queries", query_routes)
.nest("/cache", cache_routes)
.merge(super_routes)
}

View file

@ -0,0 +1,85 @@
use axum::{
extract::{Extension, Path, Query, State},
http::StatusCode,
Json,
};
use serde_json::Value;
use std::collections::HashMap;
use crate::{auth::Claims, models::query::StoredQuery, state::AppState};
pub async fn execute_query(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Path(identifier): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, StatusCode> {
let stored = sqlx::query_as::<_, StoredQuery>(
"SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1"
)
.bind(&identifier)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let mut sql = stored.sql_template.clone();
let mut bound_vals: Vec<String> = Vec::new();
let mut idx = 1usize;
let mut sorted_params: Vec<(String, String)> = params.into_iter().collect();
sorted_params.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.0.cmp(&b.0)));
for (name, val) in &sorted_params {
let placeholder = format!(":{}", name);
if sql.contains(&placeholder) {
sql = sql.replace(&placeholder, &format!("${}", idx));
bound_vals.push(val.clone());
idx += 1;
}
}
let mut q = sqlx::query(&sql);
for val in &bound_vals {
q = q.bind(val.as_str());
}
let rows = q.fetch_all(&state.pool).await.map_err(|e| {
tracing::error!("query execution error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let json_rows: Vec<Value> = rows
.into_iter()
.map(crate::routes::crud::pg_row_to_json)
.collect();
Ok(Json(Value::Array(json_rows)))
}
#[cfg(test)]
mod tests {
#[test]
fn test_prefix_param_substitution_order() {
let template = "SELECT * FROM t WHERE user_id = :user_id AND username = :username";
let mut params: Vec<(String, String)> = vec![
("user_id".into(), "42".into()),
("username".into(), "alice".into()),
];
params.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.0.cmp(&b.0)));
let mut sql = template.to_string();
for (i, (name, _)) in params.iter().enumerate() {
sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1));
}
assert!(
!sql.contains(":username"),
"placeholder not replaced: {}",
sql
);
assert!(
!sql.contains(":user_id"),
"placeholder not replaced: {}",
sql
);
// username (len 8) comes first → $1; user_id (len 7) → $2
assert_eq!(sql, "SELECT * FROM t WHERE user_id = $2 AND username = $1");
}
}

242
src/routes/admin/tables.rs Normal file
View file

@ -0,0 +1,242 @@
use axum::{
extract::{Extension, Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::Row;
use crate::{auth::Claims, routes::crud::pg_row_to_json, state::AppState};
const ALLOWED_TYPES: &[&str] = &[
"TEXT",
"INTEGER",
"BIGINT",
"SMALLINT",
"BOOLEAN",
"NUMERIC",
"FLOAT4",
"FLOAT8",
"UUID",
"TIMESTAMPTZ",
"DATE",
"JSONB",
];
const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"];
fn is_protected(name: &str) -> bool {
let lower = name.to_lowercase();
PROTECTED_TABLES.iter().any(|&t| t == lower)
}
#[derive(Debug, Deserialize)]
pub struct ColumnDef {
pub name: String,
pub col_type: String,
pub nullable: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct CreateTableRequest {
pub name: String,
pub columns: Vec<ColumnDef>,
}
#[derive(Debug, Serialize)]
pub struct TableInfo {
pub table_name: String,
pub column_count: i64,
}
#[derive(Debug, Serialize)]
pub struct ColumnInfo {
pub column_name: String,
pub data_type: String,
pub is_nullable: String,
}
#[derive(Debug, Serialize)]
pub struct TablePreview {
pub table_name: String,
pub row_count: i64,
pub columns: Vec<ColumnInfo>,
pub sample_rows: Vec<Value>,
}
pub async fn list_tables(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
) -> Result<Json<Vec<TableInfo>>, StatusCode> {
let rows = sqlx::query(
r#"
SELECT t.table_name,
COUNT(c.column_name)::bigint AS column_count
FROM information_schema.tables t
LEFT JOIN information_schema.columns c
ON c.table_schema = t.table_schema AND c.table_name = t.table_name
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
GROUP BY t.table_name
ORDER BY t.table_name
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let tables = rows
.into_iter()
.map(|r| TableInfo {
table_name: r.try_get::<String, _>("table_name").unwrap_or_default(),
column_count: r.try_get::<i64, _>("column_count").unwrap_or(0),
})
.collect();
Ok(Json(tables))
}
pub async fn get_table_preview(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Path(name): Path<String>,
) -> Result<Json<TablePreview>, StatusCode> {
if !crate::routes::is_valid_identifier(&name) {
return Err(StatusCode::BAD_REQUEST);
}
let col_rows = sqlx::query(
"SELECT column_name, data_type, is_nullable \
FROM information_schema.columns \
WHERE table_schema = 'public' AND table_name = $1 \
ORDER BY ordinal_position",
)
.bind(&name)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if col_rows.is_empty() {
return Err(StatusCode::NOT_FOUND);
}
let columns: Vec<ColumnInfo> = col_rows
.into_iter()
.map(|r| ColumnInfo {
column_name: r.try_get::<String, _>("column_name").unwrap_or_default(),
data_type: r.try_get::<String, _>("data_type").unwrap_or_default(),
is_nullable: r.try_get::<String, _>("is_nullable").unwrap_or_default(),
})
.collect();
let count_sql = format!("SELECT COUNT(*)::bigint FROM {}", name);
let row_count: i64 = sqlx::query(&count_sql)
.fetch_one(&state.pool)
.await
.map(|r| r.try_get::<i64, _>(0).unwrap_or(0))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let sample_sql = format!("SELECT * FROM {} LIMIT 10", name);
let sample_rows: Vec<Value> = sqlx::query(&sample_sql)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.into_iter()
.map(pg_row_to_json)
.collect();
Ok(Json(TablePreview {
table_name: name,
row_count,
columns,
sample_rows,
}))
}
pub async fn create_table(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Json(body): Json<CreateTableRequest>,
) -> Result<Json<TableInfo>, StatusCode> {
if !crate::routes::is_valid_identifier(&body.name) {
return Err(StatusCode::BAD_REQUEST);
}
if is_protected(&body.name) {
return Err(StatusCode::FORBIDDEN);
}
if body.columns.is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
let mut col_defs = vec!["id SERIAL PRIMARY KEY".to_string()];
for col in &body.columns {
if !crate::routes::is_valid_identifier(&col.name) {
return Err(StatusCode::BAD_REQUEST);
}
let upper_type = col.col_type.to_uppercase();
if !ALLOWED_TYPES.contains(&upper_type.as_str()) {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
let nullable = col.nullable.unwrap_or(true);
let null_clause = if nullable { "" } else { " NOT NULL" };
col_defs.push(format!("{} {}{}", col.name, upper_type, null_clause));
}
let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", "));
sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
tracing::error!("create table error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(TableInfo {
table_name: body.name,
column_count: body.columns.len() as i64 + 1,
}))
}
pub async fn drop_table(
State(state): State<AppState>,
Extension(_claims): Extension<Claims>,
Path(name): Path<String>,
) -> Result<Json<Value>, StatusCode> {
if !crate::routes::is_valid_identifier(&name) {
return Err(StatusCode::BAD_REQUEST);
}
if is_protected(&name) {
return Err(StatusCode::FORBIDDEN);
}
let sql = format!("DROP TABLE IF EXISTS {}", name);
sqlx::query(&sql).execute(&state.pool).await.map_err(|e| {
tracing::error!("drop table error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(serde_json::json!({ "dropped": true })))
}
#[cfg(test)]
mod tests {
use super::{is_protected, PROTECTED_TABLES};
#[test]
fn test_protected_tables_list() {
assert!(PROTECTED_TABLES.contains(&"users"));
assert!(PROTECTED_TABLES.contains(&"blacklist"));
assert!(PROTECTED_TABLES.contains(&"api_keys"));
assert!(PROTECTED_TABLES.contains(&"queries"));
assert!(PROTECTED_TABLES.contains(&"permissions"));
}
#[test]
fn test_is_protected() {
assert!(is_protected("users"));
assert!(is_protected("USERS"));
assert!(!is_protected("orders"));
}
}

36
src/routes/auth.rs Normal file
View file

@ -0,0 +1,36 @@
use axum::{extract::State, http::StatusCode, Json};
use serde_json::{json, Value};
use crate::{auth::encode_jwt, models::user::LoginRequest, state::AppState};
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> Result<Json<Value>, StatusCode> {
let user = sqlx::query_as::<_, crate::models::user::User>(
"SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE username = $1",
)
.bind(&body.username)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?;
let valid = bcrypt::verify(&body.password, &user.password_hash)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !valid {
return Err(StatusCode::UNAUTHORIZED);
}
let mask: u128 = user.permissions_mask.parse().unwrap_or(0);
let token = encode_jwt(
&user.username,
mask,
&state.config.jwt_secret,
state.config.jwt_expiry_secs,
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(json!({ "token": token })))
}

419
src/routes/crud.rs Normal file
View file

@ -0,0 +1,419 @@
use anyhow::{anyhow, Result};
use axum::{
extract::{Extension, Path, Query, State},
http::{Method, StatusCode},
Json,
};
use serde_json::Value;
use sqlx::postgres::PgRow;
use sqlx::Column;
use sqlx::Row;
use sqlx::TypeInfo;
use std::collections::HashMap;
use crate::{
auth::Claims,
models::blacklist::BlacklistEntry,
state::{AppState, CacheEntry},
};
/// Returns (sql, ordered_param_values, cache_key).
/// body_cols: (col_name, typed_value) pairs from request body.
/// filter_cols: (col_name, string_value) pairs from query params.
pub fn build_query(
method: &str,
table: &str,
id: Option<&str>,
body_cols: &[(String, Value)],
filter_cols: &[(String, String)],
) -> Result<(String, Vec<Value>, String)> {
if !crate::routes::is_valid_identifier(table) {
return Err(anyhow!("invalid identifier: {}", table));
}
for (col, _) in body_cols.iter() {
if !crate::routes::is_valid_identifier(col) {
return Err(anyhow!("invalid identifier: {}", col));
}
}
for (col, _) in filter_cols.iter() {
if !crate::routes::is_valid_identifier(col) {
return Err(anyhow!("invalid identifier: {}", col));
}
}
let mut sorted_body = body_cols.to_vec();
sorted_body.sort_by(|a, b| a.0.cmp(&b.0));
let mut sorted_filters = filter_cols.to_vec();
sorted_filters.sort_by(|a, b| a.0.cmp(&b.0));
match method.to_uppercase().as_str() {
"GET" => {
if let Some(id_val) = id {
let sql = format!("SELECT * FROM {} WHERE id = $1", table);
let key = format!("GET:{}:~id", table);
Ok((sql, vec![Value::String(id_val.to_string())], key))
} else if sorted_filters.is_empty() {
let sql = format!("SELECT * FROM {}", table);
let key = format!("GET:{}:", table);
Ok((sql, vec![], key))
} else {
let col_names: Vec<String> =
sorted_filters.iter().map(|(c, _)| c.clone()).collect();
let where_clause: Vec<String> = col_names
.iter()
.enumerate()
.map(|(i, c)| format!("{} = ${}", c, i + 1))
.collect();
let sql = format!(
"SELECT * FROM {} WHERE {}",
table,
where_clause.join(" AND ")
);
let params: Vec<Value> = sorted_filters
.iter()
.map(|(_, v)| Value::String(v.clone()))
.collect();
let key = format!("GET:{}:{}", table, col_names.join(","));
Ok((sql, params, key))
}
}
"POST" => {
if sorted_body.is_empty() {
return Err(anyhow!("POST requires a body with at least one field"));
}
let cols: Vec<String> = sorted_body.iter().map(|(c, _)| c.clone()).collect();
let placeholders: Vec<String> = (1..=cols.len()).map(|i| format!("${}", i)).collect();
let sql = format!(
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
table,
cols.join(", "),
placeholders.join(", ")
);
let params: Vec<Value> = sorted_body.iter().map(|(_, v)| v.clone()).collect();
let key = format!("POST:{}:{}", table, cols.join(","));
Ok((sql, params, key))
}
"PUT" => {
let id_val = id.ok_or_else(|| anyhow!("PUT requires an id"))?;
if sorted_body.is_empty() {
return Err(anyhow!("PUT requires a body with at least one field"));
}
let cols: Vec<String> = sorted_body.iter().map(|(c, _)| c.clone()).collect();
let set_clause: Vec<String> = cols
.iter()
.enumerate()
.map(|(i, c)| format!("{} = ${}", c, i + 1))
.collect();
let id_placeholder = cols.len() + 1;
let sql = format!(
"UPDATE {} SET {} WHERE id = ${} RETURNING *",
table,
set_clause.join(", "),
id_placeholder
);
let mut params: Vec<Value> = sorted_body.iter().map(|(_, v)| v.clone()).collect();
params.push(Value::String(id_val.to_string()));
let key = format!("PUT:{}:{}:by_id", table, cols.join(","));
Ok((sql, params, key))
}
"DELETE" => {
let id_val = id.ok_or_else(|| anyhow!("DELETE requires an id"))?;
let sql = format!("DELETE FROM {} WHERE id = $1", table);
let key = format!("DELETE:{}:by_id", table);
Ok((sql, vec![Value::String(id_val.to_string())], key))
}
m => Err(anyhow!("unsupported method: {}", m)),
}
}
pub fn pg_row_to_json(row: PgRow) -> Value {
let columns = row.columns();
let mut map = serde_json::Map::new();
for col in columns {
let name = col.name().to_string();
let type_name = col.type_info().name();
let val = match type_name {
"INT2" => row
.try_get::<i16, _>(col.ordinal())
.map(|v| Value::Number(i64::from(v).into()))
.unwrap_or(Value::Null),
"INT4" | "SERIAL" => row
.try_get::<i32, _>(col.ordinal())
.map(|v| Value::Number(i64::from(v).into()))
.unwrap_or(Value::Null),
"INT8" => row
.try_get::<i64, _>(col.ordinal())
.map(|v| Value::Number(v.into()))
.unwrap_or(Value::Null),
"FLOAT4" | "FLOAT8" => row
.try_get::<f64, _>(col.ordinal())
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or(Value::Null),
"BOOL" => row
.try_get::<bool, _>(col.ordinal())
.map(Value::Bool)
.unwrap_or(Value::Null),
"UUID" => row
.try_get::<uuid::Uuid, _>(col.ordinal())
.map(|v| Value::String(v.to_string()))
.unwrap_or(Value::Null),
"TIMESTAMPTZ" | "TIMESTAMP" => row
.try_get::<chrono::DateTime<chrono::Utc>, _>(col.ordinal())
.map(|v| Value::String(v.to_rfc3339()))
.unwrap_or(Value::Null),
_ => row
.try_get::<String, _>(col.ordinal())
.map(Value::String)
.unwrap_or(Value::Null),
};
map.insert(name, val);
}
Value::Object(map)
}
async fn reload_blacklist(state: &AppState) -> Result<(), StatusCode> {
let entries = sqlx::query_as::<_, BlacklistEntry>(
"SELECT id, pattern, method, reason, active, bypass_mask, created_at FROM blacklist ORDER BY id",
)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state.blacklist_cache.load(entries).await;
Ok(())
}
fn strip_password_hash(v: Value) -> Value {
match v {
Value::Object(mut m) => {
m.remove("password_hash");
Value::Object(m)
}
Value::Array(arr) => Value::Array(
arr.into_iter()
.map(|item| match item {
Value::Object(mut m) => {
m.remove("password_hash");
Value::Object(m)
}
other => other,
})
.collect(),
),
other => other,
}
}
pub async fn handle_crud(
State(state): State<AppState>,
method: Method,
Extension(claims): Extension<Claims>,
Path(params): Path<HashMap<String, String>>,
Query(query_params): Query<HashMap<String, String>>,
body: Option<Json<HashMap<String, Value>>>,
) -> Result<Json<Value>, StatusCode> {
let table = params.get("table").ok_or(StatusCode::BAD_REQUEST)?.clone();
let id = params.get("id").map(|s| s.as_str());
let method_str = method.as_str();
// Enforce permission bits before doing any work.
let required_bit = match method_str.to_uppercase().as_str() {
"GET" => crate::auth::permissions::READ,
"POST" | "PUT" => crate::auth::permissions::WRITE,
"DELETE" => crate::auth::permissions::DELETE,
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
};
if !claims.has_permission(required_bit) {
return Err(StatusCode::FORBIDDEN);
}
// Collect body as typed Values directly — no sentinel needed.
let mut body_cols: Vec<(String, Value)> = body
.map(|Json(b)| b.into_iter().collect())
.unwrap_or_default();
// Hash the password field for the users table before building the query.
if table == "users" && matches!(method_str.to_uppercase().as_str(), "POST" | "PUT") {
if let Some(pos) = body_cols.iter().position(|(k, _)| k == "password") {
let (_, val) = body_cols.remove(pos);
if let Value::String(plaintext) = val {
let hash = bcrypt::hash(&plaintext, bcrypt::DEFAULT_COST)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
body_cols.push(("password_hash".to_string(), Value::String(hash)));
}
}
}
let filter_cols: Vec<(String, String)> = query_params.into_iter().collect();
let (sql, params_vals, cache_key) =
build_query(method_str, &table, id, &body_cols, &filter_cols)
.map_err(|_| StatusCode::BAD_REQUEST)?;
let sql = if let Some(entry) = state.query_cache.get(&cache_key) {
entry.sql.clone()
} else {
let entry = CacheEntry::new(sql.clone());
state
.query_cache
.insert(cache_key, entry.clone(), state.config.cache_max_capacity);
entry.sql
};
let mut q = sqlx::query(&sql);
for val in &params_vals {
match val {
Value::Null => q = q.bind(Option::<String>::None),
Value::Bool(b) => q = q.bind(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
q = q.bind(i);
} else if let Some(f) = n.as_f64() {
q = q.bind(f);
} else {
q = q.bind(n.to_string());
}
}
Value::String(s) => q = q.bind(s.as_str()),
other => q = q.bind(other.to_string()),
}
}
let response = match method_str.to_uppercase().as_str() {
"GET" => {
let rows = q
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut v = Value::Array(rows.into_iter().map(pg_row_to_json).collect());
if table == "users" {
v = strip_password_hash(v);
}
v
}
"POST" | "PUT" => {
let row = q.fetch_one(&state.pool).await.map_err(|e| {
if e.to_string().contains("no rows") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
let mut v = pg_row_to_json(row);
if table == "users" {
v = strip_password_hash(v);
}
v
}
"DELETE" => {
q.execute(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
serde_json::json!({ "deleted": true })
}
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
};
// Reload the in-memory blacklist cache after any mutation to the blacklist table.
if table == "blacklist" && method_str != "GET" {
reload_blacklist(&state).await?;
}
Ok(Json(response))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_select_all() {
let (sql, params, key) = build_query("GET", "orders", None, &[], &[]).unwrap();
assert_eq!(sql, "SELECT * FROM orders");
assert!(params.is_empty());
assert_eq!(key, "GET:orders:");
}
#[test]
fn test_build_select_by_id() {
let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap();
assert_eq!(sql, "SELECT * FROM orders WHERE id = $1");
assert_eq!(params, vec![Value::String("42".into())]);
assert_eq!(key, "GET:orders:~id");
}
#[test]
fn test_build_insert() {
let cols = vec![
("email".into(), Value::String("a@b.com".into())),
("name".into(), Value::String("Alice".into())),
];
let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap();
assert_eq!(
sql,
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *"
);
assert_eq!(
params,
vec![
Value::String("a@b.com".into()),
Value::String("Alice".into())
]
);
assert_eq!(key, "POST:users:email,name");
}
#[test]
fn test_build_update() {
let cols = vec![("name".into(), Value::String("Bob".into()))];
let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap();
assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *");
assert_eq!(
params,
vec![Value::String("Bob".into()), Value::String("7".into())]
);
assert_eq!(key, "PUT:users:name:by_id");
}
#[test]
fn test_build_delete() {
let (sql, params, key) = build_query("DELETE", "users", Some("3"), &[], &[]).unwrap();
assert_eq!(sql, "DELETE FROM users WHERE id = $1");
assert_eq!(params, vec![Value::String("3".into())]);
assert_eq!(key, "DELETE:users:by_id");
}
#[test]
fn test_build_select_with_filters() {
let filters = vec![
("status".into(), "active".into()),
("role".into(), "admin".into()),
];
let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap();
assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2");
assert_eq!(
params,
vec![
Value::String("admin".into()),
Value::String("active".into())
]
);
assert_eq!(key, "GET:users:role,status");
}
#[test]
fn test_build_null_body_value() {
let cols = vec![("note".into(), Value::Null)];
let (sql, params, key) = build_query("POST", "items", None, &cols, &[]).unwrap();
assert_eq!(sql, "INSERT INTO items (note) VALUES ($1) RETURNING *");
assert_eq!(params, vec![Value::Null]);
assert_eq!(key, "POST:items:note");
}
#[test]
fn test_rejects_invalid_table_name() {
let result = build_query("GET", "users; DROP TABLE users--", None, &[], &[]);
assert!(result.is_err());
}
}

7
src/routes/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod admin;
pub mod auth;
pub mod crud;
pub fn is_valid_identifier(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
}

255
src/state.rs Normal file
View file

@ -0,0 +1,255 @@
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use std::time::{SystemTime, UNIX_EPOCH};
use dashmap::DashMap;
use glob::Pattern;
use sqlx::PgPool;
use tokio::sync::RwLock;
use crate::config::Config;
use crate::models::blacklist::BlacklistEntry;
pub fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub query_cache: QueryCache,
pub blacklist_cache: BlacklistCache,
pub config: Arc<Config>,
}
#[derive(Clone)]
pub struct QueryCache {
pub map: Arc<DashMap<String, CacheEntry>>,
pub hits: Arc<AtomicU64>,
pub misses: Arc<AtomicU64>,
}
#[derive(Clone, Debug)]
pub struct CacheEntry {
pub sql: String,
pub last_accessed_secs: Arc<AtomicU64>,
}
impl CacheEntry {
pub fn new(sql: String) -> Self {
Self {
sql,
last_accessed_secs: Arc::new(AtomicU64::new(unix_now())),
}
}
pub fn touch(&self) {
self.last_accessed_secs.store(unix_now(), Ordering::Relaxed);
}
pub fn last_accessed(&self) -> u64 {
self.last_accessed_secs.load(Ordering::Relaxed)
}
}
impl QueryCache {
pub fn new() -> Self {
Self {
map: Arc::new(DashMap::new()),
hits: Arc::new(AtomicU64::new(0)),
misses: Arc::new(AtomicU64::new(0)),
}
}
pub fn get(&self, key: &str) -> Option<CacheEntry> {
if let Some(entry) = self.map.get(key) {
entry.touch();
self.hits.fetch_add(1, Ordering::Relaxed);
Some(entry.clone())
} else {
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
}
pub fn insert(&self, key: String, entry: CacheEntry, max_capacity: usize) {
if self.map.len() >= max_capacity {
let oldest_key = self
.map
.iter()
.min_by_key(|e| e.last_accessed())
.map(|e| e.key().clone());
if let Some(k) = oldest_key {
self.map.remove(&k);
}
}
self.map.insert(key, entry);
}
#[allow(dead_code)]
pub fn remove(&self, key: &str) {
self.map.remove(key);
}
pub fn hits(&self) -> u64 {
self.hits.load(Ordering::Relaxed)
}
pub fn misses(&self) -> u64 {
self.misses.load(Ordering::Relaxed)
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn flush(&self) {
self.map.clear();
}
}
#[derive(Clone)]
pub struct BlacklistCache {
pub inner: Arc<RwLock<Vec<CompiledEntry>>>,
}
#[derive(Clone)]
pub struct CompiledEntry {
pub entry: BlacklistEntry,
pub pattern: Pattern,
pub bypass_mask: Option<u128>,
}
impl BlacklistCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(Vec::new())),
}
}
pub async fn load(&self, entries: Vec<BlacklistEntry>) {
let compiled: Vec<CompiledEntry> = entries
.into_iter()
.filter_map(|e| {
let bypass_mask = e
.bypass_mask
.as_deref()
.and_then(|s| s.parse::<u128>().ok());
Pattern::new(&e.pattern).ok().map(|pattern| CompiledEntry {
entry: e,
pattern,
bypass_mask,
})
})
.collect();
let mut guard = self.inner.write().await;
*guard = compiled;
}
/// Returns true if the request should be blocked.
/// `caller_mask` is 0 for unauthenticated requests; bypass only applies
/// when the caller holds the permission bit stored in bypass_mask.
pub async fn is_blocked(&self, method: &str, path: &str, caller_mask: u128) -> bool {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
let guard = self.inner.read().await;
guard.iter().any(|compiled| {
if !compiled.entry.active {
return false;
}
let method_matches = compiled
.entry
.method
.as_deref()
.map(|m| {
m.split(',')
.any(|part| part.trim().eq_ignore_ascii_case(method))
})
.unwrap_or(true);
if !method_matches || !compiled.pattern.matches(path) {
return false;
}
// If caller holds the bypass permission, they are not blocked.
!matches!(compiled.bypass_mask, Some(mask) if caller_mask & mask != 0)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_insert_and_get() {
let cache = QueryCache::new();
let entry = CacheEntry::new("SELECT 1".into());
cache.insert("key1".into(), entry, 100);
let got = cache.get("key1");
assert!(got.is_some());
assert_eq!(got.unwrap().sql, "SELECT 1");
assert_eq!(cache.hits(), 1);
assert_eq!(cache.misses(), 0);
}
#[test]
fn test_cache_miss() {
let cache = QueryCache::new();
let got = cache.get("missing");
assert!(got.is_none());
assert_eq!(cache.misses(), 1);
}
#[test]
fn test_cache_capacity_evicts_oldest() {
let cache = QueryCache::new();
let e1 = CacheEntry::new("SELECT 1".into());
// force e1 to be older
e1.last_accessed_secs.store(1, Ordering::Relaxed);
cache.map.insert("old".into(), e1);
let e2 = CacheEntry::new("SELECT 2".into());
cache.insert("new".into(), e2, 1); // capacity=1, should evict "old"
assert!(cache.map.get("old").is_none());
assert!(cache.map.get("new").is_some());
}
#[tokio::test]
async fn test_blacklist_blocks_pattern() {
use chrono::Utc;
let cache = BlacklistCache::new();
let entry = BlacklistEntry {
id: 1,
pattern: "/api/users/**".into(),
method: None,
reason: None,
active: true,
bypass_mask: None,
created_at: Utc::now(),
};
cache.load(vec![entry]).await;
assert!(cache.is_blocked("GET", "/api/users/42", 0).await);
assert!(!cache.is_blocked("GET", "/api/orders/1", 0).await);
}
#[tokio::test]
async fn test_blacklist_method_specific() {
use chrono::Utc;
let cache = BlacklistCache::new();
let entry = BlacklistEntry {
id: 2,
pattern: "/api/secrets".into(),
method: Some("GET".into()),
reason: None,
active: true,
bypass_mask: None,
created_at: Utc::now(),
};
cache.load(vec![entry]).await;
assert!(cache.is_blocked("GET", "/api/secrets", 0).await);
assert!(!cache.is_blocked("POST", "/api/secrets", 0).await);
}
}

11
ui/.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
node_modules/
dist/
.npmrc
# vue-tsc emit artifacts (should never appear in src/ — noEmit is set)
src/**/*.vue.js
src/**/*.vue.js.map
src/**/*.vue.d.ts
src/**/*.vue.d.ts.map
src/**/*.ts.js
src/**/*.ts.js.map

230
ui/bun.lock Normal file
View file

@ -0,0 +1,230 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "mercury-ui",
"dependencies": {
"@nychthemeron/library": "latest",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@nychthemeron/library": ["@nychthemeron/library@0.0.1", "https://git.mcpeakdev.com/api/packages/McPeakDev/npm/%40nychthemeron%2Flibrary/-/0.0.1/library-0.0.1.tgz", { "peerDependencies": { "primevue": "^4.5.0", "vue": "^3.5.0" } }, "sha512-84pcTnF8Ead8D7TSLhKTS8ACpaOCTS0mylzkWqgGrNs4fnrH9tEJg874nFHegiFy5P1fuKCk4B7Yt/Pjc4NK/Q=="],
"@primeuix/styled": ["@primeuix/styled@0.7.4", "", { "dependencies": { "@primeuix/utils": "^0.6.1" } }, "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ=="],
"@primeuix/styles": ["@primeuix/styles@2.0.3", "", { "dependencies": { "@primeuix/styled": "^0.7.4" } }, "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw=="],
"@primeuix/utils": ["@primeuix/utils@0.6.4", "", {}, "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg=="],
"@primevue/core": ["@primevue/core@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/utils": "^0.6.2" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ=="],
"@primevue/icons": ["@primevue/icons@4.5.5", "", { "dependencies": { "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5" } }, "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.0", "", { "os": "android", "cpu": "arm64" }, "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.0", "", { "os": "none", "cpu": "arm64" }, "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@volar/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="],
"@volar/source-map": ["@volar/source-map@2.4.15", "", {}, "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg=="],
"@volar/typescript": ["@volar/typescript@2.4.15", "", { "dependencies": { "@volar/language-core": "2.4.15", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.38", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.38", "@vue/compiler-dom": "3.5.38", "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA=="],
"@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"@vue/language-core": ["@vue/language-core@2.2.12", "", { "dependencies": { "@volar/language-core": "2.4.15", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.38", "", { "dependencies": { "@vue/shared": "3.5.38" } }, "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.38", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.38", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/runtime-core": "3.5.38", "@vue/shared": "3.5.38", "csstype": "^3.2.3" } }, "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.38", "", { "dependencies": { "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "vue": "3.5.38" } }, "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw=="],
"@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="],
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pinia": ["pinia@2.3.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"primevue": ["primevue@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/styles": "^2.0.3", "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5", "@primevue/icons": "4.5.5" } }, "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A=="],
"rollup": ["rollup@4.62.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.0", "@rollup/rollup-android-arm64": "4.62.0", "@rollup/rollup-darwin-arm64": "4.62.0", "@rollup/rollup-darwin-x64": "4.62.0", "@rollup/rollup-freebsd-arm64": "4.62.0", "@rollup/rollup-freebsd-x64": "4.62.0", "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", "@rollup/rollup-linux-arm-musleabihf": "4.62.0", "@rollup/rollup-linux-arm64-gnu": "4.62.0", "@rollup/rollup-linux-arm64-musl": "4.62.0", "@rollup/rollup-linux-loong64-gnu": "4.62.0", "@rollup/rollup-linux-loong64-musl": "4.62.0", "@rollup/rollup-linux-ppc64-gnu": "4.62.0", "@rollup/rollup-linux-ppc64-musl": "4.62.0", "@rollup/rollup-linux-riscv64-gnu": "4.62.0", "@rollup/rollup-linux-riscv64-musl": "4.62.0", "@rollup/rollup-linux-s390x-gnu": "4.62.0", "@rollup/rollup-linux-x64-gnu": "4.62.0", "@rollup/rollup-linux-x64-musl": "4.62.0", "@rollup/rollup-openbsd-x64": "4.62.0", "@rollup/rollup-openharmony-arm64": "4.62.0", "@rollup/rollup-win32-arm64-msvc": "4.62.0", "@rollup/rollup-win32-ia32-msvc": "4.62.0", "@rollup/rollup-win32-x64-gnu": "4.62.0", "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/compiler-sfc": "3.5.38", "@vue/runtime-dom": "3.5.38", "@vue/server-renderer": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A=="],
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-tsc": ["vue-tsc@2.2.12", "", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
}
}

13
ui/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mercury</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

23
ui/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "mercury-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@nychthemeron/library": "latest",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0"
}
}

13
ui/public/favicon.svg Normal file
View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Background circle -->
<circle cx="32" cy="32" r="32" fill="#1a1410"/>
<!-- Mercury symbol ☿ -->
<!-- Top crescent (horns) -->
<path d="M22 18 Q32 10 42 18" fill="none" stroke="#c9a84c" stroke-width="3.5" stroke-linecap="round"/>
<!-- Circle for the head -->
<circle cx="32" cy="25" r="7" fill="none" stroke="#c9a84c" stroke-width="3.5"/>
<!-- Vertical staff -->
<line x1="32" y1="32" x2="32" y2="50" stroke="#c9a84c" stroke-width="3.5" stroke-linecap="round"/>
<!-- Cross bar -->
<line x1="24" y1="40" x2="40" y2="40" stroke="#c9a84c" stroke-width="3.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

3
ui/src/App.vue Normal file
View file

@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

246
ui/src/assets/main.css Normal file
View file

@ -0,0 +1,246 @@
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
font-family: var(--font-serif);
background-color: var(--surface-0);
color: var(--text-body);
min-height: 100vh;
}
h1,
h2,
h3 {
font-family: var(--font-serif);
font-weight: 600; /* Cinzel's actual heaviest weight — avoids synthetic bold */
font-optical-sizing: auto;
color: var(--text-high);
margin: 0;
letter-spacing: 0.06em;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code {
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--text-label);
background: var(--surface-2);
padding: 0.1em 0.4em;
border-radius: 3px;
border: 1px solid var(--border-lo);
}
/* ── Page header ────────────────────────────────────────── */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 1.75rem;
}
.page-title {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.page-title h2 {
font-size: 1.6rem;
line-height: 1;
}
.page-title .subtitle {
font-family: var(--font-sans);
font-size: 0.8rem;
color: var(--text-muted);
letter-spacing: 0.02em;
}
/* ── Table card ─────────────────────────────────────────── */
.table-card {
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.07),
0 1px 4px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
/* Scroll wrapper sits inside the card so the header stays pinned */
.table-scroll {
overflow-x: auto;
}
.table-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.table-card-header .count {
font-size: 0.78rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
.data-table {
width: 100%;
min-width: max-content;
border-collapse: collapse;
font-family: var(--font-serif);
font-size: 0.875rem;
}
.data-table th {
padding: 0.65rem 1.25rem;
text-align: left;
background-color: var(--surface-2);
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 0.7rem;
color: var(--text-muted);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.data-table td {
padding: 0.8rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--border-lo);
color: var(--text-body);
vertical-align: middle;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table tbody tr {
transition: background-color 0.12s ease;
}
.data-table tbody tr:hover td {
background: color-mix(in srgb, var(--primary) 4%, var(--surface-1));
}
.actions-cell {
display: flex;
gap: 0.4rem;
}
/* ── Empty state ────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 4rem 2rem;
color: var(--text-dim);
}
.empty-state .empty-icon {
font-size: 2.5rem;
opacity: 0.35;
line-height: 1;
}
.empty-state .empty-label {
font-family: var(--font-serif);
font-size: 1rem;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.empty-state .empty-hint {
font-size: 0.8rem;
color: var(--text-dim);
}
/* ── Dialog form ────────────────────────────────────────── */
.dialog-form {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-top: 0.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.field label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.field .hint {
margin: 0;
font-size: 0.78rem;
color: var(--text-dim);
}
.optional {
font-weight: 400;
font-size: 0.72rem;
color: var(--text-dim);
letter-spacing: 0;
text-transform: none;
}
/* ── Gold divider ───────────────────────────────────────── */
.gold-rule {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--primary);
font-size: 0.6rem;
letter-spacing: 0.2em;
opacity: 0.6;
}
.gold-rule::before,
.gold-rule::after {
content: "";
flex: 1;
height: 1px;
background: linear-gradient(
90deg,
transparent,
var(--primary),
transparent
);
}
.nych-dialog {
min-width: 500px;
}
[class^="nych-button"] {
box-shadow: unset !important;
}
[class^="nych-button"]:hover {
box-shadow: 0 0 5px var(--text-high);
}

7
ui/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent
export default component
}

16
ui/src/main.ts Normal file
View file

@ -0,0 +1,16 @@
import { createApp } from "vue";
import '@nychthemeron/library/theme'
import './assets/main.css'
import './stores/theme'
import { createPinia } from "pinia";
import PrimeVue from "@primevue/core/config";
import { createNychthemeron } from "@nychthemeron/library";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(PrimeVue, { unstyled: true });
app.use(createNychthemeron());
app.mount("#app");

36
ui/src/router/index.ts Normal file
View file

@ -0,0 +1,36 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('../views/Login.vue') },
{
path: '/admin',
component: () => import('../views/admin/Layout.vue'),
children: [
{ path: 'queries', component: () => import('../views/admin/Queries.vue') },
{ path: 'tables', component: () => import('../views/admin/Tables.vue') },
{ path: 'users', component: () => import('../views/admin/Users.vue') },
{ path: 'permissions', component: () => import('../views/admin/Permissions.vue') },
{ path: 'blacklist', component: () => import('../views/admin/Blacklist.vue') },
{ path: 'api-keys', component: () => import('../views/admin/ApiKeys.vue') },
{ path: 'cache', component: () => import('../views/admin/Cache.vue') },
],
meta: { requiresAuth: true },
},
{ path: '/', redirect: '/admin/queries' },
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return '/login'
}
if (to.path === '/login' && auth.isAuthenticated) {
return '/admin/queries'
}
})
export default router

70
ui/src/stores/auth.ts Normal file
View file

@ -0,0 +1,70 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
interface Claims {
sub: string;
permissions: string;
exp: number;
}
function parseJwt(token: string): Claims | null {
try {
const payload = token.split(".")[1];
return payload ? (JSON.parse(atob(payload)) as Claims) : null;
} catch {
return null;
}
}
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(localStorage.getItem("mercury_token"));
const claims = computed<Claims | null>(() =>
token.value ? parseJwt(token.value) : null,
);
const isAuthenticated = computed(() => {
if (!claims.value) return false;
return claims.value.exp * 1000 > Date.now();
});
const username = computed(() => claims.value?.sub ?? "");
function hasPermission(bit: bigint): boolean {
if (!claims.value) return false;
const mask = BigInt(claims.value.permissions);
return (mask & bit) !== 0n;
}
const isSuperAdmin = computed(() => hasPermission(32n));
async function login(username: string, password: string): Promise<void> {
const res = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error("Invalid credentials");
const data = await res.json();
token.value = data.token;
localStorage.setItem("mercury_token", data.token);
}
function logout() {
token.value = null;
localStorage.removeItem("mercury_token");
}
function authHeaders(): Record<string, string> {
return token.value ? { Authorization: `Bearer ${token.value}` } : {};
}
return {
token,
claims,
isAuthenticated,
username,
isSuperAdmin,
hasPermission,
login,
logout,
authHeaders,
};
});

24
ui/src/stores/theme.ts Normal file
View file

@ -0,0 +1,24 @@
import { ref } from 'vue'
export type Theme = 'apollo' | 'hades'
const stored = localStorage.getItem('mercury-theme') as Theme | null
const theme = ref<Theme>(stored ?? 'apollo')
function applyTheme(t: Theme) {
theme.value = t
document.documentElement.setAttribute('data-theme', t)
localStorage.setItem('mercury-theme', t)
}
applyTheme(theme.value)
export function useTheme() {
return {
theme,
isDark: () => theme.value === 'hades',
toggle() {
applyTheme(theme.value === 'apollo' ? 'hades' : 'apollo')
},
}
}

174
ui/src/views/Login.vue Normal file
View file

@ -0,0 +1,174 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-brand">
<div class="brand-ornament"></div>
<h1>Mercury</h1>
<p class="brand-sub">Administration Console</p>
<div class="brand-rule"></div>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="login-field">
<label>Username</label>
<NychInputText
v-model="username"
placeholder="username"
fluid
autocomplete="username"
/>
</div>
<div class="login-field">
<label>Password</label>
<NychInputText
v-model="password"
type="password"
placeholder="••••••••"
fluid
autocomplete="current-password"
/>
</div>
<p v-if="error" class="login-error">{{ error }}</p>
<NychButton type="submit" :loading="loading"
>Sign In</NychButton
>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
const username = ref("");
const password = ref("");
const loading = ref(false);
const error = ref("");
async function handleLogin() {
loading.value = true;
error.value = "";
try {
await auth.login(username.value, password.value);
router.push("/admin/queries");
} catch {
error.value = "Invalid username or password";
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.login-page {
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
background-color: var(--surface-0);
background-image: radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--primary) 8%, transparent),
transparent
);
}
.login-card {
width: 380px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.04),
0 12px 40px rgba(0, 0, 0, 0.1),
0 0 0 1px color-mix(in srgb, var(--primary) 10%, transparent);
padding: 2.5rem 2.25rem 2.25rem;
}
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
margin-bottom: 2rem;
}
.brand-ornament {
color: var(--primary);
font-size: 0.9rem;
opacity: 0.7;
letter-spacing: 0.3em;
margin-bottom: 0.4rem;
}
.login-brand h1 {
font-family: var(--font-serif);
font-size: 2.2rem;
font-weight: 400;
letter-spacing: 0.2em;
color: var(--primary);
text-transform: uppercase;
line-height: 1;
margin: 0;
}
.brand-sub {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text-dim);
font-family: var(--font-sans);
}
.brand-rule {
width: 100%;
height: 1px;
margin-top: 1.25rem;
background: linear-gradient(
90deg,
transparent,
var(--border-hi),
transparent
);
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.login-field label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.login-error {
margin: 0;
font-size: 0.8rem;
color: var(--danger);
text-align: center;
padding: 0.5rem;
background: var(--danger-subtle);
border: 1px solid var(--danger-border);
border-radius: 6px;
}
.nych-button-primary {
margin-top: 2px;
}
</style>

View file

@ -0,0 +1,245 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>API Keys</h2>
<span class="subtitle">Long-lived tokens for non-interactive service access scoped by permission mask</span>
</div>
<NychButton @click="showCreate = true">+ New Key</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ keys.length }} {{ keys.length === 1 ? 'key' : 'keys' }}</span>
</div>
<template v-if="keys.length"><div class="table-scroll"><table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Role</th>
<th>Mask</th>
<th>Created</th>
<th>Expires</th>
<th>Last used</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="k in keys" :key="k.id">
<td class="name-cell">{{ k.name }}</td>
<td><code class="prefix-cell">mrc_{{ k.key_prefix }}</code></td>
<td><span class="role-badge" :class="roleBadgeClass(k.permissions_mask)">{{ roleName(k.permissions_mask) }}</span></td>
<td><code>{{ k.permissions_mask }}</code></td>
<td class="date-cell">{{ fmtDate(k.created_at) }}</td>
<td class="date-cell">{{ k.expires_at ? fmtDate(k.expires_at) : '—' }}</td>
<td class="date-cell">{{ k.last_used_at ? fmtDate(k.last_used_at) : 'Never' }}</td>
<td class="actions-cell">
<NychButton size="small" severity="danger" @click="revoke(k.id, k.name)">Revoke</NychButton>
</td>
</tr>
</tbody>
</table></div></template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">No API keys</span>
<span class="empty-hint">Create a key to allow service-to-service access without user credentials.</span>
</div>
</div>
<!-- Create dialog -->
<NychDialog v-model:visible="showCreate" header="New API Key" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInputText v-model="form.name" placeholder="e.g. CI pipeline, data importer" fluid />
</div>
<div class="field">
<label>Role</label>
<NychSelect v-model="form.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" placeholder="Select a role" fluid />
<p class="hint">{{ ROLES.find(r => r.value === form.permissions_mask)?.description ?? '' }}</p>
</div>
<div class="field">
<label>Expires <span class="optional">(optional leave blank for no expiry)</span></label>
<NychInputText v-model="form.expires_at" type="datetime-local" fluid />
</div>
<NychButton type="submit" :disabled="!form.name || !form.permissions_mask" fluid>Generate Key</NychButton>
</form>
</NychDialog>
<!-- Key reveal dialog shown once after creation -->
<NychDialog v-model:visible="showReveal" header="API Key Created" :modal="true" :draggable="false" :closable="false" style="width: min(600px, 95vw)">
<div class="reveal-body">
<NychMessage severity="warn" class="reveal-warning">
Copy this key now it will <strong>not</strong> be shown again.
</NychMessage>
<div class="key-display">
<code class="key-text">{{ newKey }}</code>
<button type="button" class="copy-btn" :class="{ copied }" @click="copyKey" title="Copy to clipboard">
{{ copied ? '✓' : '⎘' }}
</button>
</div>
<NychButton fluid @click="closeReveal">I've saved the key</NychButton>
</div>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const keys = ref<any[]>([])
const showCreate = ref(false)
const showReveal = ref(false)
const newKey = ref('')
const copied = ref(false)
const form = ref({ name: '', permissions_mask: '', expires_at: '' })
const ROLES = [
{ label: 'Viewer', value: '1', description: 'Read-only access to API data' },
{ label: 'Editor', value: '7', description: 'Read, write, and delete via API' },
{ label: 'Query Admin', value: '15', description: 'Editor + manage SQL query registry' },
{ label: 'Ops Admin', value: '23', description: 'Editor + manage query cache' },
{ label: 'Admin', value: '31', description: 'All above combined' },
{ label: 'Super Admin', value: '63', description: 'Full access including users, permissions, and blacklist' },
]
function roleName(mask: string) {
return ROLES.find(r => r.value === mask)?.label ?? 'Custom'
}
function roleBadgeClass(mask: string) {
const map: Record<string, string> = {
'1': 'role-viewer', '7': 'role-editor',
'15': 'role-query', '23': 'role-ops',
'31': 'role-admin', '63': 'role-super',
}
return map[mask] ?? 'role-custom'
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
async function load() {
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
keys.value = await res.json()
}
async function submitCreate() {
const body: any = {
name: form.value.name,
permissions_mask: form.value.permissions_mask,
}
if (form.value.expires_at) {
body.expires_at = new Date(form.value.expires_at).toISOString()
}
const res = await fetch('/api/admin/api-keys', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json()
showCreate.value = false
form.value = { name: '', permissions_mask: '', expires_at: '' }
newKey.value = data.key
copied.value = false
showReveal.value = true
load()
}
async function revoke(id: number, name: string) {
if (!confirm(`Revoke key "${name}"? Any services using it will lose access immediately.`)) return
await fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
}
async function copyKey() {
await navigator.clipboard.writeText(newKey.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function closeReveal() {
showReveal.value = false
newKey.value = ''
}
onMounted(load)
</script>
<style scoped>
.name-cell { font-weight: 600; color: var(--text-high); }
.prefix-cell { font-size: 0.8rem; color: var(--text-muted); }
.date-cell { color: var(--text-muted); font-size: 0.82rem; font-family: var(--font-mono); }
.role-badge {
display: inline-block;
padding: 0.2rem 0.65rem;
border-radius: 20px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.role-viewer { background: var(--info-subtle); color: var(--info); border: 1px solid var(--info-border); }
.role-editor { background: var(--success-subtle); color: var(--success); border: 1px solid var(--success-border); }
.role-query { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
.role-ops { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
.role-admin { background: color-mix(in srgb, var(--primary) 12%, var(--surface-1)); color: var(--primary); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); }
.role-super { background: var(--primary); color: var(--primary-fg); border: 1px solid var(--primary); }
.role-custom { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
/* Key reveal */
.reveal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 0.25rem;
}
.reveal-warning {
width: 100%;
}
.key-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
}
.key-text {
flex: 1;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text-high);
word-break: break-all;
background: none;
border: none;
padding: 0;
}
.copy-btn {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 5px;
border: 1px solid var(--border);
background: var(--surface-1);
color: var(--text-muted);
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.copy-btn:hover { border-color: var(--primary); color: var(--primary); }
.copy-btn.copied { border-color: var(--success); color: var(--success); background: var(--success-subtle); }
</style>

View file

@ -0,0 +1,232 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>Route Blacklist</h2>
<span class="subtitle">Glob patterns blocking API routes matched against path and HTTP method</span>
</div>
<NychButton @click="showCreate = true">+ Add Pattern</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }}</span>
</div>
<template v-if="entries.length"><div class="table-scroll"><table class="data-table">
<thead>
<tr>
<th>Pattern</th>
<th>Method</th>
<th>Status</th>
<th>Reason</th>
<th>Bypass mask</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="e in entries" :key="e.id">
<td><code>{{ e.pattern }}</code></td>
<td>
<div class="method-cell">
<template v-if="e.method">
<span v-for="m in e.method.split(',')" :key="m" class="method-badge">{{ m.trim() }}</span>
</template>
<span v-else class="dim">ALL</span>
</div>
</td>
<td>
<span class="status-badge" :class="e.active ? 'status-active' : 'status-inactive'">
{{ e.active ? 'Active' : 'Disabled' }}
</span>
</td>
<td class="reason-cell">{{ e.reason ?? '—' }}</td>
<td><span v-if="e.bypass_mask" class="bit-badge">{{ e.bypass_mask }}</span><span v-else class="dim"></span></td>
<td class="actions-cell">
<NychButton size="small" @click="openEdit(e)">Edit</NychButton>
<NychButton size="small" severity="danger" @click="deleteEntry(e.id)">Delete</NychButton>
</td>
</tr>
</tbody>
</table></div></template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">Blacklist is empty</span>
<span class="empty-hint">All routes are currently open. Add a pattern to block access.</span>
</div>
</div>
<NychDialog v-model:visible="showEdit" header="Edit Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Pattern</label>
<NychInputText v-model="editForm.pattern" fluid />
</div>
<div class="field">
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
<MethodSelect v-model="editForm.methods" :options="HTTP_METHODS" placeholder="All methods" />
</div>
<div class="field">
<label>Reason <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.reason" fluid />
</div>
<div class="field">
<label>Bypass permission mask <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.bypass_mask" placeholder="32" fluid />
</div>
<div class="field">
<label>Status</label>
<NychSelect v-model="editForm.active" :options="[{ label: 'Active', value: true }, { label: 'Disabled', value: false }]" optionLabel="label" optionValue="value" fluid />
</div>
<NychButton type="submit" fluid>Save Changes</NychButton>
</form>
</NychDialog>
<NychDialog v-model:visible="showCreate" header="New Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Pattern</label>
<NychInputText v-model="form.pattern" placeholder="/api/sensitive/**" fluid />
<p class="hint">Use <code>*</code> for one segment, <code>**</code> for any depth.</p>
</div>
<div class="field">
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
<MethodSelect v-model="form.methods" :options="HTTP_METHODS" placeholder="All methods" />
</div>
<div class="field">
<label>Reason <span class="optional">(optional)</span></label>
<NychInputText v-model="form.reason" placeholder="Why is this route blocked?" fluid />
</div>
<div class="field">
<label>Bypass permission mask <span class="optional">(optional)</span></label>
<NychInputText v-model="form.bypass_mask" placeholder="32" fluid />
<p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
</div>
<NychButton type="submit" fluid>Add to Blacklist</NychButton>
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import MethodSelect from './MethodSelect.vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const entries = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editId = ref<number | null>(null)
const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: true })
const form = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '' })
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
async function load() {
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
entries.value = await res.json()
}
function methodsToString(methods: string[]): string | null {
return methods.length ? methods.join(',') : null
}
function stringToMethods(s: string | null | undefined): string[] {
return s ? s.split(',').map(m => m.trim()).filter(Boolean) : []
}
async function submitCreate() {
await fetch('/api/blacklist', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: form.value.pattern,
method: methodsToString(form.value.methods),
reason: form.value.reason || null,
bypass_mask: form.value.bypass_mask || null,
active: true,
}),
})
showCreate.value = false
form.value = { pattern: '', methods: [], reason: '', bypass_mask: '' }
load()
}
function openEdit(e: any) {
editId.value = e.id
editForm.value = {
pattern: e.pattern,
methods: stringToMethods(e.method),
reason: e.reason ?? '',
bypass_mask: e.bypass_mask ?? '',
active: e.active,
}
showEdit.value = true
}
async function submitEdit() {
await fetch(`/api/blacklist/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: editForm.value.pattern,
method: methodsToString(editForm.value.methods),
reason: editForm.value.reason || null,
bypass_mask: editForm.value.bypass_mask || null,
active: editForm.value.active,
}),
})
showEdit.value = false
load()
}
async function deleteEntry(id: number) {
if (!confirm('Delete this blacklist entry?')) return
await fetch(`/api/blacklist/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
}
onMounted(load)
</script>
<style scoped>
.reason-cell { color: var(--text-muted); font-size: 0.85rem; }
.dim { color: var(--text-dim); font-size: 0.8rem; font-family: var(--font-mono); }
.method-cell { display: flex; gap: 0.25rem; flex-wrap: wrap; align-items: center; }
.bit-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.78rem;
background: var(--surface-2);
border: 1px solid var(--border-lo);
color: var(--text-label);
}
.method-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
background: var(--surface-2);
border: 1px solid var(--border-lo);
color: var(--text-label);
}
.status-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 20px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.status-active { background: var(--danger-subtle); color: var(--danger); border: 1px solid var(--danger-border); }
.status-inactive { background: var(--surface-2); color: var(--text-dim); border: 1px solid var(--border-lo); }
</style>

View file

@ -0,0 +1,154 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>Query Cache</h2>
<span class="subtitle">In-memory LRU cache for compiled SQL queries inspect and flush as needed</span>
</div>
<div class="header-actions">
<NychButton @click="load">Refresh</NychButton>
<NychButton severity="danger" @click="flushCache">Flush Cache</NychButton>
</div>
</div>
<div class="stats-grid" v-if="stats">
<div class="stat-card">
<span class="stat-label">Entries</span>
<span class="stat-value">{{ stats.size }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Cache Hits</span>
<span class="stat-value success">{{ stats.hits }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Cache Misses</span>
<span class="stat-value muted">{{ stats.misses }}</span>
</div>
<div class="stat-card hit-rate-card">
<span class="stat-label">Hit Rate</span>
<span class="stat-value" :class="hitRateClass">{{ hitRate }}%</span>
<div class="hit-rate-bar">
<div class="hit-rate-fill" :style="{ width: hitRate + '%' }"></div>
</div>
</div>
</div>
<div class="no-stats" v-else>
<span class="empty-icon"></span>
<span class="empty-label">Cache stats unavailable</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const stats = ref<{ size: number; hits: number; misses: number } | null>(null)
const hitRate = computed(() => {
if (!stats.value) return 0
const total = stats.value.hits + stats.value.misses
return total === 0 ? 0 : Math.round((stats.value.hits / total) * 100)
})
const hitRateClass = computed(() => {
const r = hitRate.value
if (r >= 80) return 'good'
if (r >= 50) return 'ok'
return 'poor'
})
async function load() {
const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() })
stats.value = await res.json()
}
async function flushCache() {
if (!confirm('Flush the entire query cache?')) return
await fetch('/api/admin/cache/', { method: 'DELETE', headers: auth.authHeaders() })
load()
}
onMounted(load)
</script>
<style scoped>
.header-actions { display: flex; gap: 0.5rem; }
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.stat-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem 1.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.stat-label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.stat-value {
font-family: var(--font-mono);
font-size: 2.4rem;
font-weight: 500;
line-height: 1;
color: var(--primary);
}
.stat-value.success { color: var(--success); }
.stat-value.muted { color: var(--text-muted); }
.stat-value.good { color: var(--success); }
.stat-value.ok { color: var(--warn); }
.stat-value.poor { color: var(--danger); }
.hit-rate-card { gap: 0.75rem; }
.hit-rate-bar {
height: 4px;
background: var(--surface-2);
border-radius: 2px;
overflow: hidden;
margin-top: auto;
}
.hit-rate-fill {
height: 100%;
background: var(--primary);
border-radius: 2px;
transition: width 0.4s ease;
}
.no-stats {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 4rem;
color: var(--text-dim);
}
.no-stats .empty-icon {
font-size: 2.5rem;
opacity: 0.3;
}
.no-stats .empty-label {
font-family: var(--font-serif);
font-size: 1rem;
color: var(--text-muted);
}
</style>

View file

@ -0,0 +1,265 @@
<template>
<div class="admin-layout">
<nav class="sidebar">
<div class="sidebar-brand">
<img src="/favicon.svg" class="brand-mark" alt="" />
<span class="brand-name">Mercury</span>
</div>
<div class="sidebar-rule"></div>
<ul class="nav-list">
<li v-for="item in navItems" :key="item.to">
<RouterLink :to="item.to">
<span class="nav-icon">{{ item.icon }}</span>
{{ item.label }}
</RouterLink>
</li>
</ul>
<div class="sidebar-footer">
<div class="user-info">
<span class="user-avatar">{{ usernameInitial }}</span>
<span class="user-name">{{ auth.username }}</span>
</div>
<div class="footer-actions">
<button
class="icon-btn"
:title="isDark ? 'Light mode' : 'Dark mode'"
@click="toggleTheme"
>
{{ isDark ? "☀" : "☾" }}
</button>
<button
class="icon-btn danger-btn"
title="Sign out"
@click="handleLogout"
>
</button>
</div>
</div>
</nav>
<main class="content">
<div class="page-wrapper">
<RouterView />
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../../stores/auth";
import { useTheme } from "../../stores/theme";
const auth = useAuthStore();
const router = useRouter();
const { theme, toggle: toggleTheme } = useTheme();
const isDark = computed(() => theme.value === "hades");
const usernameInitial = computed(() =>
(auth.username?.[0] ?? "?").toUpperCase(),
);
const navItems = [
{ to: "/admin/queries", label: "Queries", icon: "⌗" },
{ to: "/admin/tables", label: "Tables", icon: "▦" },
{ to: "/admin/users", label: "Users", icon: "◉" },
{ to: "/admin/permissions", label: "Permissions", icon: "⬡" },
{ to: "/admin/blacklist", label: "Blacklist", icon: "⊘" },
{ to: "/admin/api-keys", label: "API Keys", icon: "⚿" },
{ to: "/admin/cache", label: "Cache", icon: "◈" },
];
function handleLogout() {
auth.logout();
router.push("/login");
}
</script>
<style scoped>
.admin-layout {
display: flex;
height: 100vh;
background-color: var(--surface-0);
}
/* ── Sidebar ──────────────────────────────────────── */
.sidebar {
width: 224px;
display: flex;
flex-direction: column;
background-color: var(--surface-1);
border-right: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 1.5rem 1.25rem 1.25rem;
}
.brand-mark {
width: 1.4rem;
height: 1.4rem;
flex-shrink: 0;
}
.brand-name {
font-family: var(--font-serif);
font-size: 1.3rem;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--primary);
line-height: 1;
}
.sidebar-rule {
height: 1px;
margin: 0 1.25rem 0.75rem;
background: linear-gradient(90deg, var(--border-lo), transparent);
}
/* ── Nav ──────────────────────────────────────────── */
.nav-list {
list-style: none;
padding: 0 0.75rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
}
.nav-list li a {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: 7px;
color: var(--text-label);
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 500;
text-decoration: none;
transition:
background-color 0.12s ease,
color 0.12s ease;
letter-spacing: 0.01em;
}
.nav-list li a:hover {
background: var(--surface-2);
color: var(--text-high);
text-decoration: none;
}
.nav-list li a.router-link-active {
background: color-mix(in srgb, var(--primary) 10%, var(--surface-1));
color: var(--primary);
font-weight: 600;
}
.nav-icon {
font-size: 0.9rem;
width: 1.1rem;
text-align: center;
opacity: 0.65;
flex-shrink: 0;
}
.nav-list li a.router-link-active .nav-icon {
opacity: 1;
}
/* ── Footer ───────────────────────────────────────── */
.sidebar-footer {
padding: 0.875rem 1rem;
border-top: 1px solid var(--border-lo);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.user-avatar {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: color-mix(in srgb, var(--primary) 15%, var(--surface-2));
color: var(--primary);
font-size: 0.7rem;
font-weight: 700;
font-family: var(--font-serif);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid color-mix(in srgb, var(--primary) 25%, var(--border));
}
.user-name {
font-size: 0.8rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-sans);
}
.footer-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
font-size: 0.85rem;
cursor: pointer;
transition:
background-color 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
}
.icon-btn:hover {
background: var(--surface-2);
color: var(--text-high);
border-color: var(--border-lo);
}
.danger-btn:hover {
background: var(--danger-subtle);
color: var(--danger);
border-color: var(--danger-border);
}
/* ── Content ──────────────────────────────────────── */
.content {
flex: 1;
padding: 2.25rem 2.5rem;
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,122 @@
<template>
<div ref="triggerRef">
<button
type="button"
class="nych-select ms-trigger"
@click="toggleOpen"
>
<span
class="nych-select-label"
:data-p="modelValue.length === 0 ? 'placeholder' : undefined"
>
{{ modelValue.length === 0 ? placeholder : modelValue.join(', ') }}
</span>
<span class="nych-select-dropdown">
<svg
class="nych-select-dropdownIcon ms-chevron"
:class="{ 'ms-chevron-open': isOpen }"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
<Teleport to="body">
<template v-if="isOpen">
<div class="ms-backdrop" @click="close" />
<div class="nych-select-overlay ms-panel" :style="overlayStyle">
<ul class="nych-select-list">
<li
v-for="opt in options"
:key="opt"
class="nych-select-option"
:data-p-selected="modelValue.includes(opt) ? 'true' : undefined"
@click="toggleOption(opt)"
>
{{ opt }}
</li>
</ul>
</div>
</template>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
modelValue: string[]
options: string[]
placeholder?: string
}>(), { placeholder: 'Select…' })
const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
const triggerRef = ref<HTMLElement>()
const isOpen = ref(false)
const overlayStyle = ref<Record<string, string>>({})
function open() {
const rect = triggerRef.value!.getBoundingClientRect()
overlayStyle.value = {
position: 'fixed',
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
}
isOpen.value = true
window.addEventListener('scroll', close, { capture: true, once: true })
}
function close() {
isOpen.value = false
}
function toggleOpen() {
isOpen.value ? close() : open()
}
function toggleOption(opt: string) {
const cur = props.modelValue
emit('update:modelValue', cur.includes(opt) ? cur.filter(x => x !== opt) : [...cur, opt])
}
onUnmounted(close)
</script>
<style>
/* Must be global — teleported content renders outside this component's scoped tree */
.ms-trigger {
display: flex;
width: 100%;
min-width: unset;
text-align: left;
font-family: var(--font-sans);
font-size: 14px;
cursor: pointer;
}
.ms-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
}
.ms-panel {
z-index: 9999;
}
.ms-chevron {
transition: transform 0.15s ease;
}
.ms-chevron-open {
transform: rotate(180deg);
}
</style>

View file

@ -0,0 +1,147 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>Permissions</h2>
<span class="subtitle">Bitmask permission flags assigned to users via their permissions mask</span>
</div>
<NychButton @click="showCreate = true">+ New Permission</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ permissions.length }} {{ permissions.length === 1 ? 'permission' : 'permissions' }}</span>
</div>
<template v-if="permissions.length"><div class="table-scroll"><table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Bit Value</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="p in permissions" :key="p.id">
<td class="id-cell">{{ p.id }}</td>
<td><code>{{ p.name }}</code></td>
<td><span class="bit-badge">{{ p.bit_value }}</span></td>
<td class="desc-cell">{{ p.description ?? '—' }}</td>
<td class="actions-cell">
<NychButton size="small" @click="openEdit(p)">Edit</NychButton>
<NychButton size="small" severity="danger" @click="deletePermission(p.id)">Delete</NychButton>
</td>
</tr>
</tbody>
</table></div></template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">No permissions defined</span>
<span class="empty-hint">Define bitmask flags to control fine-grained API access.</span>
</div>
</div>
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.name}`" :modal="true" :draggable="false" style="width: min(600px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInputText v-model="editForm.name" fluid />
</div>
<div class="field">
<label>Description</label>
<NychInputText v-model="editForm.description" fluid />
</div>
<NychButton type="submit" fluid>Save Changes</NychButton>
</form>
</NychDialog>
<NychDialog v-model:visible="showCreate" header="New Permission" :modal="true" :draggable="false" style="width: min(600px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInputText v-model="form.name" placeholder="PERMISSION_NAME" fluid />
</div>
<div class="field">
<label>Bit value</label>
<NychInputText v-model="form.bit_value" placeholder="64" fluid />
<p class="hint">Must be a power of 2 not already in use (1, 2, 4, 8, 16, 32, 64 )</p>
</div>
<div class="field">
<label>Description</label>
<NychInputText v-model="form.description" placeholder="What this permission grants" fluid />
</div>
<NychButton type="submit" fluid>Create Permission</NychButton>
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const permissions = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editId = ref<number | null>(null)
const editForm = ref({ name: '', description: '' })
const form = ref({ name: '', bit_value: '', description: '' })
async function load() {
const res = await fetch('/api/permissions', { headers: auth.authHeaders() })
permissions.value = await res.json()
}
async function submitCreate() {
await fetch('/api/permissions', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
showCreate.value = false
form.value = { name: '', bit_value: '', description: '' }
load()
}
function openEdit(p: any) {
editId.value = p.id
editForm.value = { name: p.name, description: p.description ?? '' }
showEdit.value = true
}
async function submitEdit() {
await fetch(`/api/permissions/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(editForm.value),
})
showEdit.value = false
load()
}
async function deletePermission(id: number) {
if (!confirm('Delete this permission?')) return
await fetch(`/api/permissions/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
}
onMounted(load)
</script>
<style scoped>
.id-cell { color: var(--text-dim); font-family: var(--font-mono); font-size: 0.8rem; }
.desc-cell { color: var(--text-muted); font-size: 0.85rem; }
.bit-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.78rem;
background: var(--surface-2);
border: 1px solid var(--border-lo);
color: var(--text-label);
}
</style>

View file

@ -0,0 +1,137 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>Query Registry</h2>
<span class="subtitle">Named SQL templates executed via the API using bind parameters</span>
</div>
<NychButton @click="showCreate = true">+ New Query</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ queries.length }} {{ queries.length === 1 ? 'query' : 'queries' }}</span>
</div>
<template v-if="queries.length"><div class="table-scroll"><table class="data-table">
<thead>
<tr>
<th>Identifier</th>
<th>Description</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="q in queries" :key="q.identifier">
<td><code>{{ q.identifier }}</code></td>
<td class="desc-cell">{{ q.description ?? '—' }}</td>
<td class="date-cell">{{ new Date(q.updated_at).toLocaleString() }}</td>
<td class="actions-cell">
<NychButton size="small" @click="openEdit(q)">Edit</NychButton>
<NychButton size="small" severity="danger" @click="deleteQuery(q)">Delete</NychButton>
</td>
</tr>
</tbody>
</table></div></template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">No queries registered</span>
<span class="empty-hint">Add your first named SQL template to get started.</span>
</div>
</div>
<NychDialog v-model:visible="showCreate" header="New Query" :modal="true" :draggable="false" style="width: min(800px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Identifier</label>
<NychInputText v-model="form.identifier" placeholder="e.g. get-user-orders" fluid />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="form.description" placeholder="Brief description of what this query does" fluid />
</div>
<div class="field">
<label>SQL Template</label>
<NychTextarea v-model="form.sql_template" placeholder="SELECT * FROM orders WHERE user_id = :user_id" :rows="12" fluid />
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
</div>
<NychButton type="submit" fluid>Save Query</NychButton>
</form>
</NychDialog>
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editIdentifier}`" :modal="true" :draggable="false" style="width: min(800px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>SQL Template</label>
<NychTextarea v-model="editForm.sql_template" :rows="12" fluid />
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.description" placeholder="Brief description" fluid />
</div>
<NychButton type="submit" fluid>Update Query</NychButton>
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const queries = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editId = ref('') // UUID used for PUT/DELETE
const editIdentifier = ref('') // identifier used for display and execute URL
const form = ref({ identifier: '', sql_template: '', description: '' })
const editForm = ref({ sql_template: '', description: '' })
async function load() {
const res = await fetch('/api/queries', { headers: auth.authHeaders() })
queries.value = await res.json()
}
async function submitCreate() {
await fetch('/api/queries', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
showCreate.value = false
form.value = { identifier: '', sql_template: '', description: '' }
load()
}
function openEdit(row: any) {
editId.value = row.id
editIdentifier.value = row.identifier
editForm.value = { sql_template: row.sql_template, description: row.description ?? '' }
showEdit.value = true
}
async function submitEdit() {
await fetch(`/api/queries/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(editForm.value),
})
showEdit.value = false
load()
}
async function deleteQuery(row: any) {
if (!confirm(`Delete query "${row.identifier}"?`)) return
await fetch(`/api/queries/${row.id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
}
onMounted(load)
</script>
<style scoped>
.desc-cell { color: var(--text-muted); font-size: 0.85rem; }
.date-cell { color: var(--text-dim); font-family: var(--font-mono); font-size: 0.8rem; white-space: nowrap; }
</style>

View file

@ -0,0 +1,571 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>Tables</h2>
<span class="subtitle">Create and manage PostgreSQL tables in the public schema</span>
</div>
<NychButton @click="showCreate = true">+ New Table</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ tables.length }} {{ tables.length === 1 ? 'table' : 'tables' }}</span>
</div>
<template v-if="tables.length"><div class="table-scroll"><table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Columns</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tables" :key="t.table_name">
<td class="name-cell"><code>{{ t.table_name }}</code></td>
<td class="count-cell">{{ t.column_count }}</td>
<td class="actions-cell">
<NychButton size="small" severity="secondary" @click="openInspect(t.table_name)">Inspect</NychButton>
<NychButton size="small" severity="danger" @click="openDropPreview(t.table_name)">Drop</NychButton>
</td>
</tr>
</tbody>
</table></div></template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">No tables yet</span>
<span class="empty-hint">Create a table to start storing data via the CRUD API.</span>
</div>
</div>
<!-- Create Table Dialog -->
<NychDialog v-model:visible="showCreate" header="New Table" :modal="true" :draggable="false" style="width: min(700px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Table Name</label>
<NychInputText v-model="createForm.name" placeholder="my_table" fluid autocomplete="off" />
<p class="hint">Lowercase letters, numbers, and underscores only.</p>
</div>
<div class="columns-section">
<div class="columns-header">
<label>Columns</label>
<NychButton type="button" size="small" @click="addColumn">+ Add Column</NychButton>
</div>
<div class="hint fixed-col-hint">An <code>id SERIAL PRIMARY KEY</code> column is always added automatically.</div>
<div class="column-row header-row">
<span>Name</span>
<span>Type</span>
<span>Nullable</span>
<span></span>
</div>
<div v-for="(col, i) in createForm.columns" :key="i" class="column-row">
<NychInputText v-model="col.name" placeholder="column_name" fluid />
<NychSelect
v-model="col.col_type"
:options="COLUMN_TYPES"
fluid
/>
<div class="nullable-toggle">
<input type="checkbox" v-model="col.nullable" :id="`nullable-${i}`" />
<label :for="`nullable-${i}`">Yes</label>
</div>
<button type="button" class="remove-btn" @click="removeColumn(i)"></button>
</div>
<div class="empty-columns" v-if="createForm.columns.length === 0">
<span>Add at least one column.</span>
</div>
</div>
<NychButton type="submit" fluid :disabled="!canSubmitCreate">Create Table</NychButton>
</form>
</NychDialog>
<!-- Inspect Dialog (read-only) -->
<NychDialog v-model:visible="showInspect" :header="`Inspect — ${inspectPreview?.table_name ?? ''}`" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
<div v-if="inspectLoading" class="preview-loading">Loading table data</div>
<div v-else-if="inspectPreview" class="preview-body">
<div class="preview-meta">
<div class="meta-row">
<span class="meta-label">Table</span>
<code class="meta-value">{{ inspectPreview.table_name }}</code>
</div>
<div class="meta-row">
<span class="meta-label">Rows</span>
<span class="meta-value">{{ inspectPreview.row_count.toLocaleString() }}</span>
</div>
<div class="meta-row">
<span class="meta-label">CRUD endpoint</span>
<code class="meta-value endpoint">/api/{{ inspectPreview.table_name }}</code>
</div>
</div>
<div class="schema-section">
<div class="section-label">Schema</div>
<table class="schema-table">
<thead>
<tr>
<th>Column</th>
<th>Type</th>
<th>Nullable</th>
</tr>
</thead>
<tbody>
<tr v-for="col in inspectPreview.columns" :key="col.column_name">
<td><code>{{ col.column_name }}</code></td>
<td><span class="type-badge">{{ col.data_type }}</span></td>
<td>{{ col.is_nullable === 'YES' ? 'yes' : 'no' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="sample-section" v-if="inspectPreview.sample_rows.length > 0">
<div class="section-label">
Sample data
<span class="sample-count">(first {{ inspectPreview.sample_rows.length }} of {{ inspectPreview.row_count.toLocaleString() }})</span>
</div>
<div class="sample-scroll">
<table class="data-table sample-table">
<thead>
<tr>
<th v-for="col in inspectPreview.columns" :key="col.column_name">{{ col.column_name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in inspectPreview.sample_rows" :key="i">
<td v-for="col in inspectPreview.columns" :key="col.column_name">
{{ row[col.column_name] ?? '—' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="empty-state sample-empty" v-else>
<span class="empty-icon"></span>
<span class="empty-label">Empty table</span>
<span class="empty-hint">Use <code>POST /api/{{ inspectPreview.table_name }}</code> to insert rows.</span>
</div>
</div>
</NychDialog>
<!-- Drop Preview Dialog -->
<NychDialog v-model:visible="showDropPreview" header="Drop Table" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
<div v-if="dropPreviewLoading" class="preview-loading">Loading table data</div>
<div v-else-if="dropPreview" class="preview-body">
<div class="preview-meta">
<div class="meta-row">
<span class="meta-label">Table</span>
<code class="meta-value">{{ dropPreview.table_name }}</code>
</div>
<div class="meta-row">
<span class="meta-label">Rows</span>
<span class="meta-value row-count" :class="dropPreview.row_count > 0 ? 'has-data' : 'no-data'">
{{ dropPreview.row_count.toLocaleString() }}
</span>
</div>
</div>
<div class="warning-banner" v-if="dropPreview.row_count > 0">
This table contains {{ dropPreview.row_count.toLocaleString() }} {{ dropPreview.row_count === 1 ? 'row' : 'rows' }} that will be permanently deleted.
</div>
<div class="schema-section">
<div class="section-label">Schema</div>
<table class="schema-table">
<thead>
<tr>
<th>Column</th>
<th>Type</th>
<th>Nullable</th>
</tr>
</thead>
<tbody>
<tr v-for="col in dropPreview.columns" :key="col.column_name">
<td><code>{{ col.column_name }}</code></td>
<td><span class="type-badge">{{ col.data_type }}</span></td>
<td>{{ col.is_nullable === 'YES' ? 'yes' : 'no' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="sample-section" v-if="dropPreview.sample_rows.length > 0">
<div class="section-label">
Sample data
<span class="sample-count">(first {{ dropPreview.sample_rows.length }} of {{ dropPreview.row_count.toLocaleString() }})</span>
</div>
<div class="sample-scroll">
<table class="data-table sample-table">
<thead>
<tr>
<th v-for="col in dropPreview.columns" :key="col.column_name">{{ col.column_name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in dropPreview.sample_rows" :key="i">
<td v-for="col in dropPreview.columns" :key="col.column_name">
{{ row[col.column_name] ?? '—' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="confirm-section">
<label class="confirm-label">Type <strong>{{ dropPreview.table_name }}</strong> to confirm:</label>
<NychInputText v-model="dropConfirmName" :placeholder="dropPreview.table_name" fluid />
</div>
<div class="drop-actions">
<NychButton severity="secondary" @click="showDropPreview = false">Cancel</NychButton>
<NychButton
severity="danger"
:disabled="dropConfirmName !== dropPreview.table_name"
@click="confirmDrop"
>
Drop Table
</NychButton>
</div>
</div>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const COLUMN_TYPES = [
'TEXT', 'INTEGER', 'BIGINT', 'SMALLINT', 'BOOLEAN',
'NUMERIC', 'FLOAT4', 'FLOAT8', 'UUID', 'TIMESTAMPTZ', 'DATE', 'JSONB',
]
interface TableInfo {
table_name: string
column_count: number
}
interface ColumnInfo {
column_name: string
data_type: string
is_nullable: string
}
interface TablePreview {
table_name: string
row_count: number
columns: ColumnInfo[]
sample_rows: Record<string, unknown>[]
}
interface ColumnDef {
name: string
col_type: string
nullable: boolean
}
const tables = ref<TableInfo[]>([])
// create
const showCreate = ref(false)
const createForm = ref<{ name: string; columns: ColumnDef[] }>({ name: '', columns: [] })
const canSubmitCreate = computed(
() => createForm.value.name.trim() !== '' && createForm.value.columns.length > 0,
)
function addColumn() {
createForm.value.columns.push({ name: '', col_type: 'TEXT', nullable: true })
}
function removeColumn(i: number) {
createForm.value.columns.splice(i, 1)
}
// inspect
const showInspect = ref(false)
const inspectLoading = ref(false)
const inspectPreview = ref<TablePreview | null>(null)
// drop
const showDropPreview = ref(false)
const dropPreviewLoading = ref(false)
const dropPreview = ref<TablePreview | null>(null)
const dropConfirmName = ref('')
async function load() {
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
tables.value = await res.json()
}
async function fetchPreview(tableName: string): Promise<TablePreview> {
const res = await fetch(`/api/admin/tables/${tableName}`, { headers: auth.authHeaders() })
return res.json()
}
async function submitCreate() {
const res = await fetch('/api/admin/tables', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(createForm.value),
})
if (res.ok) {
const createdName = createForm.value.name
showCreate.value = false
createForm.value = { name: '', columns: [] }
await load()
// immediately open inspect so the user can verify the new table
openInspect(createdName)
}
}
async function openInspect(tableName: string) {
showInspect.value = true
inspectLoading.value = true
inspectPreview.value = null
inspectPreview.value = await fetchPreview(tableName)
inspectLoading.value = false
}
async function openDropPreview(tableName: string) {
showDropPreview.value = true
dropPreviewLoading.value = true
dropPreview.value = null
dropConfirmName.value = ''
dropPreview.value = await fetchPreview(tableName)
dropPreviewLoading.value = false
}
async function confirmDrop() {
if (!dropPreview.value) return
await fetch(`/api/admin/tables/${dropPreview.value.table_name}`, {
method: 'DELETE',
headers: auth.authHeaders(),
})
showDropPreview.value = false
dropPreview.value = null
load()
}
onMounted(load)
</script>
<style scoped>
.name-cell { font-family: var(--font-mono); }
.count-cell { color: var(--text-muted); font-family: var(--font-mono); font-size: 0.82rem; }
.actions-cell { display: flex; gap: 0.4rem; }
/* ── Create form ────────────────────────────────── */
.columns-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.columns-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.fixed-col-hint {
margin-top: -0.25rem;
}
.column-row {
display: grid;
grid-template-columns: 1fr 1fr 5rem 2rem;
gap: 0.5rem;
align-items: center;
}
.header-row {
font-size: 0.75rem;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0 0.25rem;
}
.nullable-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--text-muted);
padding-left: 0.25rem;
}
.remove-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 0.8rem;
padding: 0.25rem;
border-radius: 4px;
line-height: 1;
transition: color 0.1s, background 0.1s;
}
.remove-btn:hover {
color: var(--danger);
background: var(--danger-subtle);
}
.empty-columns {
font-size: 0.82rem;
color: var(--text-dim);
padding: 0.5rem 0.25rem;
}
/* ── Shared preview ─────────────────────────────── */
.preview-loading {
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem 0;
text-align: center;
}
.preview-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.preview-meta {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.meta-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.meta-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
font-weight: 600;
}
.meta-value {
font-size: 1rem;
color: var(--text-high);
}
.endpoint {
font-size: 0.85rem;
color: var(--primary);
}
.row-count.has-data { color: var(--danger); font-weight: 700; }
.row-count.no-data { color: var(--success); }
.warning-banner {
background: var(--danger-subtle);
border: 1px solid var(--danger-border);
color: var(--danger);
border-radius: 6px;
padding: 0.65rem 0.9rem;
font-size: 0.85rem;
font-weight: 500;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin-bottom: 0.4rem;
}
.sample-count {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--text-muted);
}
.schema-table {
width: 100%;
border-collapse: collapse;
font-size: 0.83rem;
}
.schema-table th,
.schema-table td {
padding: 0.35rem 0.6rem;
border-bottom: 1px solid var(--border-lo);
text-align: left;
}
.schema-table th {
color: var(--text-dim);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.type-badge {
font-family: var(--font-mono);
font-size: 0.75rem;
background: var(--surface-2);
border: 1px solid var(--border-lo);
border-radius: 4px;
padding: 0.1rem 0.4rem;
color: var(--text-label);
}
.sample-scroll {
overflow-x: auto;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-lo);
border-radius: 6px;
}
.sample-table {
min-width: 100%;
}
.sample-empty {
padding: 1.5rem;
border: 1px dashed var(--border-lo);
border-radius: 8px;
}
/* ── Drop confirm ───────────────────────────────── */
.confirm-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.confirm-label {
font-size: 0.85rem;
color: var(--text-label);
}
.drop-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.25rem;
}
</style>

View file

@ -0,0 +1,198 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>Users</h2>
<span class="subtitle">Manage API access accounts and their permission roles</span>
</div>
<NychButton @click="showCreate = true">+ New User</NychButton>
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ users.length }} {{ users.length === 1 ? 'user' : 'users' }}</span>
</div>
<template v-if="users.length"><div class="table-scroll"><table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Mask</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td class="id-cell">{{ u.id }}</td>
<td class="username-cell">{{ u.username }}</td>
<td><span class="role-badge" :class="roleBadgeClass(u.permissions_mask)">{{ roleName(u.permissions_mask) }}</span></td>
<td><code>{{ u.permissions_mask }}</code></td>
<td class="date-cell">{{ new Date(u.created_at).toLocaleDateString() }}</td>
<td class="actions-cell">
<NychButton size="small" @click="openEdit(u)">Edit</NychButton>
<NychButton size="small" severity="danger" @click="deleteUser(u.id)">Delete</NychButton>
</td>
</tr>
</tbody>
</table></div></template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">No users yet</span>
<span class="empty-hint">Create the first user to grant API access.</span>
</div>
</div>
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.username}`" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Username</label>
<NychInputText v-model="editForm.username" fluid autocomplete="off" />
</div>
<div class="field">
<label>New password <span class="optional">(leave blank to keep current)</span></label>
<NychInputText v-model="editForm.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
</div>
<div class="field">
<label>Role</label>
<NychSelect v-model="editForm.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" fluid />
<p class="hint">{{ ROLES.find(r => r.value === editForm.permissions_mask)?.description ?? '' }}</p>
</div>
<NychButton type="submit" fluid>Save Changes</NychButton>
</form>
</NychDialog>
<NychDialog v-model:visible="showCreate" header="New User" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Username</label>
<NychInputText v-model="form.username" placeholder="username" fluid autocomplete="off" />
</div>
<div class="field">
<label>Password</label>
<NychInputText v-model="form.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
</div>
<div class="field">
<label>Role</label>
<NychSelect
v-model="selectedRole"
:options="ROLES"
optionLabel="label"
optionValue="value"
placeholder="Select a role"
fluid
/>
<p class="hint">{{ roleDescription }}</p>
</div>
<NychButton type="submit" fluid>Create User</NychButton>
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const users = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editId = ref<number | null>(null)
const editForm = ref({ username: '', password: '', permissions_mask: '1' })
const form = ref({ username: '', password: '' })
const selectedRole = ref('1')
const ROLES = [
{ label: 'Viewer', value: '1', description: 'Read-only access to API data' },
{ label: 'Editor', value: '7', description: 'Read, write, and delete via API' },
{ label: 'Query Admin', value: '15', description: 'Editor + manage SQL query registry' },
{ label: 'Ops Admin', value: '23', description: 'Editor + manage query cache' },
{ label: 'Admin', value: '31', description: 'All above combined' },
{ label: 'Super Admin', value: '63', description: 'Full access including users, permissions, and blacklist' },
]
const roleDescription = computed(() =>
ROLES.find(r => r.value === selectedRole.value)?.description ?? ''
)
function roleName(mask: string) {
return ROLES.find(r => r.value === mask)?.label ?? 'Custom'
}
function roleBadgeClass(mask: string) {
const map: Record<string, string> = {
'1': 'role-viewer', '7': 'role-editor',
'15': 'role-query', '23': 'role-ops',
'31': 'role-admin', '63': 'role-super',
}
return map[mask] ?? 'role-custom'
}
async function load() {
const res = await fetch('/api/users', { headers: auth.authHeaders() })
users.value = await res.json()
}
async function submitCreate() {
await fetch('/api/users', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form.value, permissions_mask: selectedRole.value }),
})
showCreate.value = false
form.value = { username: '', password: '' }
selectedRole.value = '1'
load()
}
function openEdit(u: any) {
editId.value = u.id
editForm.value = { username: u.username, password: '', permissions_mask: u.permissions_mask }
showEdit.value = true
}
async function submitEdit() {
const body: any = { username: editForm.value.username, permissions_mask: editForm.value.permissions_mask }
if (editForm.value.password) body.password = editForm.value.password
await fetch(`/api/users/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
showEdit.value = false
load()
}
async function deleteUser(id: number) {
if (!confirm('Delete this user?')) return
await fetch(`/api/users/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
}
onMounted(load)
</script>
<style scoped>
.id-cell { color: var(--text-dim); font-family: var(--font-mono); font-size: 0.8rem; }
.username-cell { font-weight: 600; color: var(--text-high); }
.date-cell { color: var(--text-muted); font-size: 0.82rem; font-family: var(--font-mono); }
.role-badge {
display: inline-block;
padding: 0.2rem 0.65rem;
border-radius: 20px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.role-viewer { background: var(--info-subtle); color: var(--info); border: 1px solid var(--info-border); }
.role-editor { background: var(--success-subtle); color: var(--success); border: 1px solid var(--success-border); }
.role-query { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
.role-ops { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
.role-admin { background: color-mix(in srgb, var(--primary) 12%, var(--surface-1)); color: var(--primary); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); }
.role-super { background: var(--primary); color: var(--primary-fg); border: 1px solid var(--primary); }
.role-custom { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
</style>

16
ui/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"useDefineForClassFields": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}

3
ui/vite.config.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare const _default: any;
export default _default;
//# sourceMappingURL=vite.config.d.ts.map

1
ui/vite.config.d.ts.map Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"vite.config.d.ts","sourceRoot":"","sources":["vite.config.ts"],"names":[],"mappings":";AAGA,wBAYE"}

16
ui/vite.config.js Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:3000',
'/auth': 'http://localhost:3000',
'/admin': 'http://localhost:3000',
},
},
build: {
outDir: 'dist',
},
});
//# sourceMappingURL=vite.config.js.map

1
ui/vite.config.js.map Normal file
View file

@ -0,0 +1 @@
{"version":3,"file":"vite.config.js","sourceRoot":"","sources":["vite.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,GAAG,MAAM,oBAAoB,CAAA;AAEpC,eAAe,YAAY,CAAC;IAC1B,OAAO,EAAE,CAAC,GAAG,EAAE,CAAC;IAChB,MAAM,EAAE;QACN,KAAK,EAAE;YACL,MAAM,EAAE,uBAAuB;YAC/B,OAAO,EAAE,uBAAuB;YAChC,QAAQ,EAAE,uBAAuB;SAClC;KACF;IACD,KAAK,EAAE;QACL,MAAM,EAAE,MAAM;KACf;CACF,CAAC,CAAA"}

29
ui/vite.config.ts Normal file
View file

@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:3000',
'/auth': 'http://localhost:3000',
'/admin': 'http://localhost:3000',
},
},
build: {
outDir: 'dist',
cleanOutDir: true,
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
primevue: ['@primevue/core'],
nychthemeron: ['@nychthemeron/library'],
},
entryFileNames: 'js/[name]-[hash].js',
chunkFileNames: 'js/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
},
})