Inital Commit
Some checks failed
ci / test (push) Failing after 8s
ci / build-ui (push) Successful in 12s
ci / publish (push) Has been skipped

This commit is contained in:
Matthew McPeak 2026-06-18 19:19:59 -04:00
commit 53f4ca5b26
79 changed files with 16367 additions and 0 deletions

13
.env.example Normal file
View file

@ -0,0 +1,13 @@
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=
# CDN (MinIO object storage) — credentials and bucket are pre-configured internally.
# When registering CDN objects in the admin UI, use this URL prefix:
# docker-compose: http://cdn:9000/mercury/<filename>
# standalone: http://localhost:9000/mercury/<filename>

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 }}

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# ---> 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/
# Git worktrees
.worktrees/

3306
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

33
Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "mercury"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "mercury"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["macros", "multipart"] }
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"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
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"

48
Dockerfile Normal file
View file

@ -0,0 +1,48 @@
# 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: Download MinIO and mc binaries
FROM alpine:3 AS minio-download
ARG TARGETARCH=amd64
RUN wget -q "https://dl.min.io/server/minio/release/linux-${TARGETARCH}/minio" -O /minio && \
chmod +x /minio && \
wget -q "https://dl.min.io/client/mc/release/linux-${TARGETARCH}/mc" -O /mc && \
chmod +x /mc
# Stage 4: Final image
FROM postgres:16
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
ENV PGDATA=/var/lib/postgresql/data
ENV DATABASE_URL=postgres://mercury:mercury@127.0.0.1/mercury
ENV MINIO_ROOT_USER=mercury
ENV MINIO_ROOT_PASSWORD=mercurycdn
ENV MINIO_VOLUMES=/var/lib/minio/data
ENV CDN_ENDPOINT=http://localhost:9000
ENV CDN_BUCKET=mercury
WORKDIR /app
COPY --from=api-builder /app/target/release/mercury .
COPY --from=ui-builder /ui/dist ./ui/dist
COPY --from=minio-download /minio /usr/local/bin/minio
COPY --from=minio-download /mc /usr/local/bin/mc
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]

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: {}

69
docker-compose.yml Normal file
View file

@ -0,0 +1,69 @@
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
cdn:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: mercury
MINIO_ROOT_PASSWORD: mercurycdn
volumes:
- cdn_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 10
cdn-init:
image: minio/mc
depends_on:
cdn:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://cdn:9000 mercury mercurycdn &&
mc mb --ignore-existing local/mercury &&
mc anonymous set public local/mercury &&
echo 'CDN bucket ready'
"
restart: on-failure
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}
CDN_ENDPOINT: http://cdn:9000
CDN_BUCKET: mercury
MERCURY_ADMIN_USER: test
MERCURY_ADMIN_PASSWORD: test
depends_on:
db:
condition: service_healthy
cdn:
condition: service_healthy
mem_limit: 512m
mem_reservation: 256m
volumes:
postgres_data:
cdn_data:

File diff suppressed because it is too large Load diff

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

View file

