/** * Skrift – Public-API (v1) für Kunden * =========================================================================== * Eigener, versionierter API-Namespace, gedacht hinter einer eigenen Domain * (z. B. api.skrift.de → NPM proxyt auf /skrift-api/*). Der Kunde sieht NIE das * Directus-Dashboard. * * POST /v1/orders – Auftrag anlegen (Brief/Postkarte: Text ODER Datei) * GET /v1/orders/:nummer – Status abfragen (wartend | in_bearbeitung | versendet) * POST /v1/files – Druckdatei hochladen (Base64) → file_id * GET /v1/openapi.json – OpenAPI-Spezifikation * GET /docs – interaktive Doku (Scalar) * POST /v1/clients – (Admin) neuen API-Zugang + Key erzeugen * * Auth: Header `X-Api-Key: ` (NICHT Authorization – den reserviert Directus). * Gespeichert wird nur der SHA-256-Hash (api_clients). Jeder Zugang ist an GENAU * EIN Produkt gebunden. */ import crypto from 'node:crypto'; import { Readable } from 'node:stream'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // Verzeichnis dieser Extension (für die selbst gehosteten Swagger-UI-Assets). const EXT_DIR = path.dirname(fileURLToPath(import.meta.url)); const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, ''); const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || ''; const STORAGE = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local'; const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'); const pad3 = (n) => String(n).padStart(3, '0'); const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag']; /** * Internal order → public status (pending | processing | shipped | cancelled). * Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische * `status` dient als Rückfall. Beide werden berücksichtigt. */ function kundenStatus(order) { const ps = String(order?.production_status || ''); const s = String(order?.status || ''); if (ps === 'storniert' || s === 'storniert') return 'cancelled'; if (ps === 'versendet' || s === 'versendet' || s === 'abgeschlossen') return 'shipped'; if (['im_druck', 'kuvertieren', 'versandfertig'].includes(ps) || ['in_produktion', 'gedruckt'].includes(s)) return 'processing'; return 'pending'; } // Selbst ausgelieferte HTML-Doku (kein externes Script → CSP-sicher). Inline-CSS. const DOCS_HTML = ` Skrift API Reference

Skrift API Reference

Handwritten mail as a service · API v1 · Base URL https://dev.skrift.de

Overview

REST API over HTTPS. Request and response bodies are JSON (Content-Type: application/json, UTF-8). An order yields one document and/or one envelope per recipient; the quantity is derived from the number of recipients. One or more products are bound to your API key; each order selects one via the product field.

Authentication

Every request must include your API key in the X-Api-Key header:

X-Api-Key: <API_KEY>

Each key is scoped to exactly one product. Keys are secrets — never expose them in client-side code.

Errors

Errors use standard HTTP status codes and a uniform body:

{ "error": "human-readable message" }
StatusMeaning
200 / 201Success / order created
400Invalid request (missing or invalid fields)
401Missing or invalid API key
404Resource not found
413Payload too large (file > 80 MB)
415Unsupported media type (only PDF is accepted)
500Internal error

Order status

The status field returns one of:

pending received, not yet in production processing being written / printed shipped dispatched cancelled cancelled

Create an order

POST /v1/orders

Creates an order and returns its order number.

Request body

FieldTypeDescription
productstringProduct key to use. Required if your key allows multiple products; optional (auto) if exactly one.
recipientsarrayRequired. One entry per recipient; count = quantity. Address as free_text (up to 5 lines, separated by \\n).
document_formatstringDocument format: a4, a6h or a6l.
fontstringtilda, alva or ellie.
realisticbooleanNatural handwriting variation. Default true.
shipping_typestringbulk (consolidated shipment to you; default) or single (direct to each recipient).
shipping_daystringPreferred dispatch weekday, e.g. montag (optional).
letter.textstringLetter body, fully written out — no placeholders. Long text flows across multiple pages automatically; [[seitenumbruch]] forces a page break.
letter.filesarrayfile_id(s) from POST /v1/files (file / PDF products).
envelope.modestringnone (no envelope) or recipient (recipient address on the envelope). Envelope size is selected automatically: A4 → DIN Lang, otherwise C6.

