@@ -424,17 +456,18 @@ export async function render(container, { user }) {
});
}
- bindEvents(container, user, categories, icsSubscriptions);
+ bindEvents(container, user, categories, icsSubscriptions, apiTokens);
}
// --------------------------------------------------------
// Event-Binding
// --------------------------------------------------------
-function bindEvents(container, user, categories, icsSubscriptions) {
+function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
bindTabEvents(container);
bindCategoryEvents(container);
bindIcsEvents(container, user, icsSubscriptions);
+ bindApiTokenEvents(container, apiTokens);
// Theme-Toggle
const themeToggle = container.querySelector('#theme-toggle');
if (themeToggle) {
@@ -722,6 +755,109 @@ function bindDeleteButtons(container, user) {
});
}
+function apiTokenHtml(token) {
+ const status = token.revoked_at
+ ? t('settings.apiTokenRevoked')
+ : token.expires_at && new Date(token.expires_at).getTime() <= Date.now()
+ ? t('settings.apiTokenExpired')
+ : t('settings.apiTokenActive');
+ const meta = [
+ `${t('settings.apiTokenPrefix')}: ${token.token_prefix}...`,
+ token.expires_at ? `${t('settings.apiTokenExpires')}: ${formatDateTime(token.expires_at)}` : t('settings.apiTokenNeverExpires'),
+ token.last_used_at ? `${t('settings.apiTokenLastUsed')}: ${formatDateTime(token.last_used_at)}` : t('settings.apiTokenNeverUsed'),
+ status,
+ ].join(' · ');
+
+ return `
+
+
+ ${esc(token.name)}
+ ${esc(meta)}
+
+
+
+ `;
+}
+
+function renderApiTokenList(container, tokens) {
+ const list = container.querySelector('#api-token-list');
+ if (!list) return;
+ list.replaceChildren();
+ tokens.forEach((token) => {
+ const tmp = document.createElement('template');
+ tmp.innerHTML = apiTokenHtml(token);
+ list.appendChild(tmp.content.firstElementChild);
+ });
+ if (window.lucide) window.lucide.createIcons();
+}
+
+function datetimeLocalToIso(value) {
+ if (!value) return null;
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+}
+
+function bindApiTokenEvents(container, initialTokens) {
+ const form = container.querySelector('#api-token-form');
+ const list = container.querySelector('#api-token-list');
+ if (!form || !list) return;
+
+ let tokens = [...initialTokens];
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#api-token-error');
+ const output = container.querySelector('#api-token-created');
+ const outputValue = container.querySelector('#api-token-created-value');
+ errorEl.hidden = true;
+ output.hidden = true;
+
+ const name = container.querySelector('#api-token-name').value.trim();
+ const expiresValue = container.querySelector('#api-token-expires').value;
+ const expires_at = datetimeLocalToIso(expiresValue);
+ if (expiresValue && !expires_at) {
+ showError(errorEl, t('settings.apiTokenInvalidExpiration'));
+ return;
+ }
+
+ const btn = form.querySelector('[type=submit]');
+ btn.disabled = true;
+ try {
+ const res = await api.post('/auth/api-tokens', { name, expires_at });
+ tokens.unshift(res.data);
+ renderApiTokenList(container, tokens);
+ form.reset();
+ outputValue.value = res.token;
+ output.hidden = false;
+ outputValue.focus();
+ outputValue.select();
+ window.oikos?.showToast(t('settings.apiTokenCreatedToast'), 'success');
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+
+ list.addEventListener('click', async (e) => {
+ const btn = e.target.closest('[data-revoke-api-token]');
+ if (!btn) return;
+ const id = Number(btn.dataset.revokeApiToken);
+ const name = btn.dataset.name;
+ if (!await confirmModal(t('settings.apiTokenRevokeConfirm', { name }), { danger: true, confirmLabel: t('settings.apiTokenRevoke') })) return;
+ try {
+ await api.delete(`/auth/api-tokens/${id}`);
+ tokens = tokens.map((token) => token.id === id ? { ...token, revoked_at: new Date().toISOString() } : token);
+ renderApiTokenList(container, tokens);
+ window.oikos?.showToast(t('settings.apiTokenRevokedToast'), 'default');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+}
+
// --------------------------------------------------------
// Kategorie-Verwaltung
diff --git a/public/styles/settings.css b/public/styles/settings.css
index b66fecd..7bc7704 100644
--- a/public/styles/settings.css
+++ b/public/styles/settings.css
@@ -321,6 +321,13 @@
width: 100%;
}
+.settings-token-output {
+ padding: var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-2);
+}
+
/* --------------------------------------------------------
Theme-Toggle
-------------------------------------------------------- */
diff --git a/server/auth.js b/server/auth.js
index 31a62ec..3d5713a 100644
--- a/server/auth.js
+++ b/server/auth.js
@@ -8,12 +8,14 @@ import express from 'express';
import bcrypt from 'bcrypt';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
+import crypto from 'node:crypto';
import * as db from './db.js';
import { generateToken, csrfMiddleware } from './middleware/csrf.js';
import { createLogger } from './logger.js';
const log = createLogger('Auth');
const router = express.Router();
+const API_TOKEN_PREFIX = 'oikos_';
// --------------------------------------------------------
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
@@ -124,6 +126,60 @@ const loginLimiter = rateLimit({
message: { error: 'Zu viele Login-Versuche. Bitte warte kurz.', code: 429 },
});
+function hashApiToken(token) {
+ return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
+}
+
+function extractApiToken(req) {
+ const auth = req.headers.authorization || '';
+ if (auth.toLowerCase().startsWith('bearer ')) return auth.slice(7).trim();
+ return String(req.headers['x-api-key'] || '').trim();
+}
+
+function publicApiToken(row) {
+ return {
+ id: row.id,
+ name: row.name,
+ token_prefix: row.token_prefix,
+ created_by: row.created_by,
+ creator_name: row.creator_name,
+ expires_at: row.expires_at,
+ revoked_at: row.revoked_at,
+ last_used_at: row.last_used_at,
+ created_at: row.created_at,
+ };
+}
+
+function authenticateApiToken(req) {
+ const token = extractApiToken(req);
+ if (!token) return null;
+
+ const tokenHash = hashApiToken(token);
+ const row = db.get().prepare(`
+ SELECT t.*, u.role, u.username, u.display_name, u.avatar_color
+ FROM api_tokens t
+ JOIN users u ON u.id = t.created_by
+ WHERE t.token_hash = ?
+ AND t.revoked_at IS NULL
+ AND (t.expires_at IS NULL OR t.expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ `).get(tokenHash);
+ if (!row) return null;
+
+ db.get().prepare(`
+ UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?
+ `).run(row.id);
+
+ req.apiToken = publicApiToken(row);
+ req.user = {
+ id: row.created_by,
+ username: row.username,
+ display_name: row.display_name,
+ avatar_color: row.avatar_color,
+ role: row.role,
+ };
+ return row;
+}
+
// --------------------------------------------------------
// Auth-Guard Middleware
// --------------------------------------------------------
@@ -133,7 +189,18 @@ const loginLimiter = rateLimit({
* Schützt alle API-Routen außer /auth/login.
*/
function requireAuth(req, res, next) {
+ const apiToken = authenticateApiToken(req);
+ if (apiToken) {
+ req.authMethod = 'api_token';
+ req.authUserId = apiToken.created_by;
+ req.authRole = apiToken.role;
+ return next();
+ }
+
if (req.session && req.session.userId) {
+ req.authMethod = 'session';
+ req.authUserId = req.session.userId;
+ req.authRole = req.session.role;
return next();
}
res.status(401).json({ error: 'Not authenticated.', code: 401 });
@@ -143,7 +210,7 @@ function requireAuth(req, res, next) {
* Prüft ob der authentifizierte User Admin-Rolle hat.
*/
function requireAdmin(req, res, next) {
- if (req.session && req.session.role === 'admin') {
+ if (req.authRole === 'admin') {
return next();
}
res.status(403).json({ error: 'Permission denied.', code: 403 });
@@ -225,6 +292,9 @@ router.post('/login', loginLimiter, async (req, res) => {
* Response: { ok: true }
*/
router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
+ if (req.authMethod === 'api_token') {
+ return res.json({ ok: true });
+ }
req.session.destroy((err) => {
if (err) {
log.error('Logout error:', err);
@@ -293,13 +363,19 @@ router.get('/me', requireAuth, (req, res) => {
try {
const user = db.get()
.prepare('SELECT id, username, display_name, avatar_color, role FROM users WHERE id = ?')
- .get(req.session.userId);
+ .get(req.authUserId);
if (!user) {
- req.session.destroy(() => {});
+ if (req.authMethod === 'session' && typeof req.session.destroy === 'function') {
+ req.session.destroy(() => {});
+ }
return res.status(401).json({ error: 'User not found.', code: 401 });
}
+ if (req.authMethod === 'api_token') {
+ return res.json({ user });
+ }
+
// CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume:
// iOS kann den CSRF-Cookie verwerfen waehrend die Session-Cookie erhalten bleibt.
// /me ist der erste API-Call nach App-Resume, also hier den Cookie wiederherstellen.)
@@ -337,6 +413,78 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => {
}
});
+router.get('/api-tokens', requireAuth, requireAdmin, (req, res) => {
+ try {
+ const rows = db.get().prepare(`
+ SELECT t.*, u.display_name AS creator_name
+ FROM api_tokens t
+ LEFT JOIN users u ON u.id = t.created_by
+ ORDER BY t.created_at DESC
+ `).all();
+ res.json({ data: rows.map(publicApiToken) });
+ } catch (err) {
+ log.error('API token list error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
+ }
+});
+
+router.post('/api-tokens', requireAuth, requireAdmin, csrfMiddleware, (req, res) => {
+ try {
+ const name = String(req.body.name || '').trim();
+ const expiresAt = req.body.expires_at ? String(req.body.expires_at).trim() : null;
+
+ if (!name) return res.status(400).json({ error: 'Token name is required.', code: 400 });
+ if (name.length > 100) return res.status(400).json({ error: 'Token name may be at most 100 characters long.', code: 400 });
+ if (expiresAt && Number.isNaN(Date.parse(expiresAt))) {
+ return res.status(400).json({ error: 'expires_at must be a valid ISO date/time.', code: 400 });
+ }
+ if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) {
+ return res.status(400).json({ error: 'Expiration date must be in the future.', code: 400 });
+ }
+
+ const token = API_TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url');
+ const tokenHash = hashApiToken(token);
+ const tokenPrefix = token.slice(0, 12);
+ const normalizedExpiresAt = expiresAt ? new Date(expiresAt).toISOString() : null;
+
+ const result = db.get().prepare(`
+ INSERT INTO api_tokens (name, token_hash, token_prefix, created_by, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(name, tokenHash, tokenPrefix, req.authUserId, normalizedExpiresAt);
+
+ const row = db.get().prepare(`
+ SELECT t.*, u.display_name AS creator_name
+ FROM api_tokens t
+ LEFT JOIN users u ON u.id = t.created_by
+ WHERE t.id = ?
+ `).get(result.lastInsertRowid);
+
+ res.status(201).json({ data: publicApiToken(row), token });
+ } catch (err) {
+ log.error('API token creation error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
+ }
+});
+
+router.delete('/api-tokens/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid token ID.', code: 400 });
+
+ const result = db.get().prepare(`
+ UPDATE api_tokens
+ SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ WHERE id = ?
+ `).run(id);
+
+ if (result.changes === 0) return res.status(404).json({ error: 'API token not found.', code: 404 });
+ res.json({ ok: true });
+ } catch (err) {
+ log.error('API token revocation error:', err);
+ res.status(500).json({ error: 'Internal server error.', code: 500 });
+ }
+});
+
/**
* POST /api/v1/auth/users
* Admin only. Erstellt neues Familienmitglied.
@@ -405,14 +553,14 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => {
return res.status(400).json({ error: 'New password must be at least 8 characters long.', code: 400 });
}
- const user = db.get().prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.userId);
+ const user = db.get().prepare('SELECT password_hash FROM users WHERE id = ?').get(req.authUserId);
if (!user) return res.status(404).json({ error: 'User not found.', code: 404 });
const valid = await bcrypt.compare(current_password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Current password is incorrect.', code: 401 });
const hash = await bcrypt.hash(new_password, 12);
- db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId);
+ db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.authUserId);
// Alle anderen Sessions dieses Users invalidieren (aktuelle behalten)
const currentSid = req.sessionID;
@@ -421,7 +569,7 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => {
if (row.sid === currentSid) continue;
try {
const sess = JSON.parse(row.sess);
- if (sess.userId === req.session.userId) {
+ if (sess.userId === req.authUserId) {
db.get().prepare('DELETE FROM sessions WHERE sid = ?').run(row.sid);
}
} catch { /* ignore malformed session */ }
@@ -443,7 +591,7 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res
try {
const userId = parseInt(req.params.id, 10);
- if (userId === req.session.userId) {
+ if (userId === req.authUserId) {
return res.status(400).json({ error: 'You cannot delete your own account.', code: 400 });
}
diff --git a/server/db-schema-test.js b/server/db-schema-test.js
index e34e67c..981dde1 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -144,6 +144,17 @@ const MIGRATIONS_SQL = {
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(category_key, name)
);
+ CREATE TABLE IF NOT EXISTS api_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ token_prefix TEXT NOT NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TEXT,
+ revoked_at TEXT,
+ last_used_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
AFTER UPDATE ON users FOR EACH ROW
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
@@ -185,6 +196,8 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned);
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
`,
2: `
CREATE TABLE IF NOT EXISTS sync_config (
diff --git a/server/db.js b/server/db.js
index 4ef2853..57253e1 100644
--- a/server/db.js
+++ b/server/db.js
@@ -671,6 +671,26 @@ const MIGRATIONS = [
GROUP BY category, subcategory;
`,
},
+ {
+ version: 17,
+ description: 'API tokens for non-interactive authentication',
+ up: `
+ CREATE TABLE IF NOT EXISTS api_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ token_prefix TEXT NOT NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TEXT,
+ revoked_at TEXT,
+ last_used_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
+ `,
+ },
];
/**
diff --git a/server/index.js b/server/index.js
index 120d75b..d09af0e 100644
--- a/server/index.js
+++ b/server/index.js
@@ -13,6 +13,7 @@ import { createLogger } from './logger.js';
import * as db from './db.js';
import { router as authRouter, sessionMiddleware, requireAuth } from './auth.js';
import { csrfMiddleware } from './middleware/csrf.js';
+import { buildOpenApiSpec } from './openapi.js';
import * as googleCalendar from './services/google-calendar.js';
import * as appleCalendar from './services/apple-calendar.js';
import * as icsSubscription from './services/ics-subscription.js';
@@ -166,6 +167,16 @@ app.get('/api/v1/version', (req, res) => {
res.json({ version: APP_VERSION });
});
+function sendOpenApi(req, res) {
+ if (req.query.download === '1') {
+ res.setHeader('Content-Disposition', 'attachment; filename="openapi.json"');
+ }
+ res.json(buildOpenApiSpec(req, APP_VERSION));
+}
+
+app.get('/api/v1/openapi.json', sendOpenApi);
+app.get('/openapi.json', sendOpenApi);
+
// Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz
app.use('/api/v1', requireAuth);
app.use('/api/v1', csrfMiddleware);
diff --git a/server/logger.js b/server/logger.js
index f1ccc29..a05b325 100644
--- a/server/logger.js
+++ b/server/logger.js
@@ -12,14 +12,22 @@ const isProduction = process.env.NODE_ENV === 'production';
function emit(level, mod, msg, extra) {
if (LEVELS[level] < currentLevel) return;
+ const normalizedExtra = extra instanceof Error
+ ? {
+ name: extra.name,
+ message: extra.message,
+ stack: extra.stack,
+ }
+ : extra;
+
if (isProduction) {
const entry = { ts: new Date().toISOString(), level, mod, msg };
- if (extra !== undefined) entry.extra = extra;
+ if (normalizedExtra !== undefined) entry.extra = normalizedExtra;
process.stdout.write(JSON.stringify(entry) + '\n');
} else {
const prefix = `[${mod}]`;
- if (extra !== undefined) {
- console[level === 'debug' ? 'log' : level](prefix, msg, extra);
+ if (normalizedExtra !== undefined) {
+ console[level === 'debug' ? 'log' : level](prefix, msg, normalizedExtra);
} else {
console[level === 'debug' ? 'log' : level](prefix, msg);
}
diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js
index 82ea63b..35f301d 100644
--- a/server/middleware/csrf.js
+++ b/server/middleware/csrf.js
@@ -28,6 +28,8 @@ function generateToken() {
* Muss NACH requireAuth eingebunden werden.
*/
function csrfMiddleware(req, res, next) {
+ if (req.authMethod === 'api_token') return next();
+
// Token generieren falls noch nicht vorhanden (erste Request nach Login)
if (!req.session.csrfToken) {
req.session.csrfToken = generateToken();
diff --git a/server/openapi.js b/server/openapi.js
new file mode 100644
index 0000000..b9d8039
--- /dev/null
+++ b/server/openapi.js
@@ -0,0 +1,607 @@
+function authSecurity() {
+ return [{ bearerAuth: [] }, { apiKeyAuth: [] }, { cookieAuth: [] }];
+}
+
+function csrfHeaderParam() {
+ return {
+ name: 'X-CSRF-Token',
+ in: 'header',
+ required: false,
+ description: 'Required for state-changing requests when using session/cookie authentication. Not required for API-token authentication.',
+ schema: { type: 'string' },
+ };
+}
+
+function jsonBody(schemaRef, description = 'JSON request body') {
+ return {
+ required: true,
+ description,
+ content: {
+ 'application/json': {
+ schema: schemaRef ? { $ref: schemaRef } : { type: 'object', additionalProperties: true },
+ },
+ },
+ };
+}
+
+function op({
+ summary,
+ tag,
+ description,
+ auth = true,
+ admin = false,
+ params = [],
+ requestBody = null,
+ responses = null,
+ stateChanging = false,
+}) {
+ const operation = {
+ tags: [tag],
+ summary,
+ responses: responses ?? {
+ 200: { description: 'Successful response' },
+ 401: { $ref: '#/components/responses/Unauthorized' },
+ 500: { $ref: '#/components/responses/InternalServerError' },
+ },
+ };
+
+ if (description) operation.description = description;
+ if (auth) operation.security = authSecurity();
+ if (admin) {
+ operation.description = `${operation.description ? `${operation.description}\n\n` : ''}Admin-only endpoint.`;
+ operation.responses[403] = { $ref: '#/components/responses/Forbidden' };
+ }
+ if (params.length || stateChanging) {
+ operation.parameters = [...params];
+ if (stateChanging) operation.parameters.push(csrfHeaderParam());
+ }
+ if (requestBody) operation.requestBody = requestBody;
+ return operation;
+}
+
+function idParam(name = 'id', description = 'Resource ID') {
+ return {
+ name,
+ in: 'path',
+ required: true,
+ description,
+ schema: { type: 'integer' },
+ };
+}
+
+function langParam() {
+ return {
+ name: 'lang',
+ in: 'query',
+ required: false,
+ description: 'Language code for localized labels. Supported values: ar, de, el, en, es, fr, hi, it, ja, pt, ru, sv, tr, uk, zh. Defaults to en.',
+ schema: {
+ type: 'string',
+ default: 'en',
+ enum: ['ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'pt', 'ru', 'sv', 'tr', 'uk', 'zh'],
+ },
+ };
+}
+
+function buildPaths() {
+ return {
+ '/health': {
+ get: op({
+ summary: 'Health check',
+ tag: 'System',
+ auth: false,
+ responses: {
+ 200: {
+ description: 'Service health status',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/HealthResponse' } } },
+ },
+ },
+ }),
+ },
+ '/api/v1/version': {
+ get: op({
+ summary: 'Get application version',
+ tag: 'System',
+ auth: false,
+ responses: {
+ 200: {
+ description: 'Application version',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionResponse' } } },
+ },
+ },
+ }),
+ },
+ '/api/v1/openapi.json': {
+ get: op({
+ summary: 'Get OpenAPI specification',
+ tag: 'System',
+ auth: false,
+ description: 'Use `?download=1` to receive the OpenAPI document as a downloadable file.',
+ }),
+ },
+ '/openapi.json': {
+ get: op({
+ summary: 'Get OpenAPI specification',
+ tag: 'System',
+ auth: false,
+ description: 'Alias for `/api/v1/openapi.json`. Use `?download=1` to download the JSON file.',
+ }),
+ },
+ '/docs': {
+ get: op({
+ summary: 'Swagger UI documentation',
+ tag: 'System',
+ auth: false,
+ responses: { 200: { description: 'Swagger UI HTML page' } },
+ }),
+ },
+ '/api/v1/auth/login': {
+ post: op({
+ summary: 'Login with username and password',
+ tag: 'Auth',
+ auth: false,
+ requestBody: jsonBody('#/components/schemas/LoginRequest'),
+ responses: {
+ 200: {
+ description: 'Authenticated user and CSRF token',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
+ },
+ 401: { $ref: '#/components/responses/Unauthorized' },
+ },
+ }),
+ },
+ '/api/v1/auth/logout': {
+ post: op({ summary: 'Logout current session', tag: 'Auth', stateChanging: true }),
+ },
+ '/api/v1/auth/setup': {
+ post: op({
+ summary: 'Initial setup: create first admin',
+ tag: 'Auth',
+ auth: false,
+ requestBody: jsonBody('#/components/schemas/SetupRequest'),
+ responses: {
+ 201: { description: 'Admin user created' },
+ 403: { $ref: '#/components/responses/Forbidden' },
+ 409: { description: 'Username already taken' },
+ },
+ }),
+ },
+ '/api/v1/auth/me': {
+ get: op({
+ summary: 'Get current authenticated user',
+ tag: 'Auth',
+ responses: {
+ 200: {
+ description: 'Current user',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/MeResponse' } } },
+ },
+ 401: { $ref: '#/components/responses/Unauthorized' },
+ },
+ }),
+ },
+ '/api/v1/auth/me/password': {
+ patch: op({
+ summary: 'Change current user password',
+ tag: 'Auth',
+ stateChanging: true,
+ requestBody: jsonBody('#/components/schemas/PasswordChangeRequest'),
+ }),
+ },
+ '/api/v1/auth/users': {
+ get: op({ summary: 'List users', tag: 'Auth', admin: true }),
+ post: op({
+ summary: 'Create user',
+ tag: 'Auth',
+ admin: true,
+ stateChanging: true,
+ requestBody: jsonBody('#/components/schemas/UserCreateRequest'),
+ responses: {
+ 201: { description: 'User created' },
+ 400: { $ref: '#/components/responses/BadRequest' },
+ 403: { $ref: '#/components/responses/Forbidden' },
+ 409: { description: 'Username already taken' },
+ 500: { $ref: '#/components/responses/InternalServerError' },
+ },
+ }),
+ },
+ '/api/v1/auth/users/{id}': {
+ delete: op({
+ summary: 'Delete user',
+ tag: 'Auth',
+ admin: true,
+ stateChanging: true,
+ params: [idParam('id', 'User ID')],
+ }),
+ },
+ '/api/v1/auth/api-tokens': {
+ get: op({ summary: 'List API tokens', tag: 'Auth', admin: true }),
+ post: op({
+ summary: 'Create API token',
+ tag: 'Auth',
+ admin: true,
+ stateChanging: true,
+ requestBody: jsonBody('#/components/schemas/ApiTokenCreateRequest'),
+ responses: {
+ 201: {
+ description: 'API token created. The plaintext token is returned only once.',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiTokenCreateResponse' } } },
+ },
+ 400: { $ref: '#/components/responses/BadRequest' },
+ 403: { $ref: '#/components/responses/Forbidden' },
+ 500: { $ref: '#/components/responses/InternalServerError' },
+ },
+ }),
+ },
+ '/api/v1/auth/api-tokens/{id}': {
+ delete: op({
+ summary: 'Revoke API token',
+ tag: 'Auth',
+ admin: true,
+ stateChanging: true,
+ params: [idParam('id', 'API token ID')],
+ }),
+ },
+ '/api/v1/dashboard': { get: op({ summary: 'Get dashboard data', tag: 'Dashboard' }) },
+ '/api/v1/tasks': {
+ get: op({ summary: 'List tasks', tag: 'Tasks' }),
+ post: op({ summary: 'Create task', tag: 'Tasks', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/tasks/meta/options': { get: op({ summary: 'Get task metadata', tag: 'Tasks' }) },
+ '/api/v1/tasks/{id}': {
+ get: op({ summary: 'Get task', tag: 'Tasks', params: [idParam()] }),
+ put: op({ summary: 'Update task', tag: 'Tasks', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete task', tag: 'Tasks', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/tasks/{id}/status': {
+ patch: op({ summary: 'Update task status', tag: 'Tasks', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/shopping': {
+ get: op({ summary: 'List shopping lists', tag: 'Shopping' }),
+ post: op({ summary: 'Create shopping list', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/shopping/categories': {
+ get: op({ summary: 'List shopping categories', tag: 'Shopping' }),
+ post: op({ summary: 'Create shopping category', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/shopping/categories/{catId}': {
+ put: op({ summary: 'Update shopping category', tag: 'Shopping', params: [idParam('catId', 'Category ID')], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete shopping category', tag: 'Shopping', params: [idParam('catId', 'Category ID')], stateChanging: true }),
+ },
+ '/api/v1/shopping/categories/reorder': {
+ patch: op({ summary: 'Reorder shopping categories', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/shopping/suggestions': { get: op({ summary: 'Get shopping suggestions', tag: 'Shopping' }) },
+ '/api/v1/shopping/items/{itemId}': {
+ patch: op({ summary: 'Update shopping item', tag: 'Shopping', params: [idParam('itemId', 'Item ID')], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete shopping item', tag: 'Shopping', params: [idParam('itemId', 'Item ID')], stateChanging: true }),
+ },
+ '/api/v1/shopping/{listId}': {
+ put: op({ summary: 'Rename shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true }),
+ },
+ '/api/v1/shopping/{listId}/items': {
+ get: op({ summary: 'List items in shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')] }),
+ post: op({ summary: 'Add item to shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/shopping/{listId}/items/checked': {
+ delete: op({ summary: 'Delete checked shopping items', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true }),
+ },
+ '/api/v1/meals': {
+ get: op({ summary: 'List meal plan entries', tag: 'Meals' }),
+ post: op({ summary: 'Create meal plan entry', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/meals/suggestions': { get: op({ summary: 'Get meal suggestions', tag: 'Meals' }) },
+ '/api/v1/meals/{id}': {
+ put: op({ summary: 'Update meal plan entry', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete meal plan entry', tag: 'Meals', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/meals/{id}/ingredients': {
+ post: op({ summary: 'Add meal ingredient', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/meals/ingredients/{ingId}': {
+ patch: op({ summary: 'Update meal ingredient', tag: 'Meals', params: [idParam('ingId', 'Ingredient ID')], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete meal ingredient', tag: 'Meals', params: [idParam('ingId', 'Ingredient ID')], stateChanging: true }),
+ },
+ '/api/v1/meals/{id}/to-shopping-list': {
+ post: op({ summary: 'Transfer meal ingredients to shopping list', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/meals/week-to-shopping-list': {
+ post: op({ summary: 'Transfer weekly meal ingredients to shopping list', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/recipes': {
+ get: op({ summary: 'List recipes', tag: 'Recipes' }),
+ post: op({ summary: 'Create recipe', tag: 'Recipes', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/recipes/{id}': {
+ put: op({ summary: 'Update recipe', tag: 'Recipes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete recipe', tag: 'Recipes', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/calendar': {
+ get: op({ summary: 'List calendar events', tag: 'Calendar' }),
+ post: op({ summary: 'Create calendar event', tag: 'Calendar', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/calendar/upcoming': { get: op({ summary: 'List upcoming events', tag: 'Calendar' }) },
+ '/api/v1/calendar/google/auth': { get: op({ summary: 'Start Google Calendar OAuth', tag: 'Calendar', admin: true }) },
+ '/api/v1/calendar/google/callback': { get: op({ summary: 'Google Calendar OAuth callback', tag: 'Calendar', auth: false }) },
+ '/api/v1/calendar/google/sync': { post: op({ summary: 'Run Google Calendar sync', tag: 'Calendar', admin: true, stateChanging: true }) },
+ '/api/v1/calendar/google/status': { get: op({ summary: 'Get Google Calendar status', tag: 'Calendar' }) },
+ '/api/v1/calendar/google/disconnect': { delete: op({ summary: 'Disconnect Google Calendar', tag: 'Calendar', admin: true, stateChanging: true }) },
+ '/api/v1/calendar/apple/status': { get: op({ summary: 'Get Apple Calendar status', tag: 'Calendar' }) },
+ '/api/v1/calendar/apple/sync': { post: op({ summary: 'Run Apple Calendar sync', tag: 'Calendar', admin: true, stateChanging: true }) },
+ '/api/v1/calendar/apple/connect': { post: op({ summary: 'Connect Apple Calendar', tag: 'Calendar', admin: true, stateChanging: true, requestBody: jsonBody(null) }) },
+ '/api/v1/calendar/apple/disconnect': { delete: op({ summary: 'Disconnect Apple Calendar', tag: 'Calendar', admin: true, stateChanging: true }) },
+ '/api/v1/calendar/subscriptions': {
+ get: op({ summary: 'List ICS subscriptions', tag: 'Calendar' }),
+ post: op({ summary: 'Create ICS subscription', tag: 'Calendar', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/calendar/subscriptions/{id}': {
+ patch: op({ summary: 'Update ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/calendar/subscriptions/{id}/sync': {
+ post: op({ summary: 'Sync ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/calendar/{id}': {
+ get: op({ summary: 'Get calendar event', tag: 'Calendar', params: [idParam()] }),
+ put: op({ summary: 'Update calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/calendar/{id}/reset': {
+ post: op({ summary: 'Reset external calendar event to source state', tag: 'Calendar', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/notes': {
+ get: op({ summary: 'List notes', tag: 'Notes' }),
+ post: op({ summary: 'Create note', tag: 'Notes', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/notes/{id}': {
+ put: op({ summary: 'Update note', tag: 'Notes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete note', tag: 'Notes', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/notes/{id}/pin': {
+ patch: op({ summary: 'Toggle note pin state', tag: 'Notes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/contacts': {
+ get: op({ summary: 'List contacts', tag: 'Contacts' }),
+ post: op({ summary: 'Create contact', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) },
+ '/api/v1/contacts/{id}': {
+ put: op({ summary: 'Update contact', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) },
+ '/api/v1/budget/summary': { get: op({ summary: 'Get budget summary', tag: 'Budget' }) },
+ '/api/v1/budget/export': { get: op({ summary: 'Export budget entries as CSV', tag: 'Budget' }) },
+ '/api/v1/budget/meta': { get: op({ summary: 'Get budget categories and subcategories', tag: 'Budget' }) },
+ '/api/v1/budget/categories': {
+ get: op({ summary: 'List budget categories', tag: 'Budget', params: [langParam()] }),
+ post: op({ summary: 'Create budget category', tag: 'Budget', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/budget/categories/{categoryKey}/subcategories': {
+ get: op({ summary: 'List subcategories for a budget category', tag: 'Budget', params: [{ name: 'categoryKey', in: 'path', required: true, schema: { type: 'string' } }, langParam()] }),
+ post: op({ summary: 'Create budget subcategory', tag: 'Budget', params: [{ name: 'categoryKey', in: 'path', required: true, schema: { type: 'string' } }], stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/budget': {
+ get: op({ summary: 'List budget entries', tag: 'Budget' }),
+ post: op({ summary: 'Create budget entry', tag: 'Budget', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/budget/{id}': {
+ put: op({ summary: 'Update budget entry', tag: 'Budget', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete budget entry', tag: 'Budget', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/weather': { get: op({ summary: 'Get weather data', tag: 'Weather' }) },
+ '/api/v1/weather/icon/{code}': {
+ get: op({ summary: 'Get weather icon asset', tag: 'Weather', params: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }] }),
+ },
+ '/api/v1/preferences': {
+ get: op({ summary: 'Get user preferences', tag: 'Preferences' }),
+ put: op({ summary: 'Update user preferences', tag: 'Preferences', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/reminders/pending': { get: op({ summary: 'List pending reminders', tag: 'Reminders' }) },
+ '/api/v1/reminders': {
+ get: op({ summary: 'List reminders', tag: 'Reminders' }),
+ post: op({ summary: 'Create reminder', tag: 'Reminders', stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete reminders by filter', tag: 'Reminders', stateChanging: true }),
+ },
+ '/api/v1/reminders/{id}/dismiss': {
+ patch: op({ summary: 'Dismiss reminder', tag: 'Reminders', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/reminders/{id}': {
+ delete: op({ summary: 'Delete reminder', tag: 'Reminders', params: [idParam()], stateChanging: true }),
+ },
+ '/api/v1/search': { get: op({ summary: 'Search across modules', tag: 'Search' }) },
+ };
+}
+
+function buildOpenApiSpec(req, appVersion) {
+ const origin = `${req.protocol}://${req.get('host')}`;
+
+ return {
+ openapi: '3.1.0',
+ info: {
+ title: 'Oikos API',
+ version: appVersion,
+ description: 'OpenAPI documentation for the Oikos family organizer backend.',
+ },
+ servers: [
+ { url: origin, description: 'Current server' },
+ ],
+ tags: [
+ { name: 'System' },
+ { name: 'Auth' },
+ { name: 'Dashboard' },
+ { name: 'Tasks' },
+ { name: 'Shopping' },
+ { name: 'Meals' },
+ { name: 'Recipes' },
+ { name: 'Calendar' },
+ { name: 'Notes' },
+ { name: 'Contacts' },
+ { name: 'Budget' },
+ { name: 'Weather' },
+ { name: 'Preferences' },
+ { name: 'Reminders' },
+ { name: 'Search' },
+ ],
+ paths: buildPaths(),
+ components: {
+ securitySchemes: {
+ bearerAuth: {
+ type: 'http',
+ scheme: 'bearer',
+ description: 'API token sent in the Authorization header as `Bearer
`.',
+ },
+ apiKeyAuth: {
+ type: 'apiKey',
+ in: 'header',
+ name: 'X-API-Key',
+ description: 'API token sent in the `X-API-Key` header.',
+ },
+ cookieAuth: {
+ type: 'apiKey',
+ in: 'cookie',
+ name: 'oikos.sid',
+ description: 'Browser session cookie. State-changing requests also require `X-CSRF-Token`.',
+ },
+ },
+ responses: {
+ BadRequest: {
+ description: 'Bad request',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ Unauthorized: {
+ description: 'Authentication required or invalid credentials/token',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ Forbidden: {
+ description: 'Permission denied',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ InternalServerError: {
+ description: 'Internal server error',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ schemas: {
+ ApiError: {
+ type: 'object',
+ properties: {
+ error: { type: 'string' },
+ code: { type: 'integer' },
+ },
+ },
+ HealthResponse: {
+ type: 'object',
+ properties: {
+ status: { type: 'string', example: 'ok' },
+ timestamp: { type: 'string', format: 'date-time' },
+ },
+ required: ['status', 'timestamp'],
+ },
+ VersionResponse: {
+ type: 'object',
+ properties: {
+ version: { type: 'string' },
+ },
+ required: ['version'],
+ },
+ User: {
+ type: 'object',
+ properties: {
+ id: { type: 'integer' },
+ username: { type: 'string' },
+ display_name: { type: 'string' },
+ avatar_color: { type: 'string' },
+ role: { type: 'string', enum: ['admin', 'member'] },
+ },
+ required: ['id', 'username', 'display_name', 'avatar_color', 'role'],
+ },
+ LoginRequest: {
+ type: 'object',
+ properties: {
+ username: { type: 'string' },
+ password: { type: 'string' },
+ },
+ required: ['username', 'password'],
+ },
+ LoginResponse: {
+ type: 'object',
+ properties: {
+ user: { $ref: '#/components/schemas/User' },
+ csrfToken: { type: 'string' },
+ },
+ required: ['user', 'csrfToken'],
+ },
+ MeResponse: {
+ type: 'object',
+ properties: {
+ user: { $ref: '#/components/schemas/User' },
+ csrfToken: { type: 'string' },
+ },
+ required: ['user'],
+ },
+ SetupRequest: {
+ type: 'object',
+ properties: {
+ username: { type: 'string' },
+ display_name: { type: 'string' },
+ password: { type: 'string' },
+ },
+ required: ['username', 'display_name', 'password'],
+ },
+ PasswordChangeRequest: {
+ type: 'object',
+ properties: {
+ currentPassword: { type: 'string' },
+ newPassword: { type: 'string' },
+ },
+ required: ['currentPassword', 'newPassword'],
+ },
+ UserCreateRequest: {
+ type: 'object',
+ properties: {
+ username: { type: 'string' },
+ display_name: { type: 'string' },
+ password: { type: 'string' },
+ avatar_color: { type: 'string' },
+ role: { type: 'string', enum: ['admin', 'member'] },
+ },
+ required: ['username', 'display_name', 'password'],
+ },
+ ApiToken: {
+ type: 'object',
+ properties: {
+ id: { type: 'integer' },
+ name: { type: 'string' },
+ token_prefix: { type: 'string' },
+ created_by: { type: 'integer' },
+ creator_name: { type: 'string' },
+ expires_at: { type: ['string', 'null'], format: 'date-time' },
+ revoked_at: { type: ['string', 'null'], format: 'date-time' },
+ last_used_at: { type: ['string', 'null'], format: 'date-time' },
+ created_at: { type: 'string', format: 'date-time' },
+ },
+ required: ['id', 'name', 'token_prefix', 'created_by', 'created_at'],
+ },
+ ApiTokenCreateRequest: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ expires_at: { type: ['string', 'null'], format: 'date-time' },
+ },
+ required: ['name'],
+ },
+ ApiTokenCreateResponse: {
+ type: 'object',
+ properties: {
+ data: { $ref: '#/components/schemas/ApiToken' },
+ token: { type: 'string' },
+ },
+ required: ['data', 'token'],
+ },
+ },
+ },
+ };
+}
+
+export { buildOpenApiSpec };
diff --git a/server/routes/budget.js b/server/routes/budget.js
index 304b3df..36c7386 100644
--- a/server/routes/budget.js
+++ b/server/routes/budget.js
@@ -6,12 +6,101 @@
import { createLogger } from '../logger.js';
import express from 'express';
+import { readFileSync } from 'node:fs';
+import path from 'path';
import * as db from '../db.js';
import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js';
const log = createLogger('Budget');
const router = express.Router();
+const LOCALE_CACHE = new Map();
+const SUPPORTED_LANGS = new Set(['ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'pt', 'ru', 'sv', 'tr', 'uk', 'zh']);
+const CATEGORY_LABEL_KEYS = {
+ housing: 'catHousing',
+ food: 'catFood',
+ transport: 'catTransport',
+ personal_health: 'catPersonalHealth',
+ leisure: 'catLeisure',
+ shopping_clothing: 'catShoppingClothing',
+ education: 'catEducation',
+ financial_other: 'catFinancialOther',
+ 'Erwerbseinkommen': 'catEarnedIncome',
+ 'Kapitalerträge': 'catInvestmentIncome',
+ 'Geschenke & Transfers': 'catTransferGiftIncome',
+ 'Sozialleistungen': 'catGovernmentBenefits',
+ 'Sonstiges Einkommen': 'catOtherIncome',
+};
+const SUBCATEGORY_LABEL_KEYS = {
+ rent_mortgage: 'subcatRentMortgage',
+ condominium: 'subcatCondominium',
+ utilities: 'subcatUtilities',
+ internet_tv_phone: 'subcatInternetTvPhone',
+ renovation_maintenance: 'subcatRenovationMaintenance',
+ cleaning: 'subcatCleaning',
+ groceries: 'subcatGroceries',
+ restaurants_bars: 'subcatRestaurantsBars',
+ snacks_fast_food: 'subcatSnacksFastFood',
+ bakery: 'subcatBakery',
+ fuel: 'subcatFuel',
+ parking_tolls: 'subcatParkingTolls',
+ public_transport: 'subcatPublicTransport',
+ apps_taxi: 'subcatAppsTaxi',
+ maintenance_insurance: 'subcatMaintenanceInsurance',
+ pharmacy: 'subcatPharmacy',
+ health_insurance: 'subcatHealthInsurance',
+ gym_sports: 'subcatGymSports',
+ beauty_cosmetics: 'subcatBeautyCosmetics',
+ travel: 'subcatTravel',
+ streaming: 'subcatStreaming',
+ events: 'subcatEvents',
+ hobbies: 'subcatHobbies',
+ clothes_shoes: 'subcatClothesShoes',
+ electronics: 'subcatElectronics',
+ gifts: 'subcatGifts',
+ courses_college: 'subcatCoursesCollege',
+ school_supplies: 'subcatSchoolSupplies',
+ languages: 'subcatLanguages',
+ loans_interest: 'subcatLoansInterest',
+ bank_fees: 'subcatBankFees',
+ insurance_other: 'subcatInsuranceOther',
+ investments: 'subcatInvestments',
+ taxes: 'subcatTaxes',
+};
+
+function normalizeLang(raw) {
+ const lang = String(raw || 'en').trim().toLowerCase();
+ const base = lang.split(/[-_]/)[0];
+ return SUPPORTED_LANGS.has(base) ? base : 'en';
+}
+
+function budgetMessages(lang) {
+ const normalized = normalizeLang(lang);
+ if (!LOCALE_CACHE.has(normalized)) {
+ const localePath = path.join(import.meta.dirname, '..', '..', 'public', 'locales', `${normalized}.json`);
+ const parsed = JSON.parse(readFileSync(localePath, 'utf-8'));
+ LOCALE_CACHE.set(normalized, parsed.budget || {});
+ }
+ return LOCALE_CACHE.get(normalized);
+}
+
+function localizedCategory(category, lang) {
+ const budget = budgetMessages(lang);
+ const labelKey = CATEGORY_LABEL_KEYS[category.key];
+ return {
+ ...category,
+ label: labelKey ? (budget[labelKey] || category.name) : category.name,
+ };
+}
+
+function localizedSubcategory(subcategory, lang) {
+ const budget = budgetMessages(lang);
+ const labelKey = SUBCATEGORY_LABEL_KEYS[subcategory.key];
+ return {
+ ...subcategory,
+ label: labelKey ? (budget[labelKey] || subcategory.name) : subcategory.name,
+ };
+}
// --------------------------------------------------------
// Wiederkehrende Einträge: fehlende Instanzen für einen Monat erzeugen
@@ -255,6 +344,53 @@ router.get('/meta', (req, res) => {
res.json({ data: loadBudgetMeta() });
});
+router.get('/categories', (req, res) => {
+ try {
+ const lang = normalizeLang(req.query.lang);
+ const categories = db.get().prepare(`
+ SELECT key, name, type, sort_order
+ FROM budget_categories
+ ORDER BY type DESC, sort_order ASC, name COLLATE NOCASE ASC
+ `).all();
+
+ res.json({
+ data: categories.map((category) => localizedCategory(category, lang)),
+ lang,
+ });
+ } catch (err) {
+ log.error('GET /categories error:', err);
+ res.status(500).json({ error: 'Internal error', code: 500 });
+ }
+});
+
+router.get('/categories/:categoryKey/subcategories', (req, res) => {
+ try {
+ const lang = normalizeLang(req.query.lang);
+ const category = db.get().prepare(`
+ SELECT key, name, type, sort_order
+ FROM budget_categories
+ WHERE key = ?
+ `).get(req.params.categoryKey);
+ if (!category) return res.status(404).json({ error: 'Category not found.', code: 404 });
+
+ const subcategories = db.get().prepare(`
+ SELECT key, category_key, name, sort_order
+ FROM budget_subcategories
+ WHERE category_key = ?
+ ORDER BY sort_order ASC, name COLLATE NOCASE ASC
+ `).all(category.key);
+
+ res.json({
+ data: subcategories.map((subcategory) => localizedSubcategory(subcategory, lang)),
+ category: localizedCategory(category, lang),
+ lang,
+ });
+ } catch (err) {
+ log.error('GET /categories/:categoryKey/subcategories error:', err);
+ res.status(500).json({ error: 'Internal error', code: 500 });
+ }
+});
+
router.post('/categories', (req, res) => {
try {
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
diff --git a/test-db.js b/test-db.js
index ac11f83..0ab8a51 100644
--- a/test-db.js
+++ b/test-db.js
@@ -74,6 +74,7 @@ const EXPECTED_TABLES = [
'users', 'tasks', 'shopping_lists', 'shopping_items',
'meals', 'meal_ingredients', 'calendar_events',
'notes', 'contacts', 'budget_entries',
+ 'budget_categories', 'budget_subcategories', 'api_tokens',
];
EXPECTED_TABLES.forEach((table) => {
@@ -88,9 +89,18 @@ EXPECTED_TABLES.forEach((table) => {
// --------------------------------------------------------
// Test 4: Alle updated_at-Triggers vorhanden
// --------------------------------------------------------
-const EXPECTED_TRIGGERS = EXPECTED_TABLES.filter((t) => t !== 'schema_migrations').map(
- (t) => `trg_${t}_updated_at`
-);
+const EXPECTED_TRIGGERS = [
+ 'users',
+ 'tasks',
+ 'shopping_lists',
+ 'shopping_items',
+ 'meals',
+ 'meal_ingredients',
+ 'calendar_events',
+ 'notes',
+ 'contacts',
+ 'budget_entries',
+].map((t) => `trg_${t}_updated_at`);
EXPECTED_TRIGGERS.forEach((trigger) => {
test(`Trigger "${trigger}" existiert`, () => {
@@ -179,6 +189,17 @@ test('Idempotenz: Migration zweimal ausführen ändert nichts', () => {
assert(tables.n > 0, 'Tabellen sollten noch vorhanden sein');
});
+test('API-Token anlegen und lesen', () => {
+ const result = db.prepare(`
+ INSERT INTO api_tokens (name, token_hash, token_prefix, created_by, expires_at)
+ VALUES ('MCP integration', 'hash-123', 'oikos_abc123', 1, '2026-12-31T23:59:59.000Z')
+ `).run();
+ const token = db.prepare('SELECT * FROM api_tokens WHERE id = ?').get(result.lastInsertRowid);
+ assert(token.name === 'MCP integration', 'Token name stimmt nicht');
+ assert(token.created_by === 1, 'Token creator stimmt nicht');
+ assert(token.revoked_at === null, 'Token sollte nicht widerrufen sein');
+});
+
// --------------------------------------------------------
// Ergebnis
// --------------------------------------------------------