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).
This commit is contained in:
ScreenTinker 2026-06-09 12:23:55 -05:00
parent bcdffd4f56
commit 0ba36949cf
8 changed files with 403 additions and 0 deletions

View file

@ -181,6 +181,9 @@ export const api = {
adminListOrgs: () => request('/admin/orgs'),
adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
aiGetSettings: () => request('/ai/settings'),
aiSaveSettings: (data) => request('/ai/settings', { method: 'PUT', body: JSON.stringify(data) }),
aiGenerateDesign: (prompt) => request('/ai/generate-design', { method: 'POST', body: JSON.stringify({ prompt }) }),
// Instance-level default branding (#15, platform admin).
adminGetBranding: () => request('/admin/branding'),

View file

@ -600,6 +600,25 @@ export default {
// Designer
'designer.title': 'Content Designer',
'designer.subtitle': 'Create dynamic signage content',
'designer.ai.title': '✨ AI generate',
'designer.ai.settings': 'AI settings',
'designer.ai.placeholder': "Describe your sign — e.g. 'Summer sale, 20% off mains, bright modern'",
'designer.ai.generate': 'Generate design',
'designer.ai.generating': 'Generating…',
'designer.ai.contacting': 'Contacting your AI endpoint… (local models can be slow)',
'designer.ai.done': 'Generated {n} element(s) — tweak and Publish.',
'designer.ai.failed': 'Generation failed',
'designer.ai.need_prompt': 'Enter a prompt first',
'designer.ai.settings_title': 'AI design settings',
'designer.ai.settings_desc': 'Bring your own OpenAI-compatible endpoint — OpenAI cloud, or self-hosted Ollama / LM Studio. Your provider bills you; the key is stored encrypted and never shown again.',
'designer.ai.base_url': 'Endpoint base URL',
'designer.ai.model': 'Model',
'designer.ai.api_key': 'API key',
'designer.ai.key_set': '•••••• saved — leave blank to keep',
'designer.ai.key_placeholder': 'leave blank if your endpoint needs none',
'designer.ai.key_hint': 'Stored encrypted, server-side. Local endpoints (Ollama) usually need no key.',
'designer.ai.saved': 'AI settings saved',
'designer.ai.save_failed': 'Could not save AI settings',
'designer.help_tip': 'Create custom signage with live elements: clocks, weather, RSS tickers, countdowns, QR codes. Publish as a widget or export as PNG.',
'designer.load_design': 'Load Design',
'designer.export_png': 'Export PNG',

View file

@ -1,5 +1,6 @@
import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
// Background swatches: ids resolve to translated names; values are the actual
@ -50,6 +51,19 @@ export function render(container) {
</div>
<!-- Sidebar -->
<div style="width:300px;display:flex;flex-direction:column;gap:12px;max-height:calc(100vh - 120px);overflow-y:auto">
<!-- AI Generate (#41) -->
<div style="background:var(--bg-card);border:1px solid var(--accent);border-radius:var(--radius);padding:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h4 style="font-size:13px">${t('designer.ai.title')}</h4>
<button class="btn-icon" id="aiSettingsBtn" title="${t('designer.ai.settings')}" style="padding:2px">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
<textarea id="aiPrompt" rows="2" class="input" placeholder="${t('designer.ai.placeholder')}" style="width:100%;resize:vertical;font-size:12px"></textarea>
<button class="btn btn-primary btn-sm" id="aiGenerateBtn" style="width:100%;justify-content:center;margin-top:6px">${t('designer.ai.generate')}</button>
<div id="aiStatus" style="font-size:11px;color:var(--text-muted);margin-top:6px"></div>
</div>
<!-- Add Elements -->
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px">
<h4 style="font-size:13px;margin-bottom:10px">${t('designer.add_element')}</h4>
@ -110,6 +124,32 @@ export function render(container) {
reader.readAsDataURL(file);
};
// AI generate (#41): prompt -> validated design spec -> load onto the canvas.
document.getElementById('aiSettingsBtn').onclick = openAiSettings;
const aiGenBtn = document.getElementById('aiGenerateBtn');
aiGenBtn.onclick = async () => {
const prompt = document.getElementById('aiPrompt').value.trim();
const status = document.getElementById('aiStatus');
if (!prompt) { status.textContent = t('designer.ai.need_prompt'); return; }
aiGenBtn.disabled = true; aiGenBtn.textContent = t('designer.ai.generating');
status.textContent = t('designer.ai.contacting');
try {
const design = await api.aiGenerateDesign(prompt);
elements = []; selectedIdx = -1;
if (design.background) {
bgValue = design.background; bgImageDataUrl = null;
const bc = document.getElementById('bgColor'); if (bc) bc.value = design.background;
}
(design.elements || []).forEach(el => elements.push(el));
redraw();
status.textContent = t('designer.ai.done', { n: (design.elements || []).length });
} catch (err) {
status.textContent = (err && err.message) || t('designer.ai.failed');
} finally {
aiGenBtn.disabled = false; aiGenBtn.textContent = t('designer.ai.generate');
}
};
// Add element handlers
document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: t('designer.default.text'), fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false });
document.getElementById('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: t('designer.default.heading'), fontSize: 64, fontFamily: 'Impact', color: '#FFFFFF', bold: true, shadow: true });
@ -273,6 +313,59 @@ function addElement(el) {
redraw();
}
// #41: per-workspace AI endpoint config (BYO OpenAI-compatible endpoint + key).
async function openAiSettings() {
let cur = {};
try { cur = await api.aiGetSettings(); } catch { /* show empty form */ }
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.innerHTML = `
<div class="modal" style="max-width:520px;width:95vw">
<div class="modal-header">
<h3>${t('designer.ai.settings_title')}</h3>
<button class="btn-icon" data-ai-close aria-label="Close"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="modal-body">
<p style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('designer.ai.settings_desc')}</p>
<div class="form-group"><label>${t('designer.ai.base_url')}</label>
<input id="aiBaseUrl" class="input" value="${esc(cur.base_url || '')}" placeholder="https://api.openai.com/v1 · http://localhost:11434/v1" style="width:100%"></div>
<div class="form-group"><label>${t('designer.ai.model')}</label>
<input id="aiModel" class="input" value="${esc(cur.model || '')}" placeholder="gpt-4o-mini · llama3.1:8b" style="width:100%"></div>
<div class="form-group"><label>${t('designer.ai.api_key')}</label>
<input id="aiKey" class="input" type="password" autocomplete="off" placeholder="${cur.has_key ? t('designer.ai.key_set') : t('designer.ai.key_placeholder')}" style="width:100%">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('designer.ai.key_hint')}</div></div>
<div id="aiSettingsErr" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-ai-close>${t('common.cancel')}</button>
<button class="btn btn-primary" id="aiSaveSettings">${t('common.save')}</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelectorAll('[data-ai-close]').forEach(b => b.onclick = close);
overlay.onclick = (e) => { if (e.target === overlay) close(); };
overlay.querySelector('#aiSaveSettings').onclick = async () => {
const errEl = overlay.querySelector('#aiSettingsErr');
errEl.style.display = 'none';
const data = {
base_url: overlay.querySelector('#aiBaseUrl').value.trim(),
model: overlay.querySelector('#aiModel').value.trim(),
};
const key = overlay.querySelector('#aiKey').value;
if (key) data.api_key = key;
try {
await api.aiSaveSettings(data);
showToast(t('designer.ai.saved'), 'success');
close();
} catch (e2) {
errEl.textContent = (e2 && e2.message) || t('designer.ai.save_failed');
errEl.style.display = 'block';
}
};
}
function getBounds(el) {
const w = el.width || el.size || (el.fontSize ? el.fontSize * 0.6 * (el.text?.length || 8) / 100 * 100 : 20);
const h = el.height || el.size || (el.fontSize ? el.fontSize * 1.2 / 100 * 100 : 10);

View file

@ -389,6 +389,21 @@ CREATE TABLE IF NOT EXISTS white_labels (
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- ===================== AI (BYOK) SETTINGS =====================
-- #41: per-workspace AI design generation. Bring-your-own OpenAI-COMPATIBLE
-- endpoint (OpenAI cloud, or self-hosted: Ollama / LM Studio / llama.cpp, and
-- AUTOMATIC1111 etc. for images), so the operator bears no AI cost. api_key_enc
-- is AES-256-GCM encrypted (lib/secretbox.js); it is never returned to clients.
CREATE TABLE IF NOT EXISTS ai_settings (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
base_url TEXT,
api_key_enc TEXT,
model TEXT,
image_base_url TEXT,
image_model TEXT,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- ===================== KIOSK PAGES =====================
CREATE TABLE IF NOT EXISTS kiosk_pages (

31
server/lib/secretbox.js Normal file
View file

@ -0,0 +1,31 @@
'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 };

165
server/routes/ai.js Normal file
View file

@ -0,0 +1,165 @@
'use strict';
// #41: AI content design. Bring-your-own OpenAI-COMPATIBLE endpoint (OpenAI cloud
// or self-hosted Ollama / LM Studio / llama.cpp) generates a *structured* design
// spec that the existing Designer renders with real fonts — so text is crisp and
// editable (raw image-gen garbles text). The operator bears no AI cost; each
// workspace configures its own endpoint/key (encrypted at rest, never returned).
const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
const config = require('../config');
const { encrypt, decrypt } = require('../lib/secretbox');
const { logActivity, getClientIp } = require('../services/activity');
const isWorkspaceAdmin = (req) => req.isPlatformAdmin || req.actingAs || req.workspaceRole === 'workspace_admin';
const canEdit = (req) => req.isPlatformAdmin || req.actingAs || ['workspace_admin', 'workspace_editor'].includes(req.workspaceRole);
// SSRF guard. Self-hosted instances may point at localhost/LAN (the whole point);
// the hosted instance must not let a tenant admin reach the host's private network.
function endpointAllowed(rawUrl) {
let u;
try { u = new URL(rawUrl); } catch { return false; }
if (!/^https?:$/.test(u.protocol)) return false;
if (config.selfHosted) return true;
const h = u.hostname.toLowerCase();
if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return false;
if (/^127\./.test(h) || /^10\./.test(h) || /^192\.168\./.test(h) || /^169\.254\./.test(h)) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return false;
if (/^(fc|fd)/.test(h)) return false; // IPv6 ULA
return true;
}
const DESIGN_SYSTEM_PROMPT =
`You are a digital-signage designer. The canvas is 1920x1080 (16:9). Respond with ONLY a JSON object (no prose, no markdown fences) shaped exactly:
{"background":"#RRGGBB","elements":[ELEMENT, ...]}
ELEMENT is one of:
{"type":"text","x":N,"y":N,"text":"STRING","fontSize":N,"color":"#RRGGBB","bold":true|false}
{"type":"shape","x":N,"y":N,"width":N,"height":N,"color":"#RRGGBB","opacity":N}
x, y, width, height are PERCENTAGES of the canvas (0-100). fontSize is a number where a big headline is about 90 and body text about 36. Use 3 to 6 elements: one bold headline, 1-2 supporting lines, and 0-2 shapes as colored accent bands behind/beside the text. Pick a tasteful, high-contrast palette that fits the request. Keep every element within 0-95 on both axes. Output JSON only.`;
const clampN = (n, lo, hi, d) => { n = Number(n); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
const hex = (c, d) => (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c.trim())) ? c.trim() : d;
const cleanText = (s) => String(s == null ? '' : s).replace(/<[^>]*>/g, '').trim().slice(0, 200);
// Never trust raw model output: cap count, clamp ranges, fix px-vs-% (models
// often emit pixels), strip any HTML from text, validate colors.
function normalizeDesign(raw) {
const out = { background: hex(raw && raw.background, '#111827'), elements: [] };
const els = Array.isArray(raw && raw.elements) ? raw.elements.slice(0, 20) : [];
for (const e of els) {
if (!e || typeof e !== 'object') continue;
if (e.type === 'text') {
const text = cleanText(e.text);
if (!text) continue;
out.elements.push({
type: 'text', x: clampN(e.x, 0, 95, 5), y: clampN(e.y, 0, 95, 5), text,
fontSize: clampN(e.fontSize, 12, 200, 48), fontFamily: 'Arial',
color: hex(e.color, '#FFFFFF'), bold: !!e.bold, shadow: !!e.shadow,
});
} else if (e.type === 'shape') {
let w = Number(e.width), h = Number(e.height);
if (w > 100) w = w / 19.2; // px of 1920 -> %
if (h > 100) h = h / 10.8; // px of 1080 -> %
out.elements.push({
type: 'shape', shape: 'rect',
x: clampN(e.x, 0, 100, 0), y: clampN(e.y, 0, 100, 0),
width: clampN(w, 1, 100, 30), height: clampN(h, 1, 100, 20),
color: hex(e.color, '#3b82f6'), opacity: clampN(e.opacity, 0, 1, 0.85), radius: 0,
});
}
}
return out;
}
// GET /api/ai/settings — workspace members (never returns the key)
router.get('/settings', (req, res) => {
const row = db.prepare('SELECT base_url, model, image_base_url, image_model, api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
res.json({
base_url: row ? row.base_url || '' : '',
model: row ? row.model || '' : '',
image_base_url: row ? row.image_base_url || '' : '',
image_model: row ? row.image_model || '' : '',
has_key: !!(row && row.api_key_enc),
configured: !!(row && row.base_url && row.model),
});
});
// PUT /api/ai/settings — workspace admin
router.put('/settings', (req, res) => {
if (!isWorkspaceAdmin(req)) return res.status(403).json({ error: 'Workspace admin required' });
const base_url = String(req.body && req.body.base_url || '').trim().replace(/\/+$/, '');
const model = String(req.body && req.body.model || '').trim();
const image_base_url = String(req.body && req.body.image_base_url || '').trim().replace(/\/+$/, '');
const image_model = String(req.body && req.body.image_model || '').trim();
if (base_url && !endpointAllowed(base_url)) return res.status(400).json({ error: 'Endpoint URL not allowed (private/internal addresses are blocked on this instance).' });
if (image_base_url && !endpointAllowed(image_base_url)) return res.status(400).json({ error: 'Image endpoint URL not allowed.' });
const existing = db.prepare('SELECT api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
let api_key_enc = existing ? existing.api_key_enc : null;
if (typeof (req.body && req.body.api_key) === 'string' && req.body.api_key.length) api_key_enc = encrypt(req.body.api_key);
if (req.body && req.body.clear_key) api_key_enc = null;
db.prepare(`
INSERT INTO ai_settings (workspace_id, base_url, api_key_enc, model, image_base_url, image_model, updated_at)
VALUES (?, ?, ?, ?, ?, ?, strftime('%s','now'))
ON CONFLICT(workspace_id) DO UPDATE SET base_url=excluded.base_url, api_key_enc=excluded.api_key_enc,
model=excluded.model, image_base_url=excluded.image_base_url, image_model=excluded.image_model, updated_at=excluded.updated_at
`).run(req.workspaceId, base_url || null, api_key_enc, model || null, image_base_url || null, image_model || null);
logActivity(req.user.id, 'ai_settings_update', `endpoint: ${base_url || '(none)'} model: ${model || '(none)'}`, null, getClientIp(req), req.workspaceId);
res.json({ ok: true });
});
// POST /api/ai/generate-design — editor+; proxies the workspace's endpoint
router.post('/generate-design', async (req, res) => {
if (!canEdit(req)) return res.status(403).json({ error: 'Editor access required' });
const prompt = String(req.body && req.body.prompt || '').trim().slice(0, 500);
if (!prompt) return res.status(400).json({ error: 'Prompt required' });
const row = db.prepare('SELECT base_url, api_key_enc, model FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
if (!row || !row.base_url || !row.model) return res.status(400).json({ error: 'AI is not configured. Set an endpoint and model in AI settings first.' });
if (!endpointAllowed(row.base_url)) return res.status(400).json({ error: 'Configured endpoint is not allowed.' });
const key = decrypt(row.api_key_enc) || 'none';
const url = row.base_url.replace(/\/+$/, '') + '/chat/completions';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120000);
let aiRes;
try {
aiRes = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({
model: row.model, temperature: 0.6, stream: false,
messages: [{ role: 'system', content: DESIGN_SYSTEM_PROMPT }, { role: 'user', content: prompt }],
}),
signal: controller.signal,
});
} catch (e) {
clearTimeout(timer);
return res.status(502).json({ error: 'Could not reach the AI endpoint: ' + (e.name === 'AbortError' ? 'timed out' : e.message) });
}
clearTimeout(timer);
if (!aiRes.ok) {
const t = await aiRes.text().catch(() => '');
return res.status(502).json({ error: `AI endpoint error ${aiRes.status}: ${t.slice(0, 150)}` });
}
let json;
try { json = await aiRes.json(); } catch { return res.status(502).json({ error: 'AI returned non-JSON.' }); }
const content = (json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content) || '';
let parsed;
try {
const m = content.match(/\{[\s\S]*\}/);
parsed = JSON.parse(m ? m[0] : content);
} catch { return res.status(502).json({ error: 'AI did not return a usable design. Try rephrasing.' }); }
const design = normalizeDesign(parsed);
if (!design.elements.length) return res.status(502).json({ error: 'AI returned an empty design. Try a more specific prompt.' });
logActivity(req.user.id, 'ai_generate_design', `prompt: ${prompt.slice(0, 80)}`, null, getClientIp(req), req.workspaceId);
res.json(design);
});
module.exports = router;
// Exposed for unit tests (security-critical: untrusted-LLM-output normalization
// and the SSRF guard).
module.exports.normalizeDesign = normalizeDesign;
module.exports.endpointAllowed = endpointAllowed;

View file

@ -413,6 +413,7 @@ app.use('/api/admin', requireAuth, require('./routes/admin'));
app.use('/api/devices', requireAuth, resolveTenancy, require('./routes/devices'));
app.use('/api/content', requireAuth, resolveTenancy, require('./routes/content'));
app.use('/api/ai', requireAuth, resolveTenancy, require('./routes/ai')); // #41 AI design (BYOK)
app.use('/api/folders', requireAuth, resolveTenancy, require('./routes/folders'));
app.use('/api/assignments', requireAuth, resolveTenancy, require('./routes/assignments'));
app.use('/api/provision', requireAuth, resolveTenancy, require('./routes/provisioning'));

View file

@ -0,0 +1,76 @@
'use strict';
// #41: unit tests for the security-critical bits of the AI design route -
// normalizing untrusted LLM output, and the SSRF guard on the configurable
// endpoint. Node v20 built-ins only; db is mocked so requiring the route doesn't
// touch a real database. SELF_HOSTED=false so the SSRF guard is active.
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
process.env.JWT_SECRET = 'test-secret-ai';
process.env.SELF_HOSTED = 'false';
const db = new Database(':memory:');
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() {}, pruneScreenshots() {} } };
const ai = require('../routes/ai');
const { normalizeDesign, endpointAllowed } = ai;
test('normalizeDesign: keeps valid text+shape, sets background', () => {
const d = normalizeDesign({ background: '#102030', elements: [
{ type: 'text', x: 5, y: 5, text: 'HELLO', fontSize: 90, color: '#ffffff', bold: true },
{ type: 'shape', x: 0, y: 90, width: 100, height: 8, color: '#ff0000', opacity: 0.5 },
]});
assert.equal(d.background, '#102030');
assert.equal(d.elements.length, 2);
assert.equal(d.elements[0].text, 'HELLO');
assert.equal(d.elements[0].fontFamily, 'Arial');
});
test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => {
const d = normalizeDesign({ elements: [
{ type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 },
]});
const s = d.elements[0];
assert.equal(s.x, 0, 'x clamped to 0');
assert.equal(s.y, 100, 'y clamped to 100');
assert.ok(Math.abs(s.width - 100) < 0.01, '1920px -> 100%');
assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%');
assert.equal(s.color, '#3b82f6', 'non-hex color -> default');
assert.equal(s.opacity, 1, 'opacity clamped to 1');
});
test('normalizeDesign: strips HTML from text, drops empty/invalid', () => {
const d = normalizeDesign({ elements: [
{ type: 'text', text: '<img src=x onerror=alert(1)>Sale</b>', fontSize: 9999 },
{ type: 'text', text: ' ' },
{ type: 'bogus', text: 'x' },
null,
]});
assert.equal(d.elements.length, 1, 'only the one real text survives');
assert.equal(d.elements[0].text, 'Sale');
assert.ok(!/[<>]/.test(d.elements[0].text), 'no angle brackets');
assert.equal(d.elements[0].fontSize, 200, 'fontSize clamped to max');
});
test('normalizeDesign: caps element count + bad input', () => {
const many = { elements: Array.from({ length: 50 }, () => ({ type: 'text', text: 'x' })) };
assert.ok(normalizeDesign(many).elements.length <= 20);
assert.deepEqual(normalizeDesign(null).elements, []);
assert.equal(normalizeDesign({ background: 'notacolor' }).background, '#111827');
});
test('endpointAllowed: blocks private/internal when hosted, allows public https', () => {
assert.equal(endpointAllowed('https://api.openai.com/v1'), true);
assert.equal(endpointAllowed('http://localhost:11434/v1'), false);
assert.equal(endpointAllowed('http://127.0.0.1:1234'), false);
assert.equal(endpointAllowed('http://10.0.0.5/v1'), false);
assert.equal(endpointAllowed('http://192.168.1.9/v1'), false);
assert.equal(endpointAllowed('http://169.254.169.254/latest/meta-data'), false, 'cloud metadata blocked');
assert.equal(endpointAllowed('http://172.16.5.5/v1'), false);
assert.equal(endpointAllowed('ftp://example.com'), false, 'non-http blocked');
assert.equal(endpointAllowed('not a url'), false);
});