@ -0,0 +1,199 @@
# Mercury — Dynamic CORS Design Spec
**Date:** 2026-06-18
## Overview
Replace the static startup-time `CorsLayer` with a DB-backed, in-memory CORS cache that can be edited at runtime through the standard CRUD API, mirroring the existing route blacklist pattern. Add a Vue admin view for managing origins and add loading state indicators across all admin views.
---
## Motivation
CORS origins are currently loaded from the `CORS_ORIGINS` environment variable at server startup and baked into a static `tower_http::CorsLayer`. Changing origins requires a server restart. This feature makes CORS origins a first-class runtime configuration: stored in PostgreSQL, held in an in-memory cache, and editable by super-admins through the standard CRUD API without downtime.
---
## Data Layer
### Migration `007_cors_origins.sql`
Create a `cors_origins` table:
```sql
CREATE TABLE cors_origins (
id SERIAL PRIMARY KEY,
origin TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
Seed blacklist entries to restrict CRUD access to super-admins only (SUPER_ADMIN bit = 32), matching the existing pattern for `blacklist`, `queries`, `users`, and `permissions`:
```sql
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
('/api/cors_origins', NULL, 'admin-only table', true, '32'),
('/api/cors_origins/**', NULL, 'admin-only table', true, '32');
```
### Startup Seeding
On server startup, if the `cors_origins` table is empty and the `CORS_ORIGINS` environment variable is set, seed the table from the env var (splitting on `,`, trimming whitespace). This provides a smooth migration path from the env-var approach. Once the DB has entries, `CORS_ORIGINS` is ignored.
---
## In-Memory Cache
### `CorsCache` in `state.rs`
```rust
pub struct CorsCache {
pub inner: Arc<RwLock<CorsState>>,
}
pub struct CorsState {
pub wildcard: bool,
pub origins: Vec<HeaderValue>,
}
```
`CorsCache::load(origins: Vec<String>)`:
- Sets `wildcard = true` if any origin is `"*"`
- Parses each string into a `HeaderValue`, skipping malformed entries
- Acquires a write lock and replaces the inner state
`AppState` gains a `cors_cache: CorsCache` field alongside `blacklist_cache`.
---
## Middleware
### `cors_layer` in `auth/middleware.rs`
An async Axum middleware (`from_fn_with_state`) that runs on every request. Behavior:
| Cache state | Request type | Action |
|---|---|---|
| Empty (no origins, no wildcard) | Any | Pass through — no CORS headers added |
| Wildcard (`*`) | Non-OPTIONS | Add `Access-Control-Allow-Origin: *` to response |
| Wildcard (`*`) | OPTIONS preflight | Return `204` with `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers` |
| Specific origins | Non-OPTIONS, Origin matches | Echo origin back as `Access-Control-Allow-Origin`, add `Vary: Origin` |
| Specific origins | Non-OPTIONS, Origin no match | Pass through — no CORS headers |
| Specific origins | OPTIONS preflight, Origin matches | Return `204` with echoed origin, `Vary: Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers` |
| Specific origins | OPTIONS preflight, Origin no match | Return `204` with no CORS headers |
Allowed methods: `GET, POST, PUT, DELETE, OPTIONS`.
Allowed headers: `Content-Type, Authorization`.
The middleware is applied globally via `.layer(middleware::from_fn_with_state(state.clone(), cors_layer))` in `main.rs`, replacing the removed `build_cors()` call and static `CorsLayer`.
### `reload_cors` in `routes/crud.rs`
Called after any non-GET mutation to the `cors_origins` table, exactly mirroring `reload_blacklist`:
```rust
async fn reload_cors(state: &AppState) -> Result<(), StatusCode> {
// SELECT origin FROM cors_origins ORDER BY id
// state.cors_cache.load(origins).await
}
```
The existing `handle_crud` hook block gains a `cors_origins` branch alongside the `blacklist` branch.
---
## Admin Interface
### `Cors.vue`
New Vue view at `ui/src/views/admin/Cors.vue`:
- **Header:** "CORS Origins" with subtitle "Allowed cross-origin request sources — matched against the request Origin header"
- **Table card:** lists all origins with `id`, `origin` (monospace), `created_at`, and a `Delete` button per row
- **Create dialog:** single `Origin` text input with placeholder `https://app.example.com` or `*`, and an `Add Origin` submit button
- **Empty state:** icon + "No origins configured — cross-origin requests will be rejected" label + hint
- **API calls:** `GET /api/cors_origins` (list), `POST /api/cors_origins` (create), `DELETE /api/cors_origins/:id` (remove)
### Router & Navigation
- Route added to `ui/src/router/index.ts`: `{ path: 'cors', component: () => import('../views/admin/Cors.vue') }`
- Nav item added to `Layout.vue` `navItems`: `{ to: '/admin/cors', label: 'CORS', icon: '✦' }`
---
## Loading State (All Admin Views)
A consistent loading indicator is added to every admin view (`Queries.vue`, `Tables.vue`, `Users.vue`, `Permissions.vue`, `Blacklist.vue`, `ApiKeys.vue`, `Cache.vue`, and the new `Cors.vue`).
### Pattern
Each view gains a `loading` ref:
```ts
const loading = ref(false)
async function load() {
loading.value = true
try {
// existing fetch logic
} finally {
loading.value = false
}
}
```
Mutating operations (`submitCreate`, `submitEdit`, `deleteEntry`, etc.) also set `loading = true` for their duration.
### Shared CSS Spinner
A `.spinner` keyframe animation added once to `main.css`:
```css
@keyframes spin { to { transform: rotate(360deg); } }
.loading-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.loading-overlay {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 3rem 2rem;
color: var(--text-muted);
font-size: 0.85rem;
font-family: var(--font-sans);
}
```
Each view shows a `<div class="loading-overlay"><div class="loading-spinner"></div><span>Loading…</span></div>` in place of the table/content when `loading` is true.
---
## Files Changed
| File | Change |
|---|---|
| `src/db/migrations/007_cors_origins.sql` | New migration |
| `src/state.rs` | Add `CorsCache`, `CorsState`; add `cors_cache` to `AppState` |
| `src/auth/middleware.rs` | Add `cors_layer` middleware |
| `src/routes/crud.rs` | Add `reload_cors`, hook into `handle_crud` |
| `src/main.rs` | Remove `build_cors`, remove static `CorsLayer`, seed `cors_origins` from env on startup, apply `cors_layer` |
| `ui/src/views/admin/Cors.vue` | New view |
| `ui/src/router/index.ts` | Add `/admin/cors` route |
| `ui/src/views/admin/Layout.vue` | Add CORS nav item |
| `ui/src/assets/main.css` | Add spinner CSS |
| `ui/src/views/admin/*.vue` (all 7 existing) | Add `loading` ref and spinner display |
---
## Out of Scope
- Credentials support (`Access-Control-Allow-Credentials`) — not currently used
- Per-origin method/header overrides — uniform allow-list for all origins
- Max-age preflight caching header — can be added later

37
entrypoint.sh Normal file
View file

