import crypto from 'node:crypto' import express from 'express' import { chromium } from 'playwright-core' import { initDb, getPool } from './db.js' import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken } from './auth.js' import { renderEmailHtml, renderReportHtml } from './templateEngine.js' const app = express() const port = Number(process.env.PORT || 3000) const tenantId = process.env.TENANT_ID || process.env.TEMPLATE_TENANT_ID || 'default' app.set('trust proxy', 1) app.disable('x-powered-by') app.use(express.json({ limit: '2mb' })) app.get('/api/health/ready', async (_req, res) => { try { await getPool().query('SELECT 1') res.json({ ok: true }) } catch (error) { res.status(503).json({ ok: false, error: error.message }) } }) registerAuthRoutes(app) function userId(req) { return req.user?.preferred_username || req.user?.sub || 'unknown' } function keyHash(value) { return crypto.createHash('sha256').update(value).digest('hex') } function bearerToken(req) { const header = String(req.headers.authorization || '') return header.startsWith('Bearer ') ? header.slice(7).trim() : '' } function apiKeyFrom(req) { const header = String(req.headers['x-api-key'] || '').trim() if (header) return header const authorization = String(req.headers.authorization || '') return authorization.startsWith('ApiKey ') ? authorization.slice(7).trim() : '' } async function requireConsumerAuth(req, res, next) { try { const session = readSession(req) if (session) { req.consumer = { type: 'session', subject: session.sub || session.preferred_username } return next() } const apiKey = apiKeyFrom(req) if (apiKey) { const { rows } = await getPool().query( `SELECT id,name FROM api_keys WHERE tenant_id=$1 AND key_hash=$2 AND revoked_at IS NULL LIMIT 1`, [tenantId, keyHash(apiKey)] ) if (!rows[0]) return res.status(401).json({ error: 'invalid_api_key' }) await getPool().query('UPDATE api_keys SET last_used_at=now() WHERE id=$1', [rows[0].id]) req.consumer = { type: 'api_key', subject: rows[0].name } return next() } const token = bearerToken(req) if (token) { try { const payload = await verifyMachineToken(token) req.consumer = { type: 'keycloak', subject: payload.azp || payload.sub } return next() } catch { return res.status(401).json({ error: 'invalid_bearer_token' }) } } return res.status(401).json({ error: 'consumer_auth_required', accepted: ['X-API-Key', 'Authorization: Bearer '] }) } catch (error) { next(error) } } function defaultDocument(type) { return { blocks: [ { id: crypto.randomUUID(), type: 'heading', props: { text: type === 'email' ? 'Hello {{customer.name}}' : 'Report — {{report.date}}' } }, { id: crypto.randomUUID(), type: 'text', props: { text: type === 'email' ? 'Add your message here.' : 'Add report context here.' } }, { id: crypto.randomUUID(), type: 'kpi', props: { label: 'Total', value: '{{summary.total}}' } } ] } } function slug(value) { return String(value || '') .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48) || 'template' } async function newTemplateKey(name) { for (let attempt = 0; attempt < 8; attempt += 1) { const candidate = `${slug(name)}-${crypto.randomBytes(3).toString('hex')}` const { rowCount } = await getPool().query( 'SELECT 1 FROM templates WHERE tenant_id=$1 AND template_key=$2', [tenantId, candidate] ) if (!rowCount) return candidate } return `template-${crypto.randomUUID()}` } async function publishedTemplateByKey(key) { const { rows } = await getPool().query( `SELECT t.id, t.template_key, t.name, t.type, t.status, t.published_version, t.updated_at, v.document, v.created_at AS published_at FROM templates t JOIN template_versions v ON v.template_id=t.id AND v.version=t.published_version WHERE t.tenant_id=$1 AND t.template_key=$2 AND t.status='published' AND t.published_version IS NOT NULL LIMIT 1`, [tenantId, key] ) return rows[0] || null } async function sendRendered(res, type, document, data, format) { if (format === 'email') { if (type !== 'email') return res.status(400).json({ error: 'format_not_valid_for_template' }) return res.json({ html: renderEmailHtml(document, data) }) } const html = renderReportHtml(document, data) if (format === 'html') return res.json({ html }) if (format !== 'pdf') return res.status(400).json({ error: 'unsupported_format' }) if (type !== 'report') return res.status(400).json({ error: 'format_not_valid_for_template' }) const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', args: ['--no-sandbox', '--disable-dev-shm-usage'] }) try { const page = await browser.newPage() await page.setContent(html, { waitUntil: 'networkidle' }) const pdf = await page.pdf({ format: 'A4', printBackground: true }) return res.type('application/pdf').send(pdf) } finally { await browser.close() } } /** * Published consumer API. * * Anonymous access is intentionally not supported. A caller must present * either a Template Studio browser session, an API key, or a bearer token * from one of TEMPLATE_API_ALLOWED_CLIENTS. */ app.use('/api/v1', requireConsumerAuth) app.get('/api/v1/templates', async (_req, res) => { const { rows } = await getPool().query( `SELECT template_key AS key,name,type,published_version AS version,updated_at FROM templates WHERE tenant_id=$1 AND status='published' AND published_version IS NOT NULL ORDER BY name`, [tenantId] ) res.setHeader('Cache-Control', 'private, max-age=30') res.json({ templates: rows }) }) app.get('/api/v1/templates/:key', async (req, res) => { const template = await publishedTemplateByKey(req.params.key) if (!template) return res.status(404).json({ error: 'published_template_not_found' }) res.setHeader('Cache-Control', 'private, max-age=30') res.json({ key: template.template_key, name: template.name, type: template.type, version: template.published_version, published_at: template.published_at, document: template.document }) }) app.post('/api/v1/templates/:key/render', async (req, res) => { const template = await publishedTemplateByKey(req.params.key) if (!template) return res.status(404).json({ error: 'published_template_not_found' }) const data = req.body?.data ?? {} const format = req.body?.format || (template.type === 'email' ? 'email' : 'html') res.setHeader('Cache-Control', 'no-store') await sendRendered(res, template.type, template.document, data, format) }) /** * Everything below this point is the interactive editor API and requires * an authenticated Template Studio browser session. */ app.use('/api', requireAuth) app.get('/api/templates', async (_req, res) => { const { rows } = await getPool().query( `SELECT id,template_key,name,type,status,published_version,updated_at FROM templates WHERE tenant_id=$1 ORDER BY updated_at DESC`, [tenantId] ) res.json(rows) }) app.post('/api/templates', async (req, res) => { const name = String(req.body?.name || '').trim() const type = req.body?.type === 'email' ? 'email' : 'report' if (!name) return res.status(400).json({ error: 'name_required' }) const id = crypto.randomUUID() const templateKey = await newTemplateKey(name) const document = defaultDocument(type) const sampleData = { customer: { name: 'Example Customer' }, report: { date: new Date().toISOString().slice(0, 10) }, summary: { total: 125 }, items: [{ name: 'Example row', value: 125 }] } const { rows } = await getPool().query( `INSERT INTO templates (id,tenant_id,template_key,name,type,draft_document,sample_data,created_by,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8) RETURNING *`, [id, tenantId, templateKey, name, type, document, sampleData, userId(req)] ) res.status(201).json(rows[0]) }) app.get('/api/templates/:id', async (req, res) => { const { rows } = await getPool().query( 'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2', [req.params.id, tenantId] ) if (!rows[0]) return res.status(404).json({ error: 'not_found' }) res.json(rows[0]) }) app.put('/api/templates/:id/draft', async (req, res) => { const document = req.body?.document const sampleData = req.body?.sampleData ?? {} if (!document || !Array.isArray(document.blocks)) { return res.status(400).json({ error: 'invalid_document' }) } const { rows } = await getPool().query( `UPDATE templates SET draft_document=$1,sample_data=$2,updated_by=$3,updated_at=now() WHERE id=$4 AND tenant_id=$5 RETURNING *`, [document, sampleData, userId(req), req.params.id, tenantId] ) if (!rows[0]) return res.status(404).json({ error: 'not_found' }) res.json(rows[0]) }) app.post('/api/templates/:id/publish', async (req, res) => { const db = await getPool().connect() try { await db.query('BEGIN') const current = await db.query( 'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2 FOR UPDATE', [req.params.id, tenantId] ) const template = current.rows[0] if (!template) { await db.query('ROLLBACK') return res.status(404).json({ error: 'not_found' }) } const nextVersion = (template.published_version || 0) + 1 await db.query( `INSERT INTO template_versions (id,template_id,version,document,sample_data,created_by) VALUES ($1,$2,$3,$4,$5,$6)`, [ crypto.randomUUID(), template.id, nextVersion, template.draft_document, template.sample_data, userId(req) ] ) const updated = await db.query( `UPDATE templates SET status='published',published_version=$1,updated_at=now(),updated_by=$2 WHERE id=$3 RETURNING *`, [nextVersion, userId(req), template.id] ) await db.query('COMMIT') res.json(updated.rows[0]) } catch (error) { await db.query('ROLLBACK') throw error } finally { db.release() } }) app.post('/api/templates/:id/render', async (req, res) => { const { rows } = await getPool().query( 'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2', [req.params.id, tenantId] ) const template = rows[0] if (!template) return res.status(404).json({ error: 'not_found' }) const data = req.body?.data ?? template.sample_data ?? {} const format = req.body?.format || (template.type === 'email' ? 'email' : 'html') res.setHeader('Cache-Control', 'no-store') await sendRendered(res, template.type, template.draft_document, data, format) }) app.get('/api/api-keys', async (_req, res) => { const { rows } = await getPool().query( `SELECT id,name,key_prefix,created_at,last_used_at FROM api_keys WHERE tenant_id=$1 AND revoked_at IS NULL ORDER BY created_at DESC`, [tenantId] ) res.setHeader('Cache-Control', 'no-store') res.json(rows) }) app.post('/api/api-keys', async (req, res) => { const name = String(req.body?.name || '').trim() if (!name) return res.status(400).json({ error: 'name_required' }) const prefix = crypto.randomBytes(5).toString('hex') const secret = crypto.randomBytes(24).toString('base64url') const apiKey = `ts_live_${prefix}_${secret}` const id = crypto.randomUUID() const { rows } = await getPool().query( `INSERT INTO api_keys (id,tenant_id,name,key_prefix,key_hash,created_by) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id,name,key_prefix,created_at,last_used_at`, [id, tenantId, name, `ts_live_${prefix}`, keyHash(apiKey), userId(req)] ) res.status(201).json({ ...rows[0], api_key: apiKey }) }) app.delete('/api/api-keys/:id', async (req, res) => { const { rowCount } = await getPool().query( `UPDATE api_keys SET revoked_at=now() WHERE id=$1 AND tenant_id=$2 AND revoked_at IS NULL`, [req.params.id, tenantId] ) if (!rowCount) return res.status(404).json({ error: 'not_found' }) res.status(204).end() }) app.use(express.static('dist')) app.get(/^(?!\/api|\/auth).*/, (_req, res) => res.sendFile('index.html', { root: 'dist' })) app.use((error, _req, res, _next) => { console.error(error) res.status(500).json({ error: 'internal_error' }) }) await initDb() app.listen(port, '0.0.0.0', () => console.log(`Template Studio listening on :${port}`))