feat(portfolio): add product updates feed

This commit is contained in:
Jan Wagner
2026-08-16 01:43:16 +02:00
parent 091d0bdeec
commit 9bc9838d8d
14 changed files with 737 additions and 12 deletions
+3
View File
@@ -0,0 +1,3 @@
PORT=8080
UPDATES_DB_PATH=./data/updates.sqlite
UPDATES_ADMIN_TOKEN=replace-with-a-long-random-token
+9
View File
@@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY src ./src
RUN mkdir -p /data && chown -R node:node /app /data
USER node
ENV PORT=8080 HOSTNAME=0.0.0.0 UPDATES_DB_PATH=/data/updates.sqlite
EXPOSE 8080
CMD ["node", "--disable-warning=ExperimentalWarning", "src/server.js"]
+53
View File
@@ -0,0 +1,53 @@
# Updates API
Small public update feed for W-Make Batchmaker and Batchmaker Standalone.
## Local
```bash
npm test
UPDATES_ADMIN_TOKEN='use-a-local-token' npm start
```
The service listens on `PORT` (default `8080`) and stores SQLite data at `UPDATES_DB_PATH` (default `./data/updates.sqlite`).
## API
Public:
- `GET /healthz`
- `GET /v1/updates?product=batchmaker|standalone&limit=3`
- `GET /v1/updates/:slug`
- `GET /feed.xml?product=batchmaker|standalone`
Admin requests require `Authorization: Bearer <token>`:
```bash
curl -X POST http://127.0.0.1:8080/v1/admin/updates \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{"product":"standalone","title":"Studio update","summary":"A new calculation workflow is available."}'
curl -X POST http://127.0.0.1:8080/v1/admin/updates/1/publish \
-H 'Authorization: Bearer <token>'
```
Never commit the token or production data. In Docker, `/data` must be a persistent volume and `UPDATES_ADMIN_TOKEN` must be injected by the host secret/environment.
## Operations
Before deployment, back up the SQLite database. A restart must preserve published updates. Test restore by copying the backup to a temporary SQLite path and opening it with Node's `node:sqlite`.
The service intentionally has no CMS, user system, comments, or direct access to Batchmaker databases in v1.
ponytail: Keep the API contract stable; migrate to PostgreSQL/OIDC only when measured write volume or multi-author editing justifies the additional operational surface.
## Current integration boundary
The portfolio is wired as the first client. Batchmaker and Standalone still need their own UI client changes in their canonical repositories after the runtime/deployment URLs are confirmed.
No production deployment is performed by this change.
## License
Private project.
+10
View File
@@ -0,0 +1,10 @@
{
"name": "w-make-updates-api",
"private": true,
"type": "module",
"engines": { "node": ">=22.0.0" },
"scripts": {
"start": "node --disable-warning=ExperimentalWarning src/server.js",
"test": "node --test test/*.test.js"
}
}
+166
View File
@@ -0,0 +1,166 @@
import { createServer } from 'node:http';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { randomUUID } from 'node:crypto';
const PRODUCTS = new Set(['batchmaker', 'standalone']);
const STATUSES = new Set(['draft', 'published', 'archived']);
const MAX_BODY = 64 * 1024;
function now() { return new Date().toISOString(); }
function json(res, status, value, headers = {}) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers });
res.end(JSON.stringify(value));
}
function error(res, status, code, message) { json(res, status, { error: { code, message } }); }
function validProduct(product) { return typeof product === 'string' && PRODUCTS.has(product); }
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', chunk => {
data += chunk;
if (Buffer.byteLength(data) > MAX_BODY) reject(Object.assign(new Error('body too large'), { code: 'BODY_TOO_LARGE' }));
});
req.on('end', () => resolve(data));
req.on('error', reject);
});
}
function parsePayload(raw) {
if (!raw) throw Object.assign(new Error('JSON body required'), { code: 'INVALID_JSON' });
try { return JSON.parse(raw); } catch { throw Object.assign(new Error('valid JSON required'), { code: 'INVALID_JSON' }); }
}
function validatePayload(payload, partial = false) {
const fields = ['product', 'title', 'summary', 'body_markdown', 'link_url', 'published_at'];
const out = {};
for (const field of fields) if (payload[field] !== undefined) out[field] = payload[field];
if (!partial || payload.product !== undefined) {
if (!validProduct(out.product)) throw Object.assign(new Error('product must be batchmaker or standalone'), { code: 'INVALID_PRODUCT' });
}
for (const field of ['title', 'summary']) {
if (!partial || payload[field] !== undefined) {
if (typeof out[field] !== 'string' || !out[field].trim() || out[field].length > 300) throw Object.assign(new Error(`${field} is required and limited to 300 characters`), { code: 'INVALID_FIELD' });
out[field] = out[field].trim();
}
}
if (out.body_markdown !== undefined && (typeof out.body_markdown !== 'string' || out.body_markdown.length > 20_000)) throw Object.assign(new Error('body_markdown must be at most 20000 characters'), { code: 'INVALID_FIELD' });
if (out.link_url !== undefined && out.link_url !== null && (typeof out.link_url !== 'string' || !/^https?:\/\//.test(out.link_url))) throw Object.assign(new Error('link_url must be an http(s) URL'), { code: 'INVALID_FIELD' });
if (out.published_at !== undefined && out.published_at !== null && Number.isNaN(Date.parse(out.published_at))) throw Object.assign(new Error('published_at must be an ISO date'), { code: 'INVALID_FIELD' });
return out;
}
function auth(req, token) { return Boolean(token) && req.headers.authorization === `Bearer ${token}`; }
function row(row) { return row ? { ...row } : null; }
function xml(value) {
return String(value ?? '').replace(/[<>&'\"]/g, character => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '\"': '&quot;' })[character]);
}
function rss(items, baseUrl) {
const entries = items.map(item => `<item><title>${xml(item.title)}</title><link>${xml(item.link_url || `${baseUrl}/v1/updates/${encodeURIComponent(item.slug)}`)}</link><guid>${xml(item.slug)}</guid><pubDate>${new Date(item.published_at).toUTCString()}</pubDate><description>${xml(item.summary)}</description></item>`).join('');
return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>W-Make Batchmaker Updates</title><link>${xml(baseUrl)}</link><description>Updates für Batchmaker und Batchmaker Standalone</description>${entries}</channel></rss>`;
}
export function createApp({ dbPath = process.env.UPDATES_DB_PATH || './data/updates.sqlite', adminToken = process.env.UPDATES_ADMIN_TOKEN || '' } = {}) {
mkdirSync(dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
db.exec(`CREATE TABLE IF NOT EXISTS updates (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
product TEXT NOT NULL CHECK (product IN ('batchmaker', 'standalone')),
title TEXT NOT NULL,
summary TEXT NOT NULL,
body_markdown TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'archived')),
published_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
link_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_updates_public ON updates(product, status, published_at DESC);`);
function publishedFilter(product) {
return product ? { sql: 'product = ? AND status = ? AND published_at IS NOT NULL AND published_at <= ?', args: [product, 'published', now()] } : { sql: 'status = ? AND published_at IS NOT NULL AND published_at <= ?', args: ['published', now()] };
}
function getPublic(product, limit) {
const filter = publishedFilter(product);
return db.prepare(`SELECT id, slug, product, title, summary, body_markdown, published_at, created_at, updated_at, link_url FROM updates WHERE ${filter.sql} ORDER BY published_at DESC, id DESC LIMIT ?`).all(...filter.args, limit).map(row);
}
function getBySlug(slug) {
const f = publishedFilter();
return row(db.prepare(`SELECT id, slug, product, title, summary, body_markdown, published_at, created_at, updated_at, link_url FROM updates WHERE slug = ? AND ${f.sql}`).get(slug, ...f.args));
}
function adminCreate(payload) {
const fields = validatePayload(payload);
const created = now();
const slug = typeof payload.slug === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(payload.slug) ? payload.slug : `${fields.product}-${randomUUID()}`;
db.prepare('INSERT INTO updates (slug, product, title, summary, body_markdown, status, published_at, created_at, updated_at, link_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(slug, fields.product, fields.title, fields.summary, fields.body_markdown || '', 'draft', null, created, created, fields.link_url || null);
return row(db.prepare('SELECT * FROM updates WHERE slug = ?').get(slug));
}
function adminPatch(id, payload) {
const current = db.prepare('SELECT * FROM updates WHERE id = ?').get(id);
if (!current) return null;
const fields = validatePayload({ product: current.product, title: current.title, summary: current.summary, ...payload }, true);
const updated = now();
db.prepare('UPDATE updates SET product = ?, title = ?, summary = ?, body_markdown = ?, link_url = ?, updated_at = ? WHERE id = ?').run(fields.product, fields.title, fields.summary, fields.body_markdown ?? current.body_markdown, fields.link_url ?? current.link_url, updated, id);
return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id));
}
function setStatus(id, status) {
if (!STATUSES.has(status)) throw Object.assign(new Error('invalid status'), { code: 'INVALID_STATUS' });
const current = db.prepare('SELECT * FROM updates WHERE id = ?').get(id);
if (!current) return null;
const publishedAt = status === 'published' ? (current.published_at || now()) : current.published_at;
db.prepare('UPDATE updates SET status = ?, published_at = ?, updated_at = ? WHERE id = ?').run(status, publishedAt, now(), id);
return row(db.prepare('SELECT * FROM updates WHERE id = ?').get(id));
}
async function handler(req, res) {
const url = new URL(req.url, 'http://localhost');
const path = url.pathname;
try {
if (req.method === 'GET' && path === '/healthz') return json(res, 200, { status: 'ok' });
if (req.method === 'GET' && path === '/feed.xml') {
const product = url.searchParams.get('product') || null;
if (product && !validProduct(product)) return error(res, 400, 'INVALID_PRODUCT', 'product must be batchmaker or standalone');
return new Promise(resolve => {
const items = getPublic(product, 50);
res.writeHead(200, { 'content-type': 'application/rss+xml; charset=utf-8', 'cache-control': 'public, max-age=60' });
res.end(rss(items, `${url.origin}`));
resolve();
});
}
if (req.method === 'GET' && path === '/v1/updates') {
const product = url.searchParams.get('product') || null;
if (product && !validProduct(product)) return error(res, 400, 'INVALID_PRODUCT', 'product must be batchmaker or standalone');
const requested = Number(url.searchParams.get('limit') || 10);
const limit = Number.isInteger(requested) && requested >= 1 && requested <= 50 ? requested : 10;
return json(res, 200, { data: getPublic(product, limit) }, { 'cache-control': 'public, max-age=60' });
}
const slugMatch = path.match(/^\/v1\/updates\/([^/]+)$/);
if (req.method === 'GET' && slugMatch) {
const item = getBySlug(decodeURIComponent(slugMatch[1]));
return item ? json(res, 200, { data: item }, { 'cache-control': 'public, max-age=60' }) : error(res, 404, 'NOT_FOUND', 'update not found');
}
if (path.startsWith('/v1/admin/') && !auth(req, adminToken)) return error(res, 401, 'UNAUTHORIZED', 'admin authentication required');
if (req.method === 'POST' && path === '/v1/admin/updates') return json(res, 201, { data: adminCreate(parsePayload(await readBody(req))) });
const adminMatch = path.match(/^\/v1\/admin\/updates\/(\d+)(?:\/(publish|archive))?$/);
if (adminMatch && (req.method === 'PATCH' || req.method === 'POST')) {
const item = adminMatch[2] ? setStatus(Number(adminMatch[1]), adminMatch[2] === 'publish' ? 'published' : 'archived') : adminPatch(Number(adminMatch[1]), parsePayload(await readBody(req)));
return item ? json(res, 200, { data: item }) : error(res, 404, 'NOT_FOUND', 'update not found');
}
return error(res, 404, 'NOT_FOUND', 'route not found');
} catch (e) {
if (e.code === 'BODY_TOO_LARGE') return error(res, 413, e.code, e.message);
if (e.code && ['INVALID_JSON', 'INVALID_PRODUCT', 'INVALID_FIELD', 'INVALID_STATUS'].includes(e.code)) return error(res, 400, e.code, e.message);
if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return error(res, 409, 'CONFLICT', 'slug already exists');
console.error(e);
return error(res, 500, 'INTERNAL_ERROR', 'internal server error');
}
}
const server = createServer(handler);
return { server, db, close: () => { db.close(); } };
}
if (process.argv[1] && process.argv[1].endsWith('/src/server.js')) {
const app = createApp();
const port = Number(process.env.PORT || 8080);
app.server.listen(port, process.env.HOSTNAME || '0.0.0.0', () => console.log(`updates-api listening on ${port}`));
}
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createApp } from '../src/server.js';
async function setup() {
const app = createApp({ dbPath: join(mkdtempSync(join(tmpdir(), 'updates-')), 'updates.sqlite'), adminToken: 'test-token' });
await new Promise(resolve => app.server.listen(0, resolve));
const port = app.server.address().port;
const request = (path, options = {}) => fetch(`http://127.0.0.1:${port}${path}`, options);
return { app, request };
}
async function teardown(app) { await new Promise(resolve => app.server.close(resolve)); app.close(); }
const headers = { 'content-type': 'application/json', authorization: 'Bearer test-token' };
const draft = { product: 'standalone', title: 'Standalone 1', summary: 'First update', body_markdown: 'Details' };
test('public feed hides drafts until authenticated publish', async () => {
const { app, request } = await setup();
try {
const created = await request('/v1/admin/updates', { method: 'POST', headers, body: JSON.stringify(draft) });
assert.equal(created.status, 201);
const hidden = await request('/v1/updates?product=standalone');
assert.deepEqual((await hidden.json()).data, []);
const id = (await (await request('/v1/admin/updates?product=standalone')).json()).data; // route must remain private
assert.equal(id, undefined);
const unauthorized = await request('/v1/admin/updates/1/publish', { method: 'POST' });
assert.equal(unauthorized.status, 401);
const published = await request('/v1/admin/updates/1/publish', { method: 'POST', headers });
assert.equal(published.status, 200);
const visible = await request('/v1/updates?product=standalone');
assert.equal((await visible.json()).data.length, 1);
} finally { await teardown(app); }
});
test('validates product and keeps health response minimal', async () => {
const { app, request } = await setup();
try {
assert.deepEqual(await (await request('/healthz')).json(), { status: 'ok' });
const response = await request('/v1/admin/updates', { method: 'POST', headers, body: JSON.stringify({ ...draft, product: 'other' }) });
assert.equal(response.status, 400);
assert.equal((await response.json()).error.code, 'INVALID_PRODUCT');
} finally { await teardown(app); }
});
test('rejects oversized request bodies', async () => {
const { app, request } = await setup();
try {
const response = await request('/v1/admin/updates', { method: 'POST', headers, body: JSON.stringify({ ...draft, body_markdown: 'x'.repeat(70_000) }) });
assert.equal(response.status, 413);
} finally { await teardown(app); }
});