feat: standalone RecordGod web UI — landing, control-room dash, encrypted secrets vault

- app/vault.py: Fernet-encrypted credential store (app_secret table); key lives only in
  RECORDGOD_SECRET_KEY (.env, gitignored) — refuses to store anything if the key is unset.
- app/settings_routes.py: admin-gated GET/POST /settings/secrets; values encrypted at rest,
  NEVER returned. Known fields: Woo / Discogs / Cloudflare / DealGod credentials.
- site/index.html: recordgod.com landing. site/dash.html: control room (token gate +
  paste-your-credentials form). main.py loads .env, mounts site at /, store at /store.
- Proven: 401 on bad token; DB holds ciphertext (gAAAA…), not plaintext.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-21 12:40:07 +10:00
parent 3e3b08dc90
commit e1fcf94f93
7 changed files with 287 additions and 10 deletions

View File

@ -1,22 +1,37 @@
import os
import pathlib import pathlib
from fastapi import FastAPI # Load .env FIRST so auth/db/vault see their keys at import time.
from fastapi.staticfiles import StaticFiles _ENV = pathlib.Path(__file__).resolve().parent.parent / ".env"
if _ENV.exists():
for _ln in _ENV.read_text().splitlines():
_ln = _ln.strip()
if _ln and not _ln.startswith("#") and "=" in _ln:
_k, _v = _ln.split("=", 1)
os.environ.setdefault(_k.strip(), _v.strip())
from . import __version__ from fastapi import FastAPI # noqa: E402
from .wowplatter_v1 import router as wowplatter_v1_router from fastapi.staticfiles import StaticFiles # noqa: E402
from .virtual import router as virtual_router
from . import __version__ # noqa: E402
from .wowplatter_v1 import router as wowplatter_v1_router # noqa: E402
from .virtual import router as virtual_router # noqa: E402
from .settings_routes import router as settings_router # noqa: E402
app = FastAPI(title="RECORDGOD", version=__version__) app = FastAPI(title="RECORDGOD", version=__version__)
app.include_router(wowplatter_v1_router) app.include_router(wowplatter_v1_router)
app.include_router(virtual_router) app.include_router(virtual_router)
app.include_router(settings_router)
# Serve the raw Three.js storefront same-origin (so /store can fetch /virtual/scene, no CORS).
WEBSTORE = pathlib.Path(__file__).resolve().parent.parent / "webstore"
if WEBSTORE.is_dir():
app.mount("/store", StaticFiles(directory=str(WEBSTORE), html=True), name="store")
@app.get("/healthz") @app.get("/healthz")
async def healthz(): async def healthz():
return {"ok": True, "service": "recordgod", "version": __version__} return {"ok": True, "service": "recordgod", "version": __version__}
_ROOT = pathlib.Path(__file__).resolve().parent.parent
# Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site.
if (_ROOT / "webstore").is_dir():
app.mount("/store", StaticFiles(directory=str(_ROOT / "webstore"), html=True), name="store")
if (_ROOT / "site").is_dir():
app.mount("/", StaticFiles(directory=str(_ROOT / "site"), html=True), name="site")

48
app/settings_routes.py Normal file
View File

@ -0,0 +1,48 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from . import vault
from .auth import require_token
from .db import get_db
# Admin-gated. The dash POSTs credentials in; values are encrypted at rest and NEVER returned.
router = APIRouter(prefix="/settings", tags=["settings"])
# The credentials the dash knows how to collect (name → human label).
KNOWN = {
"woo_base_url": "WooCommerce store URL (https://…)",
"woo_consumer_key": "WooCommerce REST consumer key (ck_…)",
"woo_consumer_secret": "WooCommerce REST consumer secret (cs_…)",
"discogs_consumer_key": "Discogs OAuth consumer key",
"discogs_consumer_secret": "Discogs OAuth consumer secret",
"discogs_token": "Discogs personal access token",
"cloudflare_api_token": "Cloudflare API token",
"dealgod_api_key": "DealGod API key (X-Api-Key)",
}
class SecretIn(BaseModel):
name: str
value: str
@router.get("/secrets")
async def list_secrets(ident=Depends(require_token), db=Depends(get_db)):
have = {s["name"]: s["updated_at"] for s in await vault.list_secret_names(db)}
return {"ok": True, "fields": [
{"name": n, "label": label, "set": n in have, "updated_at": have.get(n)}
for n, label in KNOWN.items()
]}
@router.post("/secrets")
async def save_secret(body: SecretIn, ident=Depends(require_token), db=Depends(get_db)):
if body.name not in KNOWN:
raise HTTPException(400, "unknown secret name")
if not body.value.strip():
raise HTTPException(400, "empty value")
try:
await vault.set_secret(db, body.name, body.value.strip())
except vault.SecretsUnconfigured as e:
raise HTTPException(503, str(e))
return {"ok": True, "name": body.name, "set": True}

48
app/vault.py Normal file
View File