@ -0,0 +1,37 @@
#!/bin/sh
set -e
PGDATA="${PGDATA:-/var/lib/postgresql/data}"
PGLOG="/var/log/postgresql/postgresql.log"
MINIO_DATA="${MINIO_VOLUMES:-/var/lib/minio/data}"
CDN_BUCKET="${CDN_BUCKET:-mercury}"
mkdir -p "$PGDATA" /var/log/postgresql "$MINIO_DATA"
chown postgres:postgres "$PGDATA" /var/log/postgresql
# Start MinIO in background
minio server "$MINIO_DATA" --console-address ":9001" > /var/log/minio.log 2>&1 &
# Wait for MinIO to accept connections
echo "Waiting for MinIO..."
until mc alias set local http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" > /dev/null 2>&1; do
sleep 1
done
# Create default bucket with anonymous read+write (MinIO is internal; no ports exposed)
mc mb --ignore-existing "local/${CDN_BUCKET}" > /dev/null 2>&1
mc anonymous set public "local/${CDN_BUCKET}" > /dev/null 2>&1
echo "MinIO ready — bucket: ${CDN_BUCKET} (http://localhost:9000/${CDN_BUCKET})"
# Initialise PostgreSQL on first start
if [ ! -f "$PGDATA/PG_VERSION" ]; then
su -s /bin/sh postgres -c "initdb -D $PGDATA"
su -s /bin/sh postgres -c "pg_ctl start -D $PGDATA -w -l $PGLOG"
su -s /bin/sh postgres -c "psql postgres -c \"CREATE USER mercury WITH PASSWORD 'mercury';\""
su -s /bin/sh postgres -c "psql postgres -c \"CREATE DATABASE mercury OWNER mercury;\""
su -s /bin/sh postgres -c "pg_ctl stop -D $PGDATA -w"
fi
su -s /bin/sh postgres -c "pg_ctl start -D $PGDATA -w -l $PGLOG"
exec /app/mercury

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

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

@ -0,0 +1,185 @@
use axum::{
extract::{Request, State},
http::{header, HeaderMap, HeaderValue, Method, StatusCode},
middleware::Next,
response::{IntoResponse, 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)
}
pub async fn cors_layer(
State(state): State<AppState>,
req: Request,
next: Next,
) -> Response {
let is_preflight = req.method() == Method::OPTIONS;
let origin_header = req.headers().get(header::ORIGIN).cloned();
enum CorsDecision {
None,
Wildcard,
Specific(HeaderValue),
}
let decision = {
let guard = state.cors_cache.inner.read().await;
if guard.wildcard {
CorsDecision::Wildcard
} else if let Some(origin) = origin_header.as_ref() {
if guard.origins.contains(origin) {
CorsDecision::Specific(origin.clone())
} else {
CorsDecision::None
}
} else {
CorsDecision::None
}
};
let (cors_origin, vary) = match &decision {
CorsDecision::None => (None, false),
CorsDecision::Wildcard => (Some(HeaderValue::from_static("*")), false),
CorsDecision::Specific(v) => (Some(v.clone()), true),
};
if is_preflight {
let mut headers = HeaderMap::new();
if let Some(origin) = cors_origin {
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
headers.insert(
header::ACCESS_CONTROL_ALLOW_METHODS,
HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS"),
);
headers.insert(
header::ACCESS_CONTROL_ALLOW_HEADERS,
HeaderValue::from_static("content-type, authorization"),
);
if vary {
headers.insert(header::VARY, HeaderValue::from_static("Origin"));
}
}
return (StatusCode::NO_CONTENT, headers).into_response();
}
let mut response = next.run(req).await;
if let Some(origin) = cors_origin {
response
.headers_mut()
.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
if vary {
response
.headers_mut()
.append(header::VARY, HeaderValue::from_static("Origin"));
}
}
response
}

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

@ -0,0 +1,124 @@
pub mod middleware;
pub use middleware::cors_layer;
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);
}
});
}

65
src/config.rs Normal file
View file

@ -0,0 +1,65 @@
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>,
/// Base URL of the MinIO/S3 endpoint (no trailing slash).
pub cdn_endpoint: String,
/// Bucket name used for CDN object storage.
pub cdn_bucket: 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(),
cdn_endpoint: std::env::var("CDN_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:9000".into()),
cdn_bucket: std::env::var("CDN_BUCKET")
.unwrap_or_else(|_| "mercury".into()),
})
}
}
#[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);

View file

@ -0,0 +1,10 @@
CREATE TABLE cors_origins (
id SERIAL PRIMARY KEY,
origin TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Restrict mutations to super-admins (bit 32); GET remains open for all admins.
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
('/api/cors_origins', 'POST,PUT,PATCH,DELETE', 'admin-only table', true, '32'),
('/api/cors_origins/**', 'POST,PUT,PATCH,DELETE', 'admin-only table', true, '32');

View file

@ -0,0 +1,13 @@
CREATE TABLE cdn_objects (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
content_type TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Block mutations to /api/cdn for non-super-admins. GET is unblocked (no entry).
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
('/api/cdn', 'POST,PUT,PATCH,DELETE', 'CDN write operations are super-admin only', true, '32'),
('/api/cdn/**', 'POST,PUT,PATCH,DELETE', 'CDN write operations are super-admin only', true, '32');

View file

@ -0,0 +1,2 @@
-- Remove url column: the proxy constructs the URL from cdn_endpoint + cdn_bucket + key.
ALTER TABLE cdn_objects DROP COLUMN IF EXISTS url;

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)
}

207
src/main.rs Normal file
View file