Example — text letter

curl -X POST https://dev.skrift.de/v1/orders \\
  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
  -d '{
    "product":"briefe","document_format":"a4","font":"tilda","realistic":true,
    "shipping_type":"bulk","shipping_day":"montag",
    "letter":{"text":"Dear Anna,\\n\\nthank you for ..."},
    "envelope":{"mode":"recipient"},
    "recipients":[
      {"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}
    ]
  }'

Example — print uploaded PDFs (file product)

curl -X POST https://dev.skrift.de/v1/orders \\
  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
  -d '{"document_format":"a4","letter":{"files":["<file_id>"]},
       "envelope":{"mode":"recipient"},
       "recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'

Example — address envelopes only

curl -X POST https://dev.skrift.de/v1/orders \\
  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
  -d '{"envelope":{"mode":"recipient"},
       "recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'

Response · 201

{ "order_number": "18-08-26-001", "status": "pending" }

Retrieve order status

GET /v1/orders/{order_number}

Returns the current status of one of your orders. status_since is the ISO 8601 timestamp (UTC) since which the order has been in its current status.

curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: <KEY>"

{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }

Upload a PDF

POST /v1/files

Uploads a base64-encoded print file. PDF only, max. 80 MB. Returns a file_id to reference in letter.files.

curl -X POST https://dev.skrift.de/v1/files \\
  -H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
  -d '{"filename":"letter.pdf","content_base64":"<BASE64>"}'

{ "file_id": "..." }

Machine-readable specification: OpenAPI (JSON) — import into Postman, Insomnia, or any OpenAPI client.