@ -0,0 +1,48 @@
"""Encrypted secrets vault. Credentials are stored as Fernet ciphertext in app_secret;
the key lives only in the environment (RECORDGOD_SECRET_KEY), never in the DB or the repo.
If the key isn't set we REFUSE to store secrets — never silently store them in plaintext."""
import os
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy import text
_key = os.getenv("RECORDGOD_SECRET_KEY")
_fernet = Fernet(_key.encode()) if _key else None
class SecretsUnconfigured(RuntimeError):
pass
def _f():
if _fernet is None:
raise SecretsUnconfigured(
"RECORDGOD_SECRET_KEY not set — refusing to store secrets unencrypted")
return _fernet
async def set_secret(db, name, plaintext):
enc = _f().encrypt(plaintext.encode())
await db.execute(text("""
INSERT INTO app_secret (name, value_enc, updated_at) VALUES (:n, :v, now())
ON CONFLICT (name) DO UPDATE SET value_enc = EXCLUDED.value_enc, updated_at = now()
"""), {"n": name, "v": enc})
await db.commit()
async def get_secret(db, name):
"""Decrypt one secret for server-side use (e.g. the Woo client). Never exposed over HTTP."""
row = (await db.execute(text("SELECT value_enc FROM app_secret WHERE name = :n"),
{"n": name})).first()
if not row:
return None
try:
return _f().decrypt(bytes(row[0])).decode()
except InvalidToken:
return None # key rotated / corrupt — treat as not-set rather than crash
async def list_secret_names(db):
rows = await db.execute(text("SELECT name, updated_at FROM app_secret ORDER BY name"))
return [{"name": r[0], "updated_at": r[1].isoformat() if r[1] else None}
for r in rows]

View File

@ -3,3 +3,4 @@ uvicorn[standard]
sqlalchemy[asyncio] sqlalchemy[asyncio]
asyncpg asyncpg
httpx httpx
cryptography

View File