@ -0,0 +1,207 @@
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,
middleware,
routing::{get, post},
Router,
};
use tower_http::services::{ServeDir, ServeFile};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
auth::middleware::{blacklist_layer, cors_layer, require_auth},
cache::spawn_sweep_task,
config::Config,
db::create_pool,
models::blacklist::BlacklistEntry,
routes::{
admin::admin_router,
auth::login,
cdn::{cdn_create, cdn_delete, cdn_list, cdn_proxy, cdn_update, cdn_upload},
crud::handle_crud,
},
state::{AppState, BlacklistCache, CorsCache, QueryCache},
};
#[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 {
let (username, password) = match (
std::env::var("MERCURY_ADMIN_USER")
.ok()
.filter(|s| !s.is_empty()),
std::env::var("MERCURY_ADMIN_PASSWORD")
.ok()
.filter(|s| !s.is_empty()),
) {
(Some(u), Some(p)) => {
tracing::info!("creating first admin user from environment variables");
(u, p)
}
_ => {
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");
}
(username, password)
}
};
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;
// Load CORS origins; seed from CORS_ORIGINS env var if table is empty.
let cors_count: i64 = sqlx::query_scalar::<_, Option<i64>>("SELECT COUNT(*) FROM cors_origins")
.fetch_one(&pool)
.await?
.unwrap_or(0);
if cors_count == 0 && !config.cors_origins.is_empty() {
for origin in &config.cors_origins {
sqlx::query("INSERT INTO cors_origins (origin) VALUES ($1) ON CONFLICT DO NOTHING")
.bind(origin)
.execute(&pool)
.await?;
}
tracing::info!(
"seeded {} CORS origin(s) from CORS_ORIGINS env var",
config.cors_origins.len()
);
}
let cors_origins: Vec<String> =
sqlx::query_scalar::<_, String>("SELECT origin FROM cors_origins ORDER BY id")
.fetch_all(&pool)
.await?;
let cors_cache = CorsCache::new();
cors_cache.load(cors_origins).await;
let http_client = reqwest::Client::new();
let cdn_base_url = format!("{}/{}", config.cdn_endpoint, config.cdn_bucket);
// 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,
cors_cache,
http_client,
cdn_base_url,
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))
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
.route_layer(middleware::from_fn_with_state(
state.clone(),
blacklist_layer,
));
// CDN routes: GET is public; POST/PUT/DELETE are blacklisted for non-super-admins.
let cdn_routes = Router::new()
.route("/api/cdn", get(cdn_list).post(cdn_create))
.route("/api/cdn/upload", post(cdn_upload))
.route(
"/api/cdn/:key",
get(cdn_proxy).put(cdn_update).delete(cdn_delete),
)
.route_layer(middleware::from_fn_with_state(
state.clone(),
blacklist_layer,
));
let app = Router::new()
.route("/auth/login", post(login))
.merge(cdn_routes)
.merge(crud_routes)
.nest("/api/admin", admin_router(state.clone()))
.nest_service(
"/",
ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")),
)
.layer(middleware::from_fn_with_state(state.clone(), 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(())
}

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

@ -0,0 +1,27 @@
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>>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateApiKey {
pub name: Option<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>,
}

25
src/models/cdn.rs Normal file
View file

@ -0,0 +1,25 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct CdnObject {
pub id: i32,
pub key: String,
pub content_type: Option<String>,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCdnObject {
pub key: String,
pub content_type: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateCdnObject {
pub key: Option<String>,
pub content_type: Option<String>,
pub description: Option<String>,
}

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

@ -0,0 +1,6 @@
pub mod api_key;
pub mod blacklist;
pub mod cdn;
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,103 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde_json::{json, Value};
use crate::{
auth::generate_api_key,
models::api_key::{ApiKey, CreateApiKey, UpdateApiKey},
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 update_api_key(
State(state): State<AppState>,
Path(id): Path<i32>,
Json(body): Json<UpdateApiKey>,
) -> Result<Json<ApiKey>, StatusCode> {
if body.name.is_none() && body.permissions_mask.is_none() && body.expires_at.is_none() {
return Err(StatusCode::BAD_REQUEST);
}
let key = sqlx::query_as::<_, ApiKey>(
"UPDATE api_keys
SET name = COALESCE($1, name),
permissions_mask = COALESCE($2, permissions_mask),
expires_at = CASE WHEN $3::boolean THEN $4 ELSE expires_at END
WHERE id = $5
RETURNING id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at",
)
.bind(body.name.as_deref())
.bind(body.permissions_mask.as_deref())
.bind(body.expires_at.is_some())
.bind(body.expires_at)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| {
tracing::error!("update api_key {}: {}", id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(key))
}
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 })))
}

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

@ -0,0 +1,59 @@
pub mod api_keys;
pub mod cache;
pub mod queries;
pub mod tables;
use axum::{
middleware,
routing::{delete, get, put},
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",
put(api_keys::update_api_key).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");
}
}

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

@ -0,0 +1,245 @@
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",
"cors_origins", "cdn_objects",
];
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 })))
}

225
src/routes/cdn.rs Normal file
View file

