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 };