@ -86,5 +86,13 @@ CREATE TABLE IF NOT EXISTS sale_items (
CREATE INDEX IF NOT EXISTS sale_items_sale_idx ON sale_items (sale_id); CREATE INDEX IF NOT EXISTS sale_items_sale_idx ON sale_items (sale_id);
CREATE INDEX IF NOT EXISTS sale_items_release_idx ON sale_items (release_id); CREATE INDEX IF NOT EXISTS sale_items_release_idx ON sale_items (release_id);
-- Encrypted credential vault. Values are Fernet ciphertext; the key lives in the environment
-- (RECORDGOD_SECRET_KEY), never here. Woo/Discogs/Cloudflare/DealGod tokens land here via /settings.
CREATE TABLE IF NOT EXISTS app_secret (
name text PRIMARY KEY,
value_enc bytea NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- pgvector (semantic search / "records like this" / newsletter copy): add when keyword search -- pgvector (semantic search / "records like this" / newsletter copy): add when keyword search
-- measurably falls short, not before. -- measurably falls short, not before.

107
site/dash.html Normal file
View File

@ -0,0 +1,107 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — control room</title>
<style>
:root{--pink:#ff5db1;--ink:#0c0c0e;--mut:#9a9aa6;--card:#141418;--line:#26262c}
*{box-sizing:border-box}
html,body{margin:0;background:var(--ink);color:#f0f0f2;font:15px/1.55 system-ui,sans-serif}
.wrap{max-width:660px;margin:0 auto;padding:6vh 22px}
h1{font-size:28px;font-weight:600;margin:0}
h1 b{color:var(--pink)}
.sub{color:var(--mut);margin:.2em 0 28px}
.card{border:1px solid var(--line);border-radius:12px;padding:18px;background:var(--card);margin:0 0 18px}
.card h2{font-size:15px;font-weight:500;margin:0 0 12px;color:#fff}
label{display:block;font-size:13px;color:var(--mut);margin:12px 0 4px}
input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:#0e0e11;color:#eee;font:14px system-ui}
.field{display:flex;gap:8px;align-items:flex-end}
.field input{flex:1}
button{padding:10px 14px;border:0;border-radius:8px;background:var(--pink);color:#1a0a14;font:500 14px system-ui;cursor:pointer;white-space:nowrap}
button.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line)}
.ok{color:#46d18a;font-size:12px;margin-left:6px}
.gate{text-align:center;padding:30px 0}
#app{display:none}
.soon{color:#55555f;font-size:13px}
a{color:var(--pink)}
</style>
</head>
<body>
<div class="wrap">
<h1>Record<b>God</b> · control room</h1>
<div class="sub"><a href="/">← home</a> · <a href="/store/">virtual store</a></div>
<div id="gate" class="card gate">
<h2>Sign in</h2>
<label>Admin token</label>
<div class="field">
<input id="tok" type="password" placeholder="RECORDGOD_TOKEN (from your .env)">
<button onclick="signin()">Enter</button>
</div>
<div id="gerr" class="soon" style="margin-top:10px"></div>
</div>
<div id="app">
<div class="card">
<h2>Credentials <span class="soon">— encrypted at rest, never shown back</span></h2>
<div id="secrets"></div>
</div>
<div class="card">
<h2>Orders (WooCommerce)</h2>
<p class="soon">Save your Woo store URL + consumer key/secret above, then live orders land here —
managed without wp-admin. (Coming next.)</p>
</div>
<div class="card">
<h2>Discogs</h2>
<p class="soon">Save your Discogs OAuth/token above to enrich intake from the live API and your wantlist.</p>
</div>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
let TOKEN = localStorage.getItem('rg_token') || '';
function hdr(){ return { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' }; }
async function signin(){
TOKEN = $('#tok').value.trim();
const r = await fetch('/settings/secrets', { headers: hdr() });
if(!r.ok){ $('#gerr').textContent = 'invalid token (' + r.status + ')'; return; }
localStorage.setItem('rg_token', TOKEN);
$('#gate').style.display = 'none'; $('#app').style.display = 'block';
renderSecrets(await r.json());
}
function renderSecrets(data){
const box = $('#secrets'); box.innerHTML = '';
data.fields.forEach(f => {
const wrap = document.createElement('div');
const status = f.set ? `<span class="ok">✓ set ${(f.updated_at||'').slice(0,10)}</span>` : '';
wrap.innerHTML = `<label>${f.label} ${status}</label>
<div class="field">
<input type="password" placeholder="${f.set ? '•••••• (replace to update)' : 'paste value'}" data-name="${f.name}">
<button class="ghost" data-save="${f.name}">Save</button>
</div>`;
box.appendChild(wrap);
});
box.querySelectorAll('[data-save]').forEach(btn => btn.onclick = () => save(btn.dataset.save, btn));
}
async function save(name, btn){
const input = document.querySelector(`input[data-name="${name}"]`);
const value = input.value.trim();
if(!value) return;
btn.textContent = '…';
const r = await fetch('/settings/secrets', { method:'POST', headers: hdr(), body: JSON.stringify({ name, value }) });
btn.textContent = r.ok ? 'Saved' : 'Error';
if(r.ok){ input.value=''; input.placeholder='•••••• (replace to update)';
btn.previousElementSibling; setTimeout(()=>btn.textContent='Save', 1500); }
}
// auto-resume if a token is already stored
if(TOKEN){ $('#tok').value = TOKEN; signin(); }
</script>
</body>
</html>

50
site/index.html Normal file
View File

@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — the records engine</title>
<style>
:root{--pink:#ff5db1;--ink:#0c0c0e;--mut:#9a9aa6}
*{box-sizing:border-box}
html,body{margin:0;min-height:100%;background:var(--ink);color:#f0f0f2;font:16px/1.6 system-ui,sans-serif}
.wrap{max-width:860px;margin:0 auto;padding:8vh 24px 6vh}
.tag{letter-spacing:.18em;text-transform:uppercase;color:var(--mut);font-size:13px}
h1{font-size:clamp(40px,9vw,76px);margin:.1em 0 .1em;font-weight:600;line-height:1}
h1 b{color:var(--pink);font-weight:600}
.lede{font-size:20px;color:#d8d8de;max-width:620px}
.row{display:flex;gap:14px;flex-wrap:wrap;margin:34px 0 0}
a.btn{display:inline-flex;align-items:center;gap:8px;padding:13px 22px;border-radius:10px;text-decoration:none;font-weight:500}
a.primary{background:var(--pink);color:#1a0a14}
a.ghost{border:1px solid #333;color:#eee}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:18px;margin:64px 0 0}
.card{border:1px solid #232327;border-radius:12px;padding:18px;background:#141418}
.card h3{margin:.1em 0;color:var(--pink);font-weight:500;font-size:16px}
.card p{color:var(--mut);font-size:14px;margin:.4em 0 0}
.foot{margin-top:64px;color:#55555f;font-size:13px;border-top:1px solid #1c1c20;padding-top:18px}
.foot b{color:var(--mut);font-weight:500}
</style>
</head>
<body>
<div class="wrap">
<div class="tag">House of the Hustle · the crate</div>
<h1>Record<b>God</b></h1>
<p class="lede">The records back-office engine. Inventory, pricing, the 3D store, and order management —
one custom stack, no WordPress paper box. Powered by Postgres, fed by DealGod.</p>
<div class="row">
<a class="btn primary" href="/store/">Enter the virtual store →</a>
<a class="btn ghost" href="/dash.html">Control room</a>
</div>
<div class="grid">
<div class="card"><h3>The crate</h3><p>Walk the shop in 3D, dig through bins, pull a sleeve to inspect it.</p></div>
<div class="card"><h3>Locate stock</h3><p>Any release → the exact crate and slot it lives in.</p></div>
<div class="card"><h3>Live pricing</h3><p>DealGod's cross-store market value on every shelf.</p></div>
<div class="card"><h3>Orders, your way</h3><p>WooCommerce orders managed here — no wp-admin.</p></div>
</div>
<div class="foot">Run from the VPS, not inside WordPress · talks to <b>DealGod</b> · honours <b>WowPlatter</b>, our founder.</div>
</div>
</body>
</html>