@ -0,0 +1,225 @@
use axum::{
extract::{Multipart, Path, State},
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use serde_json::Value;
use sqlx::Row;
use crate::{
models::cdn::{CdnObject, CreateCdnObject, UpdateCdnObject},
state::AppState,
};
pub async fn cdn_list(
State(state): State<AppState>,
) -> Result<Json<Vec<CdnObject>>, StatusCode> {
let objects = sqlx::query_as::<_, CdnObject>(
"SELECT id, key, content_type, description, created_at \
FROM cdn_objects ORDER BY id",
)
.fetch_all(&state.pool)
.await
.map_err(|e| {
tracing::error!("cdn_list: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(objects))
}
pub async fn cdn_proxy(
State(state): State<AppState>,
Path(key): Path<String>,
) -> Result<impl IntoResponse, StatusCode> {
let row = sqlx::query(
"SELECT content_type FROM cdn_objects WHERE key = $1",
)
.bind(&key)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let content_type: Option<String> = row.try_get::<Option<String>, _>("content_type").ok().flatten();
let url = format!("{}/{}", state.cdn_base_url, key);
let upstream = state
.http_client
.get(&url)
.send()
.await
.map_err(|e| {
tracing::error!("cdn_proxy upstream error for key={}: {}", key, e);
StatusCode::BAD_GATEWAY
})?;
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let bytes = upstream
.bytes()
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
let ct = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
Ok((status, [(header::CONTENT_TYPE, ct)], bytes))
}
pub async fn cdn_create(
State(state): State<AppState>,
Json(body): Json<CreateCdnObject>,
) -> Result<Json<CdnObject>, StatusCode> {
let obj = sqlx::query_as::<_, CdnObject>(
"INSERT INTO cdn_objects (key, content_type, description) \
VALUES ($1, $2, $3) \
RETURNING id, key, content_type, description, created_at",
)
.bind(&body.key)
.bind(&body.content_type)
.bind(&body.description)
.fetch_one(&state.pool)
.await
.map_err(|e| {
tracing::error!("cdn_create: {}", e);
if let sqlx::Error::Database(db_err) = &e {
if db_err.code().as_deref() == Some("23505") {
return StatusCode::CONFLICT;
}
}
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(obj))
}
pub async fn cdn_update(
State(state): State<AppState>,
Path(key): Path<String>,
Json(body): Json<UpdateCdnObject>,
) -> Result<Json<CdnObject>, StatusCode> {
let obj = sqlx::query_as::<_, CdnObject>(
"UPDATE cdn_objects \
SET key = COALESCE($2, key), \
content_type = COALESCE($3, content_type), \
description = COALESCE($4, description) \
WHERE key = $1 \
RETURNING id, key, content_type, description, created_at",
)
.bind(&key)
.bind(&body.key)
.bind(&body.content_type)
.bind(&body.description)
.fetch_optional(&state.pool)
.await
.map_err(|e| {
tracing::error!("cdn_update: {}", e);
if let sqlx::Error::Database(db_err) = &e {
if db_err.code().as_deref() == Some("23505") {
return StatusCode::CONFLICT;
}
}
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(obj))
}
pub async fn cdn_delete(
State(state): State<AppState>,
Path(key): Path<String>,
) -> Result<Json<Value>, StatusCode> {
let rows = sqlx::query("DELETE FROM cdn_objects WHERE key = $1")
.bind(&key)
.execute(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.rows_affected();
if rows == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(serde_json::json!({ "deleted": true })))
}
/// Receive a multipart upload, PUT the file to MinIO, then register it in cdn_objects.
/// Fields: `file` (required), `key` (optional — defaults to filename), `content_type`
/// (optional — defaults to detected), `description` (optional).
pub async fn cdn_upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Result<Json<CdnObject>, StatusCode> {
let mut file_bytes: Option<axum::body::Bytes> = None;
let mut filename: Option<String> = None;
let mut key_override: Option<String> = None;
let mut content_type_override: Option<String> = None;
let mut description: Option<String> = None;
while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? {
let field_name = field.name().unwrap_or("").to_string();
match field_name.as_str() {
"file" => {
filename = field.file_name().map(|s| s.to_string());
if content_type_override.is_none() {
content_type_override = field.content_type().map(|s| s.to_string());
}
file_bytes = Some(field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?);
}
"key" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
if !v.is_empty() { key_override = Some(v); }
}
"content_type" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
if !v.is_empty() { content_type_override = Some(v); }
}
"description" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
if !v.is_empty() { description = Some(v); }
}
_ => {}
}
}
let bytes = file_bytes.ok_or(StatusCode::BAD_REQUEST)?;
let key = key_override.or(filename).ok_or(StatusCode::BAD_REQUEST)?;
let ct = content_type_override.unwrap_or_else(|| "application/octet-stream".to_string());
// Upload to MinIO (bucket is anonymous-public, so no auth needed)
let upload_url = format!("{}/{}", state.cdn_base_url, key);
let res = state
.http_client
.put(&upload_url)
.header("Content-Type", &ct)
.body(bytes)
.send()
.await
.map_err(|e| {
tracing::error!("cdn_upload: MinIO PUT failed: {}", e);
StatusCode::BAD_GATEWAY
})?;
if !res.status().is_success() {
tracing::error!("cdn_upload: MinIO returned {}", res.status());
return Err(StatusCode::BAD_GATEWAY);
}
let obj = sqlx::query_as::<_, CdnObject>(
"INSERT INTO cdn_objects (key, content_type, description) \
VALUES ($1, $2, $3) \
ON CONFLICT (key) DO UPDATE \
SET content_type = EXCLUDED.content_type, \
description = EXCLUDED.description \
RETURNING id, key, content_type, description, created_at",
)
.bind(&key)
.bind(&ct)
.bind(&description)
.fetch_one(&state.pool)
.await
.map_err(|e| {
tracing::error!("cdn_upload: DB insert failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(obj))
}

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

@ -0,0 +1,450 @@
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.
fn coerce_id(id_val: &str) -> Value {
if let Ok(n) = id_val.parse::<i64>() {
Value::Number(n.into())
} else {
Value::String(id_val.to_string())
}
}
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![coerce_id(id_val)], 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(coerce_id(id_val));
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![coerce_id(id_val)], 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(())
}
async fn reload_cors(state: &AppState) -> Result<(), StatusCode> {
let origins: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT origin FROM cors_origins ORDER BY id",
)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state.cors_cache.load(origins).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(|e| {
tracing::error!("GET {}: {}", table, e);
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 matches!(e, sqlx::Error::RowNotFound) {
StatusCode::NOT_FOUND
} else {
tracing::error!("{} {}: {}", method_str, table, e);
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
let mut v = pg_row_to_json(row);
if table == "users" {
v = strip_password_hash(v);
}
v
}
"DELETE" => {
let rows_affected = q
.execute(&state.pool)
.await
.map_err(|e| {
tracing::error!("DELETE {}: {}", table, e);
StatusCode::INTERNAL_SERVER_ERROR
})?
.rows_affected();
if rows_affected == 0 {
return Err(StatusCode::NOT_FOUND);
}
serde_json::json!({ "deleted": true })
}
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
};
// Reload in-memory caches after mutations to their backing tables.
if table == "blacklist" && method_str != "GET" {
reload_blacklist(&state).await?;
}
if table == "cors_origins" && method_str != "GET" {
reload_cors(&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::Number(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::Number(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::Number(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());
}
}

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

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

327
src/state.rs Normal file
View file

@ -0,0 +1,327 @@
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use std::time::{SystemTime, UNIX_EPOCH};
use axum::http::HeaderValue;
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 cors_cache: CorsCache,
pub http_client: reqwest::Client,
pub cdn_base_url: String,
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)
})
}
}
#[derive(Clone)]
pub struct CorsCache {
pub inner: Arc<RwLock<CorsState>>,
}
#[derive(Clone, Default)]
pub struct CorsState {
pub wildcard: bool,
pub origins: Vec<HeaderValue>,
}
impl CorsCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(CorsState::default())),
}
}
pub async fn load(&self, origins: Vec<String>) {
let wildcard = origins.iter().any(|o| o == "*");
let parsed: Vec<HeaderValue> = origins
.iter()
.filter(|o| *o != "*")
.filter_map(|o| o.parse().ok())
.collect();
let mut guard = self.inner.write().await;
*guard = CorsState { wildcard, origins: parsed };
}
}
#[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);
}
#[tokio::test]
async fn test_cors_cache_wildcard() {
let cache = CorsCache::new();
cache.load(vec!["*".to_string()]).await;
let guard = cache.inner.read().await;
assert!(guard.wildcard);
assert!(guard.origins.is_empty());
}
#[tokio::test]
async fn test_cors_cache_specific_origin() {
let cache = CorsCache::new();
cache.load(vec!["https://example.com".to_string()]).await;
let guard = cache.inner.read().await;
assert!(!guard.wildcard);
assert_eq!(guard.origins.len(), 1);
assert_eq!(guard.origins[0], "https://example.com");
}
#[tokio::test]
async fn test_cors_cache_empty() {
let cache = CorsCache::new();
cache.load(vec![]).await;
let guard = cache.inner.read().await;
assert!(!guard.wildcard);
assert!(guard.origins.is_empty());
}
#[tokio::test]
async fn test_cors_cache_load_replaces() {
let cache = CorsCache::new();
cache.load(vec!["https://a.com".to_string()]).await;
cache.load(vec!["https://b.com".to_string()]).await;
let guard = cache.inner.read().await;
assert_eq!(guard.origins.len(), 1);
assert_eq!(guard.origins[0], "https://b.com");
}
}

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>

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

