feat: Hermes prompt callback endpoint + API key management

- Add /api/prompt/<rid> callback endpoint with dual auth:
  per-request signed URL token + Bearer API key.
- Add HERMES_API_KEY config and runtime key helpers in app.py.
- Update admin request page to copy request details + callback URL.
- Add API key management to admin settings: regenerate, mask, show-once.
- Update .env.example, docker-compose.yml, README, REVIEW docs.
This commit is contained in:
Troll (Hermes Agent) 2026-08-03 17:16:56 +00:00
parent e46893e0b4
commit 6a60aa686c
8 changed files with 242 additions and 35 deletions

View file

@ -17,6 +17,7 @@
# PRICE_PER_VERSION - shown on the receipt page (default 10.00)
# CURRENCY - currency label (default CAD)
# MAX_REVISIONS - default customer revision limit (default 2)
# HERMES_API_KEY - API key Hermes uses to POST prompts back (can be generated from /admin/settings)
# DATABASE - SQLite database path inside the container (default /app/data/booth.db)
# UPLOAD_FOLDER - directory for uploaded MP3s inside the container (default /app/uploads)
# SMTP_HOST - outgoing mail server (default mailroot8.namespro.ca)
@ -39,5 +40,6 @@ HOST_PORT=127.0.0.1:8000
PRICE_PER_VERSION=10.00
CURRENCY=CAD
MAX_REVISIONS=2
HERMES_API_KEY=
DATABASE=/app/data/booth.db
UPLOAD_FOLDER=/app/uploads

View file

