diff --git a/public/doc-assets/swagger-init.js b/public/doc-assets/swagger-init.js
new file mode 100644
index 0000000..75d47d8
--- /dev/null
+++ b/public/doc-assets/swagger-init.js
@@ -0,0 +1,11 @@
+window.addEventListener('DOMContentLoaded', () => {
+ window.ui = window.SwaggerUIBundle({
+ url: '/openapi.json',
+ dom_id: '#swagger-ui',
+ deepLinking: true,
+ docExpansion: 'list',
+ persistAuthorization: true,
+ displayRequestDuration: true,
+ filter: true,
+ });
+});
diff --git a/public/doc-assets/swagger.html b/public/doc-assets/swagger.html
new file mode 100644
index 0000000..0e9abd9
--- /dev/null
+++ b/public/doc-assets/swagger.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Oikos API Docs
+
+
+
+
+
+
+
+
+
+
diff --git a/server/index.js b/server/index.js
index 120d75b..77307a8 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';
@@ -56,10 +57,10 @@ app.use(helmet({
// Alpine.js CDN (optional, falls verwendet)
'https://cdn.jsdelivr.net',
],
- styleSrc: ["'self'", "'unsafe-inline'"],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
- fontSrc: ["'self'"],
+ fontSrc: ["'self'", 'data:', 'https://cdn.jsdelivr.net'],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
// upgrade-insecure-requests nur mit HTTPS aktivieren
@@ -166,6 +167,19 @@ 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);
+app.get('/docs', (_req, res) => {
+ res.sendFile(path.join(import.meta.dirname, '..', 'public', 'doc-assets', 'swagger.html'));
+});
+
// Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz
app.use('/api/v1', requireAuth);
app.use('/api/v1', csrfMiddleware);
diff --git a/server/openapi.js b/server/openapi.js
new file mode 100644
index 0000000..d97ece3
--- /dev/null
+++ b/server/openapi.js
@@ -0,0 +1,591 @@
+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 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': {
+ post: op({ summary: 'Create budget category', tag: 'Budget', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/budget/categories/{categoryKey}/subcategories': {
+ 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 };