@ -0,0 +1,278 @@
*,
*::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;
justify-content: center !important;
}
[class^="nych-button"]:hover {
box-shadow: 0 0 5px var(--text-high);
}
.nych-loading-icon .wreath {
transform-box: fill-box;
transform-origin: center;
}
/* ── Loading spinner ─────────────────────────────────────── */
@keyframes mercury-spin {
to { transform: rotate(360deg); }
}
.loading-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: mercury-spin 0.7s linear infinite;
flex-shrink: 0;
}
.loading-overlay {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 3rem 2rem;
color: var(--text-muted);
font-size: 0.85rem;
font-family: var(--font-sans);
}

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");

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

@ -0,0 +1,38 @@
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') },
{ path: 'cors', component: () => import('../views/admin/Cors.vue') },
{ path: 'cdn', component: () => import('../views/admin/Cdn.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')
},
}
}

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

@ -0,0 +1,173 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-brand">
<img src="/favicon.svg" class="brand-ornament" alt="" />
<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" label="Sign In" fluid />
</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;
max-width: 100px;
}
.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,255 @@
<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" label="+ New Key" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ keys.length }} {{ keys.length === 1 ? 'key' : 'keys' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<template v-else-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)" label="Revoke" />
</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 label="Generate Key" />
</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" label="I've saved the key" />
</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 loading = ref(false)
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() {
loading.value = true
try {
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
keys.value = await res.json()
} finally {
loading.value = false
}
}
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,242 @@
<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" label="+ Add Pattern" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<template v-else-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)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteEntry(e.id)" label="Delete" />
</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 label="Save Changes" />
</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 label="Add to Blacklist" />
</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 loading = ref(false)
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
async function load() {
loading.value = true
try {
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
entries.value = await res.json()
} finally {
loading.value = false
}
}
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,160 @@
<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" label="Refresh" />
<NychButton severity="danger" @click="flushCache" label="Flush Cache" />
</div>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<div class="stats-grid" v-else-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 loading = ref(false)
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>

