screentinker/server/lib/secretbox.js
ScreenTinker 0ba36949cf feat(ai): AI content design in the Designer, BYO endpoint (#41 Phase 1)
Competitor pressure (Mandoe 'AI Magic Create'): prompt -> signage. We answer it
in a way that's actually BETTER for signage and costs the operator nothing.

Key idea: don't generate raw images (AI garbles text - fatal for menus/promos).
The LLM returns a STRUCTURED design spec (headline, supporting text, accent
shapes, palette) that the existing Designer renders with real fonts - crisp and
fully editable. Reuses the whole Designer.

BYOK, fully under the customer's control: each workspace configures its own
OpenAI-COMPATIBLE endpoint + key - OpenAI cloud OR self-hosted (Ollama / LM Studio
/ llama.cpp). Operator bears zero AI cost/liability.
- server/lib/secretbox.js: AES-256-GCM for the key at rest (never returned).
- routes/ai.js: GET/PUT /api/ai/settings (admin; key write-only) + POST
  /generate-design (editor+). Output is strictly validated/normalized (cap count,
  clamp ranges, px->%, strip HTML, validate colors) - never trust the model.
  SSRF guard: hosted instances block private/internal targets; self-hosted (the
  whole point of local AI) may point at localhost/LAN.
- Designer: an 'AI generate' panel (prompt + Generate) + a settings modal.

Verified end-to-end against local Ollama (llama3.1:8b): prompt -> editable design
on the canvas. Unit tests cover normalization + the SSRF guard. Suite 61/61.

Phase 2 (next): AI background images (OpenAI images / AUTOMATIC1111).
2026-06-09 12:23:55 -05:00

32 lines
1.2 KiB
JavaScript

'use strict';
// AES-256-GCM encrypt/decrypt for secrets at rest (e.g. BYOK AI provider keys,
// #41). The key is derived from the instance's JWT secret so there's no extra
// env to manage; rotating JWT_SECRET invalidates stored secrets (they're
// re-enterable). Format: base64(iv[12] | tag[16] | ciphertext).
const crypto = require('crypto');
const config = require('../config');
const KEY = crypto.createHash('sha256').update(String(config.jwtSecret) + ':secretbox-v1').digest();
function encrypt(plain) {
if (plain == null || plain === '') return null;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64');
}
function decrypt(b64) {
if (!b64) return null;
try {
const buf = Buffer.from(b64, 'base64');
const iv = buf.subarray(0, 12), tag = buf.subarray(12, 28), enc = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
} catch { return null; }
}
module.exports = { encrypt, decrypt };