`; // Swagger UI (selbst gehostet). Assets liegen als swagger-ui.css + swagger-ui-bundle.js // im Extension-Ordner; init.js wird generiert. Kein CDN → CSP-/Brave-sicher. const SWAGGER_HTML = ` Skrift API Reference
`; const INIT_JS = "window.addEventListener('DOMContentLoaded',function(){window.ui=SwaggerUIBundle({url:'v1/openapi.json',dom_id:'#swagger-ui',deepLinking:true,presets:[SwaggerUIBundle.presets.apis],layout:'BaseLayout'});});"; const handler = (router, { services, getSchema, logger }) => { const { ItemsService, FilesService } = services; const svcFactory = (schema) => (c) => new ItemsService(c, { schema, accountability: null }); /** Zeichen-Whitelist (wie im übrigen System) als Sanitizer laden. */ async function ladeClean(schema) { try { const ps = await new ItemsService('pricing_settings', { schema, accountability: null }) .readSingleton({ fields: ['char_whitelist'] }); const wl = ps?.char_whitelist; const re = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null; return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v); } catch { return (v) => v; } } /** API-Key aus dem X-Api-Key-Header lesen und den aktiven Zugang finden. * (NICHT Authorization: Bearer – den reserviert Directus für eigene Tokens * und lehnt fremde Werte global mit INVALID_CREDENTIALS ab.) */ async function authClient(req) { const raw = String(req.headers['x-api-key'] || '').trim(); if (!raw) return null; const schema = await getSchema(); const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({ filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } }, limit: 1, fields: ['id', 'name', 'customer', 'webhook_url', 'config', 'product.id', 'product.key', 'product.type', 'product.input_mode', 'products.product.id', 'products.product.key', 'products.product.type', 'products.product.input_mode'], }); const client = rows?.[0] || null; if (client) { // Letzte Nutzung vermerken (best effort, nicht blockierend). new ItemsService('api_clients', { schema, accountability: null }) .updateOne(client.id, { last_used: new Date().toISOString() }).catch(() => {}); } return client; } const fehler = (res, code, msg) => res.status(code).json({ error: msg }); // ── POST /v1/files ───────────────────────────────────────────────────────── // Body: { filename, content_base64 } → { file_id }. NUR PDF (Sicherheit). router.post('/v1/files', async (req, res) => { const client = await authClient(req); if (!client) return fehler(res, 401, 'Missing or invalid API key.'); const { filename, content_base64 } = req.body || {}; if (!filename || !content_base64) return fehler(res, 400, 'filename and content_base64 are required.'); if (!/\.pdf$/i.test(String(filename))) return fehler(res, 415, 'Only PDF files are accepted (filename must end in .pdf).'); let buf; try { buf = Buffer.from(String(content_base64), 'base64'); } catch { return fehler(res, 400, 'content_base64 is not valid base64.'); } if (!buf.length) return fehler(res, 400, 'File is empty.'); if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'File too large (max. 80 MB).'); // Inhalts-Prüfung: echte PDF-Signatur, nicht nur die Endung. if (buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'File is not a valid PDF.'); try { const schema = await getSchema(); const filesSvc = new FilesService({ schema, accountability: null }); const id = await filesSvc.uploadOne(Readable.from(buf), { storage: STORAGE, filename_download: String(filename), title: String(filename), type: 'application/pdf', }); return res.json({ file_id: id }); } catch (err) { logger.error(`[skrift-api] files: ${err.stack || err.message}`); return fehler(res, 500, 'File upload failed.'); } }); // ── POST /v1/orders ───────────────────────────────────────────────────────── router.post('/v1/orders', async (req, res) => { const client = await authClient(req); if (!client) return fehler(res, 401, 'Missing or invalid API key.'); const body = req.body || {}; // Erlaubte Produkte des Keys: m2m-Liste + (Fallback) Einzelprodukt, dedupliziert. const erlaubt = {}; if (Array.isArray(client.products)) for (const j of client.products) if (j && j.product && j.product.id != null) erlaubt[j.product.id] = j.product; if (client.product && client.product.id != null) erlaubt[client.product.id] = client.product; const erlaubteListe = Object.values(erlaubt); if (!erlaubteListe.length) return fehler(res, 409, 'No product is assigned to this API key.'); // Produktwahl: body.product (Key) muss erlaubt sein; bei genau einem Produkt optional. const gewuenscht = String(body.product || '').trim(); let product; if (gewuenscht) { product = erlaubteListe.find((p) => String(p.key) === gewuenscht); if (!product) return fehler(res, 400, `product "${gewuenscht}" is not allowed for this key. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`); } else if (erlaubteListe.length === 1) { product = erlaubteListe[0]; } else { return fehler(res, 400, `Field "product" is required. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`); } const recipients = Array.isArray(body.recipients) ? body.recipients : []; if (!recipients.length) return fehler(res, 400, 'At least one recipient is required.'); if (recipients.length > 5000) return fehler(res, 400, 'Too many recipients (max. 5000).'); const istKuvertOnly = product.type === 'envelope'; const istDatei = product.input_mode === 'datei'; const letter = body.letter || {}; const files = Array.isArray(letter.files) ? letter.files.filter(Boolean) : []; // Kuvert-only: kein Brief nötig (nur adressierte Kuverts); PDF-Dateien optional. if (!istKuvertOnly) { if (istDatei && !files.length) return fehler(res, 400, 'This product requires uploaded print files (letter.files).'); if (!istDatei && !String(letter.text || '').trim()) return fehler(res, 400, 'letter.text is required.'); } try { const schema = await getSchema(); const svc = svcFactory(schema); const clean = await ladeClean(schema); // Schriftstückformat: nur A4 / A6H / A6L (freundliche Werte → interne Keys). const FORMAT_MAP = { a4: 'a4', a6h: 'a6_hoch', a6l: 'a6_quer', a6_hoch: 'a6_hoch', a6_quer: 'a6_quer' }; const fmtKey = FORMAT_MAP[String(body.document_format || '').trim().toLowerCase()] || null; if (!fmtKey && !istKuvertOnly) return fehler(res, 400, 'document_format must be a4, a6h or a6l.'); let formatId = null; if (fmtKey) { const fr = await svc('formats').readByQuery({ filter: { key: { _eq: fmtKey } }, limit: 1, fields: ['id'] }); formatId = fr?.[0]?.id ?? null; if (!formatId) return fehler(res, 400, `Format ${fmtKey} is not active.`); } // Kuvert: NUR "recipient" (Empfängeradresse) oder "none" (gar kein Kuvert). const envMode = String((body.envelope || {}).mode || '').toLowerCase() === 'recipient' ? 'recipient' : (istKuvertOnly ? 'recipient' : 'none'); // Kuvertformat automatisch aus dem Dokumentformat: A4 → DIN Lang, sonst C6. const envFormat = envMode === 'none' ? null : (fmtKey === 'a4' ? 'dinlang' : 'c6'); const hatDateien = files.length > 0; const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda'; // Versandart nur Englisch: "single" (direkt an Empfänger) / "bulk" (Sammelversand an dich). const shippingType = String(body.shipping_type || '').toLowerCase() === 'single' ? 'einzeln' : 'sammel'; // Gewünschter Versandtag (optional), z. B. "montag". const shippingDay = WOCHENTAGE.includes(String(body.shipping_day || '').toLowerCase()) ? String(body.shipping_day).toLowerCase() : null; const orderData = { api_client: client.id, customer: client.customer || null, customer_email: (client.config && client.config.email) || null, product: product.id, format: formatId, person_type: 'unternehmen', font, realistic: body.realistic !== false, source: 'api', status: 'in_queue', production_status: 'eingegangen', payment_status: 'offen', payment_method: 'rechnung', shipping_type: shippingType, needs_envelope: envMode === 'recipient', envelope_labeling: envMode === 'recipient' ? 'empfaenger' : 'keine', envelope_text: null, envelope_format: envFormat, // Kein Brieftext bei Datei- oder Kuvert-only-Produkten. text_template: (istDatei || istKuvertOnly) ? null : clean(String(letter.text || '')), // Hochgeladene PDF-Briefe (Datei-Produkt ODER optional bei Kuvert-only). source_files: (istDatei || (istKuvertOnly && hatDateien)) ? files : null, source_file: (istDatei || (istKuvertOnly && hatDateien)) ? (files[0] || null) : null, entries_count: recipients.length, shipping_day: shippingDay, // Mehrseitigkeit läuft selbstständig (Auto-Fluss); nicht vom Kunden wählbar. multi_page: true, // Schriftgröße nicht vom Kunden wählbar. font_scale_letter: 1, font_scale_envelope: 1, }; // Auftragsnummer (Tages-Präfix) mit Kollisions-Wiederholung. const jt = new Date(); const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`; const naechsteNummer = async () => { const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] }); return `${praefix}-${pad3((heutige?.length ?? 0) + 1)}`; }; let orderId, orderNumber; for (let v = 0; v < 6; v += 1) { orderNumber = await naechsteNummer(); try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; } catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; } } // Empfängerzeilen – Briefnummer wird vom System vergeben. const entriesSvc = svc('order_entries'); let n = 0; for (const e of recipients) { n += 1; await entriesSvc.createOne({ order: orderId, letter_number: n, salutation: clean(e.salutation ?? null), first_name: clean(e.first_name ?? null), last_name: clean(e.last_name ?? null), street: clean(e.street ?? null), house_no: clean(e.house_no ?? null), zip: clean(e.zip ?? null), city: clean(e.city ?? null), country: clean(e.country ?? null), free_text: clean(e.free_text ?? null), // Keine Platzhalter über die API – der Brieftext wird 1:1 verwendet. placeholders: null, }); } await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {}); // Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das // Backend entscheidet, was zu erzeugen ist: Schriftstücke bei Handschrift- // Produkten, Kuverts immer wenn gewünscht; bei Datei-Produkten (z. B. // Unterschriftenservice) NUR die Kuverts. Ohne zu Erzeugendes = No-Op. fetch(`${BACKEND_URL}/api/order/from-directus`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, body: JSON.stringify({ orderId }), }).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`)); return res.status(201).json({ order_number: orderNumber, status: 'pending' }); } catch (err) { logger.error(`[skrift-api] orders: ${err.stack || err.message}`); return fehler(res, 500, 'Order could not be created.'); } }); // ── GET /v1/orders/:nummer ────────────────────────────────────────────────── router.get('/v1/orders/:nummer', async (req, res) => { const client = await authClient(req); if (!client) return fehler(res, 401, 'Missing or invalid API key.'); try { const schema = await getSchema(); const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({ // Nur eigene Aufträge des Zugangs. filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } }, limit: 1, fields: ['order_number', 'status', 'production_status', 'production_status_changed_at', 'versandt_am', 'entries_count', 'date_created'], }); const order = rows?.[0]; if (!order) return fehler(res, 404, 'Order not found.'); const status = kundenStatus(order); // Seit wann der Status so ist: Zeitpunkt der letzten Produktionsstatus- // Änderung; bei „shipped" ersatzweise der Versandzeitpunkt, sonst Anlage. const statusSince = order.production_status_changed_at || (status === 'shipped' ? order.versandt_am : null) || order.date_created || null; return res.json({ order_number: order.order_number, status, status_since: statusSince, recipients: order.entries_count ?? null, created_at: order.date_created ?? null, }); } catch (err) { logger.error(`[skrift-api] status: ${err.stack || err.message}`); return fehler(res, 500, 'Failed to retrieve order status.'); } }); // ── POST /v1/clients (Admin) – neuen Zugang + Key erzeugen ─────────────────── // Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert. router.post('/v1/clients', async (req, res) => { if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.'); const { name, product, products, customer, webhook_url } = req.body || {}; // Mehrere Produkte (Array von IDs) bevorzugt; „product" (einzeln) als Kurzform. const prodIds = Array.isArray(products) ? products.filter((x) => x != null) : (product != null ? [product] : []); if (!name || !prodIds.length) return fehler(res, 400, 'name and products (array of product IDs) are required.'); try { const schema = await getSchema(); const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`; const id = await new ItemsService('api_clients', { schema, accountability: null }).createOne({ name: String(name), products: prodIds.map((pid) => ({ product: pid })), customer: customer || null, webhook_url: webhook_url || null, key_hash: sha256(key), key_prefix: key.slice(0, 14), active: true, }); // Klartext-Key nur JETZT – wird nirgends gespeichert. return res.status(201).json({ id, name, api_key: key, note: 'Store this key securely — it will not be shown again.' }); } catch (err) { logger.error(`[skrift-api] clients: ${err.stack || err.message}`); return fehler(res, 500, 'API client could not be created.'); } }); // ── OpenAPI + Doku ────────────────────────────────────────────────────────── const OPENAPI = { openapi: '3.0.3', info: { title: 'Skrift API', version: '1.0.0', description: 'Handwritten mail as a service. Authentication: API key in the X-Api-Key header. Bodies are JSON (UTF-8).' }, servers: [{ url: 'https://dev.skrift.de' }], components: { securitySchemes: { ApiKey: { type: 'apiKey', in: 'header', name: 'X-Api-Key' } }, schemas: { Recipient: { type: 'object', properties: { free_text: { type: 'string', description: 'Recipient address as free text, up to 5 lines separated by \\n.' }, salutation: { type: 'string' }, first_name: { type: 'string' }, last_name: { type: 'string' }, street: { type: 'string' }, house_no: { type: 'string' }, zip: { type: 'string' }, city: { type: 'string' }, country: { type: 'string' }, }, }, OrderRequest: { type: 'object', required: ['recipients'], properties: { product: { type: 'string', description: 'Product key to use. Required if the API key is bound to more than one product; optional (auto) if exactly one.' }, document_format: { type: 'string', enum: ['a4', 'a6h', 'a6l'], description: 'Document format.' }, font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] }, realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' }, shipping_type: { type: 'string', enum: ['single', 'bulk'], default: 'bulk', description: 'single = direct to each recipient; bulk = consolidated shipment to you.' }, shipping_day: { type: 'string', enum: ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], description: 'Preferred dispatch weekday (optional).' }, letter: { type: 'object', properties: { text: { type: 'string', description: 'Letter body, fully written out (no placeholders). Long text flows across multiple pages automatically; [[seitenumbruch]] forces a page break.' }, files: { type: 'array', items: { type: 'string' }, description: 'file_id(s) from POST /v1/files (file / PDF products).' }, }, }, envelope: { type: 'object', properties: { mode: { type: 'string', enum: ['none', 'recipient'], default: 'none', description: 'none = no envelope; recipient = recipient address on the envelope. Envelope size is selected automatically (A4 → DIN Lang, otherwise C6).' }, }, }, recipients: { type: 'array', items: { $ref: '#/components/schemas/Recipient' } }, }, }, }, }, security: [{ ApiKey: [] }], paths: { '/v1/orders': { post: { summary: 'Create an order', description: 'Quantity equals the number of recipients. Returns the order number.', requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderRequest' } } } }, responses: { 201: { description: 'Order created', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string' } } } } } } }, }, }, '/v1/orders/{order_number}': { get: { summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] }, status_since: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp since which the order has held its current status.' }, recipients: { type: 'integer' } } } } } } }, }, }, '/v1/files': { post: { summary: 'Upload a PDF (PDF only, max. 80 MB)', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['filename', 'content_base64'], properties: { filename: { type: 'string', description: 'Must end in .pdf.' }, content_base64: { type: 'string', description: 'PDF as base64.' } } } } } }, responses: { 200: { description: 'Uploaded', content: { 'application/json': { schema: { type: 'object', properties: { file_id: { type: 'string' } } } } } } }, }, }, }, }; router.get('/v1/openapi.json', (_req, res) => res.json(OPENAPI)); router.get('/docs', (_req, res) => { // Swagger UI, wenn die Assets im Extension-Ordner liegen; sonst die statische // HTML-Doku als Fallback (nie leer). Beides CSP-/Brave-sicher (kein CDN). let hasSwagger = false; try { hasSwagger = fs.existsSync(path.join(EXT_DIR, 'swagger-ui-bundle.js')); } catch { /* ignore */ } res.type('html').send(hasSwagger ? SWAGGER_HTML : DOCS_HTML); }); // Statische Doku-Assets (Swagger UI) – von der eigenen Domain, whitelisted. router.get('/docs-assets/:file', (req, res) => { const name = String(req.params.file || ''); if (name === 'init.js') { res.type('application/javascript').send(INIT_JS); return; } const TYPES = { 'swagger-ui.css': 'text/css', 'swagger-ui-bundle.js': 'application/javascript' }; if (!TYPES[name]) return res.status(404).json({ error: 'Not found.' }); try { const buf = fs.readFileSync(path.join(EXT_DIR, name)); res.setHeader('Content-Type', TYPES[name]); res.setHeader('Cache-Control', 'public, max-age=86400'); return res.send(buf); } catch { return res.status(404).json({ error: 'Docs asset missing – place swagger-ui.css and swagger-ui-bundle.js in the extension folder.' }); } }); }; // Objekt-Form mit fester id → Mount unter /skrift-api (wie skrift-orders). export default { id: 'skrift-api', handler };