203
ui/src/views/admin/Cdn.vue Normal file
View file

@ -0,0 +1,203 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>CDN Objects</h2>
<span class="subtitle">Object storage registry keys proxied through the API to the local CDN</span>
</div>
<NychButton @click="showCreate = true" label="+ Add Object" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ objects.length }} {{ objects.length === 1 ? 'object' : 'objects' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<template v-else-if="objects.length">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Key</th>
<th>Content Type</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in objects" :key="o.id">
<td><code>{{ o.key }}</code></td>
<td class="ct-cell">{{ o.content_type ?? '—' }}</td>
<td class="desc-cell">{{ o.description ?? '—' }}</td>
<td class="actions-cell">
<NychButton size="small" @click="openEdit(o)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteObject(o.key)" label="Delete" />
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">No CDN objects</span>
<span class="empty-hint">Add objects to expose local CDN assets through the API.</span>
</div>
</div>
<NychDialog v-model:visible="showCreate" header="Upload CDN Object" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>File</label>
<input type="file" ref="fileInput" @change="onFileChange" class="file-input" />
<p class="hint">The filename becomes the key unless overridden below.</p>
</div>
<div class="field">
<label>Key <span class="optional">(optional defaults to filename)</span></label>
<NychInputText v-model="form.key" placeholder="logo.png" fluid />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional auto-detected)</span></label>
<NychInputText v-model="form.content_type" placeholder="image/png" fluid />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="form.description" placeholder="App logo" fluid />
</div>
<NychButton type="submit" fluid label="Upload" :disabled="!selectedFile || loading" />
</form>
</NychDialog>
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editKey}`" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Key</label>
<NychInputText v-model="editForm.key" fluid />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.content_type" fluid />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.description" fluid />
</div>
<NychButton type="submit" fluid label="Save Changes" :disabled="loading" />
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const objects = ref<any[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const editKey = ref('')
const loading = ref(false)
const form = ref({ key: '', content_type: '', description: '' })
const editForm = ref({ key: '', content_type: '', description: '' })
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
function onFileChange() {
selectedFile.value = fileInput.value?.files?.[0] ?? null
}
async function load() {
loading.value = true
try {
const res = await fetch('/api/cdn', { headers: auth.authHeaders() })
objects.value = await res.json()
} finally {
loading.value = false
}
}
async function submitCreate() {
if (!selectedFile.value) return
loading.value = true
try {
const fd = new FormData()
fd.append('file', selectedFile.value)
if (form.value.key) fd.append('key', form.value.key)
if (form.value.content_type) fd.append('content_type', form.value.content_type)
if (form.value.description) fd.append('description', form.value.description)
const res = await fetch('/api/cdn/upload', {
method: 'POST',
headers: auth.authHeaders(),
body: fd,
})
if (!res.ok) return
showCreate.value = false
selectedFile.value = null
form.value = { key: '', content_type: '', description: '' }
await load()
} finally {
loading.value = false
}
}
function openEdit(o: any) {
editKey.value = o.key
editForm.value = {
key: o.key,
content_type: o.content_type ?? '',
description: o.description ?? '',
}
showEdit.value = true
}
async function submitEdit() {
loading.value = true
try {
const res = await fetch(`/api/cdn/${editKey.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
key: editForm.value.key || null,
content_type: editForm.value.content_type || null,
description: editForm.value.description || null,
}),
})
if (!res.ok) return
showEdit.value = false
await load()
} finally {
loading.value = false
}
}
async function deleteObject(key: string) {
if (!confirm(`Delete CDN object "${key}"?`)) return
loading.value = true
try {
const res = await fetch(`/api/cdn/${key}`, { method: 'DELETE', headers: auth.authHeaders() })
if (!res.ok) return
await load()
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.file-input {
width: 100%;
font-size: 0.85rem;
font-family: var(--font-sans);
color: var(--text-label);
cursor: pointer;
}
.ct-cell { color: var(--text-muted); font-size: 0.85rem; font-family: var(--font-mono); }
.desc-cell { color: var(--text-muted); font-size: 0.85rem; }
.hint { font-size: 0.78rem; color: var(--text-dim); margin: 0.25rem 0 0; font-family: var(--font-sans); }
</style>

118
ui/src/views/admin/Cors.vue Normal file
View file

@ -0,0 +1,118 @@
<template>
<div>
<div class="page-header">
<div class="page-title">
<h2>CORS Origins</h2>
<span class="subtitle">Allowed cross-origin request sources matched against the request Origin header</span>
</div>
<NychButton @click="showCreate = true" label="+ Add Origin" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ origins.length }} {{ origins.length === 1 ? 'origin' : 'origins' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<template v-else-if="origins.length">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Origin</th>
<th>Added</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in origins" :key="o.id">
<td><code>{{ o.origin }}</code></td>
<td class="date-cell">{{ fmtDate(o.created_at) }}</td>
<td class="actions-cell">
<NychButton size="small" severity="danger" @click="deleteOrigin(o.id)" label="Delete" />
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div class="empty-state" v-else>
<span class="empty-icon"></span>
<span class="empty-label">No origins configured</span>
<span class="empty-hint">Cross-origin requests will be rejected until an origin is added.</span>
</div>
</div>
<NychDialog v-model:visible="showCreate" header="Add CORS Origin" :modal="true" :draggable="false" style="width: min(480px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Origin</label>
<NychInputText v-model="form.origin" placeholder="https://app.example.com" fluid />
<p class="hint">Use <code>*</code> to allow all origins (permissive mode).</p>
</div>
<NychButton type="submit" fluid label="Add Origin" :disabled="!form.origin || loading" />
</form>
</NychDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth'
const auth = useAuthStore()
const origins = ref<any[]>([])
const showCreate = ref(false)
const loading = ref(false)
const form = ref({ origin: '' })
function fmtDate(s: string) {
return new Date(s).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
async function load() {
loading.value = true
try {
const res = await fetch('/api/cors_origins', { headers: auth.authHeaders() })
origins.value = await res.json()
} finally {
loading.value = false
}
}
async function submitCreate() {
loading.value = true
try {
await fetch('/api/cors_origins', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ origin: form.value.origin }),
})
showCreate.value = false
form.value = { origin: '' }
await load()
} finally {
loading.value = false
}
}
async function deleteOrigin(id: number) {
if (!confirm('Remove this CORS origin?')) return
loading.value = true
try {
await fetch(`/api/cors_origins/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
await load()
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.date-cell { color: var(--text-muted); font-size: 0.85rem; }
.hint { font-size: 0.78rem; color: var(--text-dim); margin: 0.25rem 0 0; font-family: var(--font-sans); }
</style>

View file

@ -0,0 +1,267 @@
<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: "◈" },
{ to: "/admin/cors", label: "CORS", icon: "✦" },
{ to: "/admin/cdn", label: "CDN Objects", 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,157 @@
<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" label="+ New Permission" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ permissions.length }} {{ permissions.length === 1 ? 'permission' : 'permissions' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<template v-else-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)" label="Edit" />
<NychButton size="small" severity="danger" @click="deletePermission(p.id)" label="Delete" />
</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 label="Save Changes" />
</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 label="Create Permission" />
</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: '' })
const loading = ref(false)
async function load() {
loading.value = true
try {
const res = await fetch('/api/permissions', { headers: auth.authHeaders() })
permissions.value = await res.json()
} finally {
loading.value = false
}
}
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,147 @@
<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" label="+ New Query" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ queries.length }} {{ queries.length === 1 ? 'query' : 'queries' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<template v-else-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)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteQuery(q)" label="Delete" />
</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 label="Save Query" />
</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 label="Update Query" />
</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: '' })
const loading = ref(false)
async function load() {
loading.value = true
try {
const res = await fetch('/api/queries', { headers: auth.authHeaders() })
queries.value = await res.json()
} finally {
loading.value = false
}
}
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,575 @@
<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" label="+ New Table" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ tables.length }} {{ tables.length === 1 ? 'table' : 'tables' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<template v-else-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)" label="Inspect" />
<NychButton size="small" severity="danger" @click="openDropPreview(t.table_name)" label="Drop" />
</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" label="+ Add Column" />
</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" label="Create Table" />
</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" label="Cancel" />
<NychButton severity="danger" :disabled="dropConfirmName !== dropPreview.table_name" @click="confirmDrop" label="Drop Table" />
</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[]>([])
const loading = ref(false)
// 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() {
loading.value = true
try {
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
tables.value = await res.json()
} finally {
loading.value = false
}
}
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,208 @@
<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" label="+ New User" />
</div>
<div class="table-card">
<div class="table-card-header">
<span class="count">{{ users.length }} {{ users.length === 1 ? 'user' : 'users' }}</span>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
<span>Loading</span>
</div>
<template v-else-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)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteUser(u.id)" label="Delete" />
</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 label="Save Changes" />
</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 label="Create User" />
</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 loading = ref(false)
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() {
loading.value = true
try {
const res = await fetch('/api/users', { headers: auth.authHeaders() })
users.value = await res.json()
} finally {
loading.value = false
}
}
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

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

@ -0,0 +1,28 @@
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",
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]",
},
},
},
});