import { OpenAPIRegistry, OpenApiGeneratorV3, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { z } from 'zod'; import { getBetterAuthBaseUrl } from '../utils/env'; extendZodWithOpenApi(z); const registry = new OpenAPIRegistry(); const ErrorResponseSchema = registry.register( 'ErrorResponse', z.object({ message: z.string(), errors: z.unknown().optional(), }), ); const VideoRecordSchema = registry.register( 'VideoRecord', z.object({ id: z.string().uuid(), objectKey: z.string(), bucket: z.string(), status: z.string(), createdAt: z.string().datetime(), expiresAt: z.string().datetime().nullable(), }), ); const VideoUploadUrlRequestSchema = registry.register( 'VideoUploadUrlRequest', z.object({ fileName: z.string().min(1).max(255), deviceId: z.string().uuid(), prefix: z.string().optional(), recordingId: z.string().uuid().optional(), eventId: z.string().uuid().optional(), }), ); const AdminUploadUrlRequestSchema = registry.register( 'AdminUploadUrlRequest', z.object({ fileName: z.string().min(1).max(255), prefix: z.string().optional(), }), ); const UploadUrlResponseSchema = registry.register( 'UploadUrlResponse', z.object({ message: z.string(), bucket: z.string(), objectKey: z.string(), uploadUrl: z.string().url(), expiresInSeconds: z.number().int().positive(), video: VideoRecordSchema, }), ); const DownloadUrlQuerySchema = registry.register( 'DownloadUrlQuery', z.object({ objectKey: z.string().min(1), }), ); const DownloadUrlResponseSchema = registry.register( 'DownloadUrlResponse', z.object({ message: z.string(), bucket: z.string(), objectKey: z.string(), downloadUrl: z.string().url(), expiresInSeconds: z.number().int().positive(), }), ); const ListVideosQuerySchema = registry.register( 'ListVideosQuery', z.object({ prefix: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), }), ); const ListedObjectSchema = registry.register( 'ListedObject', z.object({ objectKey: z.string().nullable(), size: z.number(), etag: z.string().nullable(), lastModified: z.string().datetime().nullable(), }), ); const ListVideosResponseSchema = registry.register( 'ListVideosResponse', z.object({ bucket: z.string(), count: z.number().int().nonnegative(), objects: z.array(ListedObjectSchema), }), ); const DeleteVideoResponseSchema = registry.register( 'DeleteVideoResponse', z.object({ message: z.string(), bucket: z.string(), objectKey: z.string(), }), ); const DeviceSchema = registry.register( 'Device', z.object({ id: z.string().uuid(), userId: z.string().uuid(), name: z.string().nullable(), role: z.enum(['camera', 'client']), platform: z.string().nullable(), appVersion: z.string().nullable(), status: z.string(), isCamera: z.boolean(), lastSeenAt: z.string().datetime().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }), ); const DeviceLinkSchema = registry.register( 'DeviceLink', z.object({ id: z.string().uuid(), ownerUserId: z.string().uuid(), cameraDeviceId: z.string().uuid(), clientDeviceId: z.string().uuid(), status: z.string(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }), ); const DeviceCommandSchema = registry.register( 'DeviceCommand', z.object({ id: z.string().uuid(), ownerUserId: z.string().uuid(), sourceDeviceId: z.string().uuid(), targetDeviceId: z.string().uuid(), commandType: z.string(), payload: z.record(z.string(), z.unknown()).nullable(), status: z.string(), retryCount: z.number().int(), lastDispatchedAt: z.string().datetime().nullable(), acknowledgedAt: z.string().datetime().nullable(), error: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }), ); registry.registerPath({ method: 'get', path: '/', summary: 'Health check', tags: ['System'], responses: { 200: { description: 'Service health text', content: { 'text/plain': { schema: z.string(), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/videos/upload-url', summary: 'Create a presigned upload URL and attach it to a recording', tags: ['Videos'], security: [{ cookieAuth: [] }], request: { body: { content: { 'application/json': { schema: VideoUploadUrlRequestSchema, }, }, }, }, responses: { 201: { description: 'Presigned upload URL generated', content: { 'application/json': { schema: UploadUrlResponseSchema, }, }, }, 400: { description: 'Invalid request body', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'get', path: '/videos/download-url', summary: 'Create a signed download URL', tags: ['Videos'], security: [{ cookieAuth: [] }], request: { query: DownloadUrlQuerySchema, }, responses: { 200: { description: 'Signed download URL generated', content: { 'application/json': { schema: DownloadUrlResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'get', path: '/videos', summary: 'List objects in the configured bucket', tags: ['Videos'], security: [{ cookieAuth: [] }], request: { query: ListVideosQuerySchema, }, responses: { 200: { description: 'Object listing', content: { 'application/json': { schema: ListVideosResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'delete', path: '/videos', summary: 'Delete object by object key', tags: ['Videos'], security: [{ cookieAuth: [] }], request: { query: DownloadUrlQuerySchema, }, responses: { 200: { description: 'Object deleted', content: { 'application/json': { schema: DeleteVideoResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'post', path: '/admin/upload-url', summary: 'Admin: create presigned upload URL', tags: ['Admin'], security: [{ basicAuth: [] }], request: { body: { content: { 'application/json': { schema: AdminUploadUrlRequestSchema, }, }, }, }, responses: { 201: { description: 'Admin upload URL generated', content: { 'application/json': { schema: z.object({ message: z.string(), bucket: z.string(), objectKey: z.string(), uploadUrl: z.string().url(), expiresAt: z.string().datetime(), expiresInSeconds: z.number().int().positive(), }), }, }, }, 400: { description: 'Invalid request body', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'get', path: '/admin/objects', summary: 'Admin: list objects in bucket', tags: ['Admin'], security: [{ basicAuth: [] }], request: { query: ListVideosQuerySchema, }, responses: { 200: { description: 'Admin object listing', content: { 'application/json': { schema: ListVideosResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'delete', path: '/admin/object', summary: 'Admin: delete object by key', tags: ['Admin'], security: [{ basicAuth: [] }], request: { query: DownloadUrlQuerySchema, }, responses: { 200: { description: 'Object deleted', content: { 'application/json': { schema: DeleteVideoResponseSchema, }, }, }, 400: { description: 'Invalid query params', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, 401: { description: 'Unauthorized', content: { 'application/json': { schema: ErrorResponseSchema, }, }, }, }, }); registry.registerPath({ method: 'post', path: '/devices/register', summary: 'Register a mobile device and issue a device token', tags: ['Devices'], security: [{ cookieAuth: [] }], request: { body: { content: { 'application/json': { schema: z.object({ name: z.string().optional(), role: z.enum(['camera', 'client']), platform: z.string().optional(), appVersion: z.string().optional(), pushToken: z.string().optional(), }), }, }, }, }, responses: { 201: { description: 'Device registered', content: { 'application/json': { schema: z.object({ message: z.string(), device: DeviceSchema, deviceToken: z.string(), }), }, }, }, }, }); registry.registerPath({ method: 'get', path: '/devices', summary: 'List devices for the authenticated user', tags: ['Devices'], security: [{ cookieAuth: [] }], responses: { 200: { description: 'User devices', content: { 'application/json': { schema: z.object({ count: z.number().int(), devices: z.array(DeviceSchema), }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/devices/{deviceId}/heartbeat', summary: 'Record heartbeat for a device using bearer device token', tags: ['Devices'], security: [{ bearerDeviceToken: [] }], request: { params: z.object({ deviceId: z.string().uuid() }), body: { content: { 'application/json': { schema: z.object({ status: z.enum(['online', 'offline']).optional() }), }, }, }, }, responses: { 200: { description: 'Heartbeat recorded', content: { 'application/json': { schema: z.object({ message: z.string(), device: z.object({ id: z.string().uuid(), status: z.string(), lastSeenAt: z.string().datetime().nullable(), updatedAt: z.string().datetime(), }), }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/device-links', summary: 'Link a client device to a camera device', tags: ['Device Links'], security: [{ cookieAuth: [] }], request: { body: { content: { 'application/json': { schema: z.object({ cameraDeviceId: z.string().uuid(), clientDeviceId: z.string().uuid(), }), }, }, }, }, responses: { 201: { description: 'Link created', content: { 'application/json': { schema: z.object({ message: z.string(), link: DeviceLinkSchema }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/commands', summary: 'Create and dispatch a realtime command from client to camera', tags: ['Commands'], security: [{ cookieAuth: [] }], request: { body: { content: { 'application/json': { schema: z.object({ sourceDeviceId: z.string().uuid(), targetDeviceId: z.string().uuid(), commandType: z.enum(['start_stream', 'stop_stream', 'ping', 'update_settings']), payload: z.record(z.string(), z.unknown()).optional(), }), }, }, }, }, responses: { 201: { description: 'Command queued', content: { 'application/json': { schema: z.object({ message: z.string(), command: DeviceCommandSchema }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/events/motion/start', summary: 'Camera device starts a motion event and notifies linked clients', tags: ['Events'], security: [{ bearerDeviceToken: [] }], request: { body: { content: { 'application/json': { schema: z.object({ title: z.string().optional(), triggeredBy: z.string().optional(), videoUrl: z.string().url().optional(), }), }, }, }, }, responses: { 201: { description: 'Motion event started', content: { 'application/json': { schema: z.object({ message: z.string(), event: z.object({ id: z.string().uuid(), userId: z.string().uuid(), deviceId: z.string().uuid().nullable(), title: z.string().nullable(), triggeredBy: z.string().nullable(), status: z.string(), startedAt: z.string().datetime(), endedAt: z.string().datetime().nullable(), videoUrl: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), notifiedAt: z.string().datetime().nullable(), }), notifiedClients: z.number().int(), }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/events/{eventId}/motion/end', summary: 'Camera device ends a motion event and notifies linked clients', tags: ['Events'], security: [{ bearerDeviceToken: [] }], request: { params: z.object({ eventId: z.string().uuid() }), body: { content: { 'application/json': { schema: z.object({ status: z.enum(['completed', 'cancelled', 'failed']).optional(), videoUrl: z.string().url().optional(), }), }, }, }, }, responses: { 200: { description: 'Motion event ended', content: { 'application/json': { schema: z.object({ message: z.string(), event: z.object({ id: z.string().uuid(), status: z.string(), endedAt: z.string().datetime().nullable(), updatedAt: z.string().datetime(), }), notifiedClients: z.number().int(), }), }, }, }, }, }); registry.registerPath({ method: 'get', path: '/events', summary: 'List events for authenticated user', tags: ['Events'], security: [{ cookieAuth: [] }], request: { query: z.object({ status: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), }), }, responses: { 200: { description: 'User event list', content: { 'application/json': { schema: z.object({ count: z.number().int(), events: z.array( z.object({ id: z.string().uuid(), status: z.string(), startedAt: z.string().datetime(), endedAt: z.string().datetime().nullable(), deviceId: z.string().uuid().nullable(), title: z.string().nullable(), triggeredBy: z.string().nullable(), videoUrl: z.string().nullable(), }), ), }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/streams/request', summary: 'Client device requests an on-demand stream from a linked camera', tags: ['Streams'], security: [{ bearerDeviceToken: [] }], request: { body: { content: { 'application/json': { schema: z.object({ cameraDeviceId: z.string().uuid(), reason: z.enum(['on_demand', 'motion_follow_up']).optional(), metadata: z.record(z.string(), z.unknown()).optional(), }), }, }, }, }, responses: { 201: { description: 'Stream request created', content: { 'application/json': { schema: z.object({ message: z.string(), streamSession: z.object({ id: z.string().uuid(), ownerUserId: z.string().uuid(), cameraDeviceId: z.string().uuid(), requesterDeviceId: z.string().uuid(), status: z.string(), reason: z.string(), metadata: z.record(z.string(), z.unknown()).nullable().optional(), startedAt: z.string().datetime().nullable().optional(), endedAt: z.string().datetime().nullable().optional(), createdAt: z.string().datetime().optional(), updatedAt: z.string().datetime().optional(), }), }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/streams/{streamSessionId}/accept', summary: 'Camera device accepts a pending on-demand stream request', tags: ['Streams'], security: [{ bearerDeviceToken: [] }], request: { params: z.object({ streamSessionId: z.string().uuid() }), body: { content: { 'application/json': { schema: z.object({ streamKey: z.string().optional(), metadata: z.record(z.string(), z.unknown()).optional(), }), }, }, }, }, responses: { 200: { description: 'Stream session accepted', content: { 'application/json': { schema: z.object({ message: z.string(), streamSession: z.object({ id: z.string().uuid(), ownerUserId: z.string().uuid(), cameraDeviceId: z.string().uuid(), requesterDeviceId: z.string().uuid(), status: z.string(), reason: z.string(), metadata: z.record(z.string(), z.unknown()).nullable().optional(), startedAt: z.string().datetime().nullable(), endedAt: z.string().datetime().nullable().optional(), createdAt: z.string().datetime().optional(), updatedAt: z.string().datetime().optional(), }), }), }, }, }, }, }); registry.registerPath({ method: 'post', path: '/streams/{streamSessionId}/end', summary: 'Requester or camera ends an active stream session', tags: ['Streams'], security: [{ bearerDeviceToken: [] }], request: { params: z.object({ streamSessionId: z.string().uuid() }), body: { content: { 'application/json': { schema: z.object({ reason: z.enum(['completed', 'cancelled', 'failed']).optional(), }), }, }, }, }, responses: { 200: { description: 'Stream session ended', content: { 'application/json': { schema: z.object({ message: z.string(), streamSession: z.object({ id: z.string().uuid(), status: z.string(), endedAt: z.string().datetime().nullable(), }), }), }, }, }, }, }); registry.registerPath({ method: 'get', path: '/streams/me/list', summary: 'List stream sessions for current device', tags: ['Streams'], security: [{ bearerDeviceToken: [] }], request: { query: z.object({ status: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), }), }, responses: { 200: { description: 'Stream sessions list', content: { 'application/json': { schema: z.object({ count: z.number().int(), streamSessions: z.array( z.object({ id: z.string().uuid(), cameraDeviceId: z.string().uuid(), requesterDeviceId: z.string().uuid(), status: z.string(), reason: z.string(), streamKey: z.string().nullable(), mediaProvider: z.string(), mediaSessionId: z.string().nullable(), }), ), }), }, }, }, }, }); export function buildOpenApiDocument() { const generator = new OpenApiGeneratorV3(registry.definitions); const document = generator.generateDocument({ openapi: '3.0.3', info: { title: 'Backend API', version: '1.0.0', description: 'Auto-generated API documentation from Zod schemas.', }, servers: [{ url: getBetterAuthBaseUrl() }], tags: [ { name: 'System', description: 'Service endpoints' }, { name: 'Videos', description: 'Authenticated video object operations' }, { name: 'Admin', description: 'Basic-auth protected admin operations' }, { name: 'Devices', description: 'Device registration and heartbeat endpoints' }, { name: 'Device Links', description: 'Client-camera authorization links' }, { name: 'Commands', description: 'Realtime command dispatch and status' }, { name: 'Events', description: 'Motion event lifecycle and user event history' }, { name: 'Streams', description: 'On-demand live stream control lifecycle' }, ], }); document.components = { ...(document.components ?? {}), securitySchemes: { cookieAuth: { type: 'apiKey', in: 'cookie', name: 'better-auth.session_token', description: 'Better Auth session cookie', }, basicAuth: { type: 'http', scheme: 'basic', }, bearerDeviceToken: { type: 'http', scheme: 'bearer', }, }, }; return document; }