From e1fcf94f934f9dbfd39f46bf12562504ed203b70 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 21 Jun 2026 12:40:07 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20standalone=20RecordGod=20web=20UI=20?= =?UTF-8?q?=E2=80=94=20landing,=20control-room=20dash,=20encrypted=20secre?= =?UTF-8?q?ts=20vault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/main.py | 35 ++++++++++---- app/settings_routes.py | 48 ++++++++++++++++++ app/vault.py | 48 ++++++++++++++++++ requirements.txt | 1 + schema.sql | 8 +++ site/dash.html | 107 +++++++++++++++++++++++++++++++++++++++++ site/index.html | 50 +++++++++++++++++++ 7 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 app/settings_routes.py create mode 100644 app/vault.py create mode 100644 site/dash.html create mode 100644 site/index.html diff --git a/app/main.py b/app/main.py index 4f1ce34..16e4f48 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,37 @@ +import os import pathlib -from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles +# Load .env FIRST so auth/db/vault see their keys at import time. +_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 .wowplatter_v1 import router as wowplatter_v1_router -from .virtual import router as virtual_router +from fastapi import FastAPI # noqa: E402 +from fastapi.staticfiles import StaticFiles # noqa: E402 + +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.include_router(wowplatter_v1_router) app.include_router(virtual_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.include_router(settings_router) @app.get("/healthz") async def healthz(): 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") diff --git a/app/settings_routes.py b/app/settings_routes.py new file mode 100644 index 0000000..2c1c98f --- /dev/null +++ b/app/settings_routes.py @@ -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} diff --git a/app/vault.py b/app/vault.py new file mode 100644 index 0000000..4d73e82 --- /dev/null +++ b/app/vault.py @@ -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] diff --git a/requirements.txt b/requirements.txt index 7d2f8a9..20a610f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard] sqlalchemy[asyncio] asyncpg httpx +cryptography diff --git a/schema.sql b/schema.sql index 0181364..7a59b5a 100644 --- a/schema.sql +++ b/schema.sql @@ -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_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 -- measurably falls short, not before. diff --git a/site/dash.html b/site/dash.html new file mode 100644 index 0000000..423f50d --- /dev/null +++ b/site/dash.html @@ -0,0 +1,107 @@ + + + + + +RecordGod — control room + + + +
+

RecordGod · control room

+ + +
+

Sign in

+ +
+ + +
+
+
+ +
+
+

Credentials — encrypted at rest, never shown back

+
+
+
+

Orders (WooCommerce)

+

Save your Woo store URL + consumer key/secret above, then live orders land here — + managed without wp-admin. (Coming next.)

+
+
+

Discogs

+

Save your Discogs OAuth/token above to enrich intake from the live API and your wantlist.

+
+
+
+ + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..26fddd3 --- /dev/null +++ b/site/index.html @@ -0,0 +1,50 @@ + + + + + +RecordGod — the records engine + + + +
+
House of the Hustle · the crate
+

RecordGod

+

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.

+ + + +
+

The crate

Walk the shop in 3D, dig through bins, pull a sleeve to inspect it.

+

Locate stock

Any release → the exact crate and slot it lives in.

+

Live pricing

DealGod's cross-store market value on every shelf.

+

Orders, your way

WooCommerce orders managed here — no wp-admin.

+
+ +
Run from the VPS, not inside WordPress · talks to DealGod · honours WowPlatter, our founder.
+
+ +