@ -21,6 +21,8 @@ A Flask web app for a convention booth where visitors request a custom AI-genera
- **Dashboard queue** (`/admin`) — filter by status (All, Pending, Needs Upload, Awaiting Payment, Delivered) and auto-refresh at a configurable interval.
- **Per-request detail page** (`/admin/request/<id>`):
- Generate and save a Suno prompt from customer info.
- One-click copy of request details + a signed callback URL for Hermes.
- Hermes POSTs back the generated Title/Style/Lyrics; status becomes **Prompt Ready** automatically.
- Edit the customer's email address if they mistyped it.
- Upload Version A and Version B MP3s (with automatic ID3 metadata tagging).
- Send a preview email with a private player link.
@ -32,6 +34,7 @@ A Flask web app for a convention booth where visitors request a custom AI-genera
- Configure dashboard auto-refresh interval.
- Configure SMTP host/port/user/from; store the SMTP password encrypted.
- Configure default MP3 metadata tags (artist, album, year, comment).
- Generate/regenerate the Hermes API key used by the prompt callback endpoint (shown once, otherwise masked).
- Send a test email.
- Download or restore the SQLite database backup.
- Reset the entire system for a new event.
@ -46,7 +49,7 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de
| Status | Meaning |
|---|---|
| `pending` | Customer submitted a request; operator has not saved a prompt yet. |
| `prompt_ready` | Operator saved Title/Style/Lyrics. |
| `prompt_ready` | Hermes POSTed back the generated Suno prompt, or the operator saved it manually. |
| `songs_uploaded` | Both MP3s uploaded; preview link can be sent. Dashboard filter label: **Needs Upload** (shown for this state when filtering). |
| `revisions_requested` | Customer asked for changes; current files were archived. |
| `awaiting_payment` | Customer approved a version; waiting for operator to collect payment and deliver. |
@ -57,13 +60,13 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de
| File | Purpose |
|---|---|
| `app.py` | Flask routes, helpers, email layer, runtime settings, MP3 tagging, rate limiting, and DB maintenance helpers. |
| `config.py` | Environment-variable based configuration; defines defaults for DB, uploads, SMTP, and secrets. |
| `models.py` | SQLite schema and CRUD helpers. |
| `init_db.py` | Standalone script to create the database tables. |
| `templates/closed.html` | Message shown on `/request` when the booth is marked closed. |
| `templates/request.html` | Customer request form. |
| `templates/thanks.html` | Post-submission confirmation. |
| `app.py` | Flask routes, helpers, email layer, runtime settings, MP3 tagging, rate limiting, DB maintenance, and Hermes callback endpoint. |
| `config.py` | Environment-variable based configuration; defines defaults for DB, uploads, SMTP, secrets, and `HERMES_API_KEY`. |
| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. |
| `init_db.py` | Run once after deploy: `python init_db.py`. |
| `templates/admin/request.html` | Biggest template; prompt copy helpers and JS live here. Customer email and operator notes are editable here. |
| `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + topbar Reset System button. |
| `templates/admin/settings.html` | Maintenance, settings, backup/restore, reset, and Hermes API key management. |
| `templates/status.html` | Customer order status lookup. |
| `templates/faq.html` | Customer FAQ page. |
| `templates/player.html` | Customer audio player, approval, and revision form. |
@ -114,10 +117,11 @@ Visit:
| `PUBLIC_BASE_URL` | Yes | Public HTTPS URL, e.g. `https://booth.dionysismedia.ca`. |
| `HOST_PORT` | No | Host-side port mapping, default `127.0.0.1:8000`. |
| `INTERNAL_PORT` | No | Port gunicorn binds inside container, default `8000`. |
| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. |
| `PRICE_PER_VERSION` | No | Shown to the operator/customer, default `10.00`. |
| `CURRENCY` | No | Currency label, default `CAD`. |
| `MAX_REVISIONS` | No | Default customer revision limit if not changed in settings, default `2`. |
|| `BOOTH_NAME` | No | Name used in emails, default `Trollgorithm Theme Songs`. |
|| `PRICE_PER_VERSION` | No | Shown to the operator/customer, default `10.00`. |
|| `CURRENCY` | No | Currency label, default `CAD`. |
|| `MAX_REVISIONS` | No | Default customer revision limit if not changed in settings, default `2`. |
|| `HERMES_API_KEY` | No | API key for the `/api/prompt` callback. If omitted, generate one from `/admin/settings`. |
5. Deploy the stack.
6. Open a console in the `theme-song-booth` container and run once:
@ -127,7 +131,9 @@ python init_db.py
```
7. Point your reverse proxy at the `HOST_PORT` you chose.
8. Print or display a QR code pointing to `https://your-domain/request`.
8. Visit `/admin/settings` and click **Regenerate API Key** to create the Hermes callback key.
9. Update your Hermes skill or config with the new key and the booth public URL.
10. Print or display a QR code pointing to `https://your-domain/request`.
### Updating the deployment
@ -136,13 +142,14 @@ After each push to GitLab, go to Portainer → **Stacks** → `theme-song-booth`
## Important notes
- **No `.env` file in production.** `docker-compose.yml` passes variables directly from Portainer. This avoids Portainer's `env_file not found` error.
- **Runtime settings persist.** SMTP config, revision limit, auto-refresh interval, booth open/closed state, and MP3 metadata defaults are stored in `booth_settings.json` inside the persistent uploads volume. They survive redeploys.
- **Runtime settings persist.** SMTP config, revision limit, auto-refresh interval, booth open/closed state, Hermes API key, and MP3 metadata defaults are stored in `booth_settings.json` inside the persistent uploads volume. They survive redeploys.
- **Booth open/closed switch.** Operators can flip the booth status from `/admin/settings`. When closed, `/request` shows a closed banner and message instead of the form.
- **Payments are manual.** The app records a Square payment reference but does not integrate with Square's API. Use a Square Terminal/Reader at the booth.
- **Operator queue is the dashboard.** No operator email alerts are sent; approvals and revision notes appear as status changes in `/admin`.
- **Hermes callback workflow.** Operators copy request details + a signed callback URL from `/admin/request/<id>`. Hermes POSTs back Title/Style/Lyrics; the record becomes `prompt_ready`.
- **MP3 metadata.** Uploaded files are tagged with title (from the saved prompt), plus configured artist/album/year/comment values.
- **Email logo.** `static/DM-Logo_email.png` is attached inline to all customer emails as the Dionysis Media signature.
- **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment.
- **Security:** the repo is public on GitLab. No secrets are committed. Admin password is plain text in the Portainer environment. The Hermes API key is stored encrypted in `booth_settings.json` and masked in the admin UI.
## Common troubleshooting

View file

@ -27,13 +27,13 @@ Flask app that lets convention attendees request custom AI-generated theme songs
| File | Notes |
|---|---|
| `app.py` | All routes, helpers, email function, status labels, runtime settings, MP3 tagging, rate limiting, DB health. |
| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. |
| `app.py` | All routes, helpers, email function, status labels, runtime settings, MP3 tagging, rate limiting, DB health, and the `/api/prompt/<id>` Hermes callback endpoint. |
| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. `HERMES_API_KEY` can be overridden at runtime. |
| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. |
| `init_db.py` | Run once after deploy: `python init_db.py`. |
| `templates/admin/request.html` | Biggest template; prompt copy helpers and JS live here. Customer email and operator notes are editable here. |
| `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + topbar Reset System button. |
| `templates/admin/settings.html` | SMTP config, MP3 metadata defaults, DB backup/restore, health check, reset. |
| `templates/admin/settings.html` | SMTP config, MP3 metadata defaults, DB backup/restore, health check, reset, and Hermes API key management. |
| `templates/faq.html` | Customer FAQ page. |
| `templates/status.html` | Customer order status lookup. |
| `templates/closed.html` | Message shown on `/request` when the booth is marked closed. |
@ -52,13 +52,14 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de
1. Customer fills `/request`.
2. Open `/admin`, click request row (or filter by status).
3. On `/admin/request/<id>`, fix the customer's email if needed, then click **Copy customer info for Hermes**, paste result to Hermes.
4. Paste Hermes response (Title/Style/Lyrics format) into the fields and click **Save Prompt**.
5. Copy Style/Lyrics into Suno Custom Mode, generate two versions.
6. Upload Version A and B MP3s.
7. Click **Send Preview Link**.
8. Customer receives email, visits player, picks version.
9. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**.
10. Customer receives MP3 attachment(s) by email.
4. Hermes POSTs Title/Style/Lyrics back to the signed callback URL; the request becomes **Prompt Ready**.
5. If the callback fails, paste Hermes' response into the Title/Style/Lyrics fields and click **Save Prompt**.
6. Copy Style/Lyrics into Suno Custom Mode, generate two versions.
7. Upload Version A and B MP3s.
8. Click **Send Preview Link**.
9. Customer receives email, visits player, picks version.
10. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**.
11. Customer receives MP3 attachment(s) by email.
## Environment variables that matter
@ -71,6 +72,7 @@ BOOTH_NAME
HOST_PORT
INTERNAL_PORT
MAX_REVISIONS
HERMES_API_KEY
```
Most can be overridden at runtime from `/admin/settings` and stored in `booth_settings.json`.
@ -84,6 +86,8 @@ Most can be overridden at runtime from `/admin/settings` and stored in `booth_se
- Runtime settings are stored in the persistent uploads volume (`booth_settings.json`).
- The `booth_open` setting controls whether `/request` shows the form or the closed banner.
- Container cannot read host paths; all static assets used at runtime (logo, banner, favicons, closed banner) must be in the repo or a mounted volume.
- The Hermes callback URL is signed with `APP_SECRET_KEY` and expires after 1 hour.
- If you regenerate the Hermes API key, update the Hermes skill/config immediately; old key requests will 401.
## How to redeploy

154
app.py
View file

@ -45,8 +45,12 @@ from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
import hmac
import hashlib
import secrets
# Flask and related imports
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file, jsonify
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.utils import secure_filename
@ -263,6 +267,82 @@ def get_refresh_seconds():
val = 10
return val if val in (10, 20, 30) else 10
def get_hermes_api_key():
"""
Return the effective Hermes API key.
Runtime settings override the environment variable so the key can be
regenerated from /admin/settings without redeploying.
"""
cfg = load_booth_settings()
return decrypt_value(cfg.get('hermes_api_key', '')) or current_app.config.get('HERMES_API_KEY', '')
def set_hermes_api_key(key):
"""Persist a new Hermes API key (encrypted) to runtime settings."""
cfg = load_booth_settings()
cfg['hermes_api_key'] = encrypt_value(key)
save_booth_settings(cfg)
def generate_hermes_api_key():
"""Generate a new random API key for Hermes callback authentication."""
return secrets.token_urlsafe(32)
def mask_api_key(key):
"""Return a masked version of the API key showing only the last 6 characters."""
if not key:
return 'Not set'
if len(key) <= 6:
return '*' * len(key)
return '*' * (len(key) - 6) + key[-6:]
def sign_prompt_callback(rid, expires_at=None):
"""
Create a signed callback token for a specific request ID.
The signature is HMAC-SHA256 over "rid:expires_at" using APP_SECRET_KEY.
Returns a URL-safe token string.
"""
secret = current_app.config['SECRET_KEY'].encode()
if expires_at is None:
# Default expiry: 1 hour.
expires_at = int(time.time()) + 3600
payload = f"{rid}:{expires_at}"
sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:16]
return f"{rid}:{expires_at}:{sig}"
def verify_prompt_callback(token):
"""
Verify a signed callback token. Returns (rid, ok) tuple.
The token must match the HMAC signature and not be expired.
"""
if not token:
return None, False
parts = token.split(':')
if len(parts) != 3:
return None, False
try:
rid = int(parts[0])
expires_at = int(parts[1])
except ValueError:
return None, False
if int(time.time()) > expires_at:
return None, False
expected = sign_prompt_callback(rid, expires_at)
if not hmac.compare_digest(expected, token):
return None, False
return rid, True
def build_prompt_callback_url(rid):
"""Build the full callback URL an operator pastes into Hermes for a request."""
token = sign_prompt_callback(rid)
return f"{current_app.config['PUBLIC_BASE_URL']}/api/prompt/{rid}?token={token}"
def send_email(to, subject, body, attachments=None, inline_images=None):
"""Send an email using the configured or runtime SMTP settings."""
cfg = get_email_config()
@ -383,6 +463,59 @@ def request_form():
return render_template('request.html', form=None)
@app.route('/api/prompt/<int:rid>', methods=['POST'])
def api_update_prompt(rid):
"""
Hermes callback endpoint.
Accepts a JSON POST with generated Suno prompt fields and updates the
matching request. Two layers of auth:
1) A per-request signed callback token in the query string.
2) A Hermes API key in the Authorization header (Bearer).
Only records in 'pending' status can be updated. On success, status is set
to 'prompt_ready'.
"""
# Layer 1: verify the signed callback URL token.
callback_token = request.args.get('token', '').strip()
token_rid, token_ok = verify_prompt_callback(callback_token)
if not token_ok or token_rid != rid:
abort(401)
# Layer 2: verify the Hermes API key from the Authorization header.
expected_key = get_hermes_api_key()
if not expected_key:
abort(500, description='Hermes API key is not configured')
auth_header = request.headers.get('Authorization', '').strip()
if not auth_header.startswith('Bearer '):
abort(401)
provided_key = auth_header[7:].strip()
if not hmac.compare_digest(expected_key, provided_key):
abort(401)
req = get_request_by_id(rid)
if not req:
abort(404)
if req['status'] != 'pending':
abort(409, description='Request is no longer pending')
data = request.get_json(silent=True) or {}
# Optional email verification to make sure clipboard matches record.
provided_email = data.get('email', '').strip().lower()
if provided_email and provided_email != req['email'].lower():
abort(400, description='Email mismatch')
update_request(rid,
suno_title=data.get('suno_title', '').strip(),
suno_style=data.get('suno_style', '').strip(),
suno_lyrics=data.get('suno_lyrics', '').strip(),
status='prompt_ready'
)
return jsonify({'ok': True, 'request_id': rid, 'status': 'prompt_ready'}), 200
@app.route('/thanks/<int:rid>')
def thanks(rid):
"""Confirmation page shown after a customer submits a request."""
@ -692,7 +825,7 @@ def admin_request(rid):
return redirect(url_for('admin_request', rid=rid))
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename, extra_files=extra_files)
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename, extra_files=extra_files, callback_url=build_prompt_callback_url(rid))
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])
@ -765,6 +898,12 @@ def admin_settings():
'smtp_pass_set': bool(runtime_settings.get('smtp_pass', '')),
}
# Hermes API key state for the settings page.
hermes_key = get_hermes_api_key()
hermes_key_masked = mask_api_key(hermes_key)
hermes_key_set = bool(hermes_key)
hermes_key_just_generated = session.pop('hermes_key_just_generated', None)
# Compute upload folder stats.
total_upload_size = 0
upload_file_count = 0
@ -948,6 +1087,14 @@ def admin_settings():
flash(f'Database restore failed: {e}', 'error')
return redirect(url_for('admin_settings'))
elif action == 'regenerate_hermes_key':
# Generate a new Hermes API key, persist it, and show it exactly once.
new_key = generate_hermes_api_key()
set_hermes_api_key(new_key)
session['hermes_key_just_generated'] = new_key
flash('Hermes API key regenerated. Copy it now — it will not be shown again.', 'success')
return redirect(url_for('admin_settings'))
return render_template(
'admin/settings.html',
health=health,
@ -965,6 +1112,9 @@ def admin_settings():
booth_open=booth_open,
email_form=email_form,
metadata=runtime_settings,
hermes_key_masked=hermes_key_masked,
hermes_key_set=hermes_key_set,
hermes_key_just_generated=hermes_key_just_generated,
)

View file

@ -69,6 +69,10 @@ class Config:
# Default customer revision limit if not overridden in runtime settings.
MAX_REVISIONS = int(os.environ.get('MAX_REVISIONS', '2'))
# API key used by Hermes to POST generated Suno prompts back to the website.
# Also stored (encrypted) in runtime settings so it can be regenerated from /admin/settings.
HERMES_API_KEY = os.environ.get('HERMES_API_KEY', '')
# Booth name used in customer-facing text and email sign-offs.
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')

View file

@ -10,8 +10,8 @@
#
# Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL
# Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION,
# CURRENCY, MAX_REVISIONS, DATABASE, UPLOAD_FOLDER, SMTP_HOST,
# SMTP_PORT, SMTP_USER, SMTP_FROM
# CURRENCY, MAX_REVISIONS, HERMES_API_KEY, DATABASE, UPLOAD_FOLDER,
# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM
#
# Named volumes keep the SQLite database and uploaded MP3s persistent
# across container restarts and redeploys.
@ -34,6 +34,7 @@ services:
- BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs}
- INTERNAL_PORT=${INTERNAL_PORT:-8000}
- MAX_REVISIONS=${MAX_REVISIONS:-2}
- HERMES_API_KEY=${HERMES_API_KEY:-}
- PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00}
- CURRENCY=${CURRENCY:-CAD}
- DATABASE=${DATABASE:-/app/data/booth.db}

View file

@ -322,7 +322,10 @@
<script>
function copyPromptForHermes() {
const callbackUrl = {{ callback_url | tojson }};
const data = {
request_id: {{ req.id | tojson }},
email: {{ req.email | tojson }},
name: {{ req.name | tojson }},
hobbies: {{ req.hobbies | tojson }},
notable_facts: {{ req.notable_facts | tojson }},
@ -330,8 +333,8 @@
vocal_gender: {{ req.vocal_gender | tojson }},
extra_requests: {{ req.extra_requests | tojson }}
};
const text = `Please write a Suno Custom Mode prompt for this customer. Suggest a song title too.\n\nCustomer data:\nName: ${data.name}\nHobbies: ${data.hobbies || '-'}\nNotable facts: ${data.notable_facts || '-'}\nStyle / genre: ${data.style_genre || '-'}\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\nExtra requests: ${data.extra_requests || '-'}\n\nReply with exactly this format:\n\nTitle: [suggested title]\n\nStyle:\n[the style description here]\n\nLyrics:\n[the lyrics here, with metatags]`;
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste Hermes response into the Title, Style, and Lyrics fields."));
const text = `Booth request: ${data.request_id}\nCustomer email: ${data.email}\nCallback URL: ${callbackUrl}\n\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\n\nCustomer data:\nName: ${data.name}\nHobbies: ${data.hobbies || '-'}\nNotable facts: ${data.notable_facts || '-'}\nStyle / genre: ${data.style_genre || '-'}\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\nExtra requests: ${data.extra_requests || '-'}\n\nExpected JSON response format:\n{\n \"request_id\": ${data.request_id},\n \"email\": \"${data.email}\",\n \"suno_title\": \"...\",\n \"suno_style\": \"...\",\n \"suno_lyrics\": \"...\"\n}`;
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL."));
}
function copyToClipboard(elementId, label) {

View file

@ -251,6 +251,28 @@
</form>
</div>
<!-- Email / SMTP configuration -->
<div class="section">
<h2>Hermes API Key</h2>
<p class="copy-hint">Hermes uses this key in the Authorization header when POSTing generated Suno prompts back to the callback endpoint.</p>
<p><strong>Current key:</strong> <code>{{ hermes_key_masked }}</code></p>
{% if hermes_key_just_generated %}
<div class="flash success" style="background:#064e3b">
<p><strong>New key (copy it now — it will not be shown again):</strong></p>
<p class="path" id="new-hermes-key">{{ hermes_key_just_generated }}</p>
<button type="button" onclick="copyHermesKey()">Copy to clipboard</button>
</div>
{% elif not hermes_key_set %}
<p class="status-bad">⚠️ No Hermes API key is configured. Generate one before using the callback workflow.</p>
{% endif %}
<form method="POST" onsubmit="return confirm('Regenerating the key will invalidate the old one. Hermes will need the new key. Continue?')">
<input type="hidden" name="action" value="regenerate_hermes_key">
<button type="submit">Regenerate API Key</button>
</form>
</div>
<!-- Email / SMTP configuration -->
<div class="section">
<h2>Email (SMTP) Settings</h2>
@ -351,8 +373,22 @@
<button type="submit">Reset System</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
function copyHermesKey() {
const el = document.getElementById('new-hermes-key');
if (!el) return;
const range = document.createRange();
range.selectNode(el);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
navigator.clipboard.writeText(el.textContent).then(() => {
alert('Hermes API key copied!');
});
}
</script>
</body>
</html>