diff --git a/.env.example b/.env.example index cf43c72..3cc2f91 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ LOG_LEVEL=warn # API documentation UI + OpenAPI JSON # Default behavior: enabled outside production, disabled in production +# Recommended: +# development/staging: OPENAPI_DOCS_ENABLED=true +# production: OPENAPI_DOCS_ENABLED=false # OPENAPI_DOCS_ENABLED=true # Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York) diff --git a/README.md b/README.md index 20ab08d..02bd4d5 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,19 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl | `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. | | `TZ` | `Europe/Berlin` | Timezone for scheduled reminders | +Recommended values for API docs by environment: + +| Environment | Recommendation | +|-------------|----------------| +| Development | `OPENAPI_DOCS_ENABLED=true` | +| Staging/Test | `OPENAPI_DOCS_ENABLED=true` | +| Production | `OPENAPI_DOCS_ENABLED=false` (or keep `auto` with `NODE_ENV=production`) | + +Notes: + +- `auto` means: docs enabled when `NODE_ENV` is not `production`, disabled when `NODE_ENV=production`. +- Explicitly setting `OPENAPI_DOCS_ENABLED` always overrides `auto` behavior. + ### Authentication | Variable | Default | Description | diff --git a/backend/src/routes/api-keys.ts b/backend/src/routes/api-keys.ts index bb10a28..ec0a180 100644 --- a/backend/src/routes/api-keys.ts +++ b/backend/src/routes/api-keys.ts @@ -18,7 +18,10 @@ const idParamSchema = z.object({ id: z.string().regex(/^\d+$/), }); -const protectedEndpointSecurity = [{ bearerAuth: [] }]; +const protectedEndpointSecurity: ReadonlyArray> = [ + { bearerAuth: [] }, + { cookieAuth: [] }, +]; const genericErrorSchema = { type: "object", properties: { diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index 92bc5b2..7a09301 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -6,6 +6,7 @@ import { doseTracking, medications, shareTokens } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; import { parseIntakesJson, parseTakenByJson, personTakesMedication } from "../utils/scheduler-utils.js"; // ============================================================================= @@ -23,6 +24,11 @@ const dismissDosesSchema = z.object({ doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"), }); +const protectedEndpointSecurity: ReadonlyArray> = [ + { bearerAuth: [] }, + { cookieAuth: [] }, +]; + const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/; function maskToken(token: string): string { @@ -135,33 +141,43 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI // Dose Tracking Routes // ============================================================================= export async function doseRoutes(app: FastifyInstance) { + applyOpenApiRouteStandards(app, { + tag: "doses", + protectedByDefault: false, + protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/], + }); + // --------------------------------------------------------------------------- // GET /doses/taken - PROTECTED: Get all taken doses for the user // Suppress request logs — polled every 5s by frontend // --------------------------------------------------------------------------- - app.get("/doses/taken", { preHandler: requireAuth, logLevel: "warn" }, async (request, reply) => { - const userId = await getUserId(request, reply); + app.get( + "/doses/taken", + { preHandler: requireAuth, logLevel: "warn", schema: { tags: ["doses"], security: protectedEndpointSecurity } }, + async (request, reply) => { + const userId = await getUserId(request, reply); - // Get all taken doses for this user (no time limit) - const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId)); + // Get all taken doses for this user (no time limit) + const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId)); - return { - doses: doses.map((d) => ({ - doseId: d.doseId, - takenAt: d.takenAt?.getTime() ?? Date.now(), - markedBy: d.markedBy, - takenSource: d.takenSource ?? "manual", - dismissed: d.dismissed ?? false, - })), - }; - }); + return { + doses: doses.map((d) => ({ + doseId: d.doseId, + takenAt: d.takenAt?.getTime() ?? Date.now(), + markedBy: d.markedBy, + takenSource: d.takenSource ?? "manual", + dismissed: d.dismissed ?? false, + })), + }; + } + ); // --------------------------------------------------------------------------- // POST /doses/taken - PROTECTED: Mark a dose as taken // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/doses/taken", - { preHandler: requireAuth }, + { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } }, async (request, reply) => { const userId = await getUserId(request, reply); @@ -201,7 +217,7 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.delete<{ Params: { doseId: string } }>( "/doses/taken/:doseId", - { preHandler: requireAuth }, + { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } }, async (request, reply) => { const userId = await getUserId(request, reply); @@ -230,7 +246,7 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/doses/dismiss", - { preHandler: requireAuth }, + { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } }, async (request, reply) => { const userId = await getUserId(request, reply); @@ -281,30 +297,34 @@ export async function doseRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss) // --------------------------------------------------------------------------- - app.delete("/doses/dismiss", { preHandler: requireAuth }, async (request, reply) => { - const userId = await getUserId(request, reply); + app.delete( + "/doses/dismiss", + { preHandler: requireAuth, schema: { tags: ["doses"], security: protectedEndpointSecurity } }, + async (request, reply) => { + const userId = await getUserId(request, reply); - // Delete all dismissed-only records (not taken ones) - // For taken+dismissed, just remove the dismissed flag - const dismissed = await db - .select() - .from(doseTracking) - .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true))); + // Delete all dismissed-only records (not taken ones) + // For taken+dismissed, just remove the dismissed flag + const dismissed = await db + .select() + .from(doseTracking) + .where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true))); - for (const d of dismissed) { - const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt); + for (const d of dismissed) { + const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt); - if (d.markedBy !== null || hasRealTakenTimestamp) { - // This was also marked as taken - just remove dismissed flag - await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id)); - } else { - // This was only dismissed - delete it - await db.delete(doseTracking).where(eq(doseTracking.id, d.id)); + if (d.markedBy !== null || hasRealTakenTimestamp) { + // This was also marked as taken - just remove dismissed flag + await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id)); + } else { + // This was only dismissed - delete it + await db.delete(doseTracking).where(eq(doseTracking.id, d.id)); + } } - } - return { success: true, clearedCount: dismissed.length }; - }); + return { success: true, clearedCount: dismissed.length }; + } + ); // --------------------------------------------------------------------------- // GET /share/:token/doses - PUBLIC: Get taken doses for a share link diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index 3248c3d..e009e10 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -10,6 +10,7 @@ import { doseTracking, medications, refillHistory, shareTokens, userSettings } f import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js"; import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js"; @@ -272,6 +273,7 @@ function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: nu export async function exportRoutes(app: FastifyInstance) { // All export routes require auth app.addHook("preHandler", requireAuth); + applyOpenApiRouteStandards(app, { tag: "export", protectedByDefault: true }); // --------------------------------------------------------------------------- // GET /export - Export all user data diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index a06fa0b..b3118b7 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { FastifyInstance } from "fastify"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; // Read version from package.json at startup const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -10,6 +11,8 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); const backendVersion = packageJson.version || "unknown"; export async function healthRoutes(app: FastifyInstance) { + applyOpenApiRouteStandards(app, { tag: "health", protectedByDefault: false }); + // Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck) app.get("/health", { config: { rateLimit: false }, logLevel: "warn" }, async () => ({ status: "ok", diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 6ec11e0..ab032b5 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -14,6 +14,7 @@ import { streamToBuffer, writeOptimizedImageSet, } from "../utils/image-upload.js"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, isLiquidContainerPackageType, @@ -240,6 +241,7 @@ const medicationSchema = z export async function medicationRoutes(app: FastifyInstance) { // All medication routes require auth app.addHook("preHandler", requireAuth); + applyOpenApiRouteStandards(app, { tag: "medications", protectedByDefault: true }); // Helper to get user ID from request // Returns anonymous user ID when auth is disabled diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index d7a9ab1..0751c7f 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -5,6 +5,7 @@ import * as client from "openid-client"; import { db } from "../db/client.js"; import { refreshTokens, users } from "../db/schema.js"; import { env } from "../plugins/env.js"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; // ============================================================================= // OIDC Configuration Cache @@ -49,6 +50,8 @@ function getFrontendUrl(): string { // OIDC Routes // ============================================================================= export async function oidcRoutes(app: FastifyInstance) { + applyOpenApiRouteStandards(app, { tag: "auth", protectedByDefault: false }); + if (!env.OIDC_ENABLED) { // Register a disabled route that returns an error app.get("/auth/oidc/login", async (_request, reply) => { diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index fc3e398..4d7dab7 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -15,6 +15,7 @@ import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js"; import type { AuthUser } from "../types/fastify.js"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; import { getPlannerUnitKind, isAmountBasedPackageType, @@ -134,6 +135,7 @@ type PrescriptionReminderBody = { export async function plannerRoutes(app: FastifyInstance) { // Add auth hook for all planner routes app.addHook("preHandler", requireAuth); + applyOpenApiRouteStandards(app, { tag: "planner", protectedByDefault: true }); // Helper to get user ID from request async function getUserId(request: FastifyRequest): Promise { diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index 52c0fd1..73edb35 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -6,6 +6,7 @@ import { medications, refillHistory } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; const refillSchema = z @@ -21,6 +22,7 @@ const refillSchema = z export async function refillRoutes(app: FastifyInstance) { // All refill routes require auth app.addHook("preHandler", requireAuth); + applyOpenApiRouteStandards(app, { tag: "refills", protectedByDefault: true }); // Helper to get user ID from request async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { diff --git a/backend/src/routes/report.ts b/backend/src/routes/report.ts index 62e69d4..0d107de 100644 --- a/backend/src/routes/report.ts +++ b/backend/src/routes/report.ts @@ -6,6 +6,7 @@ import { doseTracking, medications, refillHistory } from "../db/schema.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; const reportDataSchema = z.object({ medicationIds: z.array(z.number().int().positive()).min(1).max(100), @@ -13,6 +14,7 @@ const reportDataSchema = z.object({ export async function reportRoutes(app: FastifyInstance) { app.addHook("preHandler", requireAuth); + applyOpenApiRouteStandards(app, { tag: "report", protectedByDefault: true }); async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise { if (!env.AUTH_ENABLED) { diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 7d4a231..1942e0f 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -8,6 +8,7 @@ import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import { buildSharedMedicationOverview } from "../services/coverage.js"; import type { AuthUser } from "../types/fastify.js"; +import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; import { getAllTakenByForMedication, @@ -24,6 +25,11 @@ const createShareSchema = z.object({ scheduleDays: z.number().int().min(1).max(365).default(30), }); +const protectedEndpointSecurity: ReadonlyArray> = [ + { bearerAuth: [] }, + { cookieAuth: [] }, +]; + const shareTokenPattern = /^[a-f0-9]{16}$/; function maskToken(token: string): string { @@ -51,6 +57,12 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise< // Share Routes // ============================================================================= export async function shareRoutes(app: FastifyInstance) { + applyOpenApiRouteStandards(app, { + tag: "share", + protectedByDefault: false, + protectedPaths: [/^\/share$/, /^\/share\/people$/], + }); + // --------------------------------------------------------------------------- // GET /share/:token - PUBLIC: Get shared schedule by token // --------------------------------------------------------------------------- @@ -256,7 +268,7 @@ export async function shareRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- app.post<{ Body: z.infer }>( "/share", - { preHandler: requireAuth }, + { preHandler: requireAuth, schema: { tags: ["share"], security: protectedEndpointSecurity } }, async (request, reply) => { const userId = await getUserId(request, reply); @@ -337,37 +349,41 @@ export async function shareRoutes(app: FastifyInstance) { // --------------------------------------------------------------------------- // GET /share/people - PROTECTED: Get list of unique takenBy values // --------------------------------------------------------------------------- - app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => { - const userId = await getUserId(request, reply); + app.get( + "/share/people", + { preHandler: requireAuth, schema: { tags: ["share"], security: protectedEndpointSecurity } }, + async (request, reply) => { + const userId = await getUserId(request, reply); - // Get all unique takenBy values for this user (from both medication-level and intake-level) - const meds = await db - .select({ - takenByJson: medications.takenByJson, - intakesJson: medications.intakesJson, - usageJson: medications.usageJson, - everyJson: medications.everyJson, - startJson: medications.startJson, - intakeRemindersEnabled: medications.intakeRemindersEnabled, - }) - .from(medications) - .where(eq(medications.userId, userId)); + // Get all unique takenBy values for this user (from both medication-level and intake-level) + const meds = await db + .select({ + takenByJson: medications.takenByJson, + intakesJson: medications.intakesJson, + usageJson: medications.usageJson, + everyJson: medications.everyJson, + startJson: medications.startJson, + intakeRemindersEnabled: medications.intakeRemindersEnabled, + }) + .from(medications) + .where(eq(medications.userId, userId)); - // Collect all unique person names from medication-level AND intake-level takenBy - const allPeople = new Set(); - for (const med of meds) { - const takenByArray = parseTakenByJson(med.takenByJson); - const intakes = parseIntakesJson( - med.intakesJson, - { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, - med.intakeRemindersEnabled ?? false - ); - const allForMed = getAllTakenByForMedication(takenByArray, intakes); - for (const person of allForMed) { - if (person) allPeople.add(person); + // Collect all unique person names from medication-level AND intake-level takenBy + const allPeople = new Set(); + for (const med of meds) { + const takenByArray = parseTakenByJson(med.takenByJson); + const intakes = parseIntakesJson( + med.intakesJson, + { usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson }, + med.intakeRemindersEnabled ?? false + ); + const allForMed = getAllTakenByForMedication(takenByArray, intakes); + for (const person of allForMed) { + if (person) allPeople.add(person); + } } - } - return { people: [...allPeople].sort() }; - }); + return { people: [...allPeople].sort() }; + } + ); } diff --git a/backend/src/utils/openapi-route-standards.ts b/backend/src/utils/openapi-route-standards.ts new file mode 100644 index 0000000..e67db0c --- /dev/null +++ b/backend/src/utils/openapi-route-standards.ts @@ -0,0 +1,70 @@ +import type { FastifyInstance, RouteOptions } from "fastify"; + +type SecurityEntry = Readonly>; + +const defaultProtectedSecurity: readonly SecurityEntry[] = [{ bearerAuth: [] }, { cookieAuth: [] }]; + +export type OpenApiRouteStandardsOptions = { + tag: string; + protectedByDefault: boolean; + protectedPaths?: RegExp[]; + publicPaths?: RegExp[]; +}; + +function asMethods(method: RouteOptions["method"]): string[] { + if (Array.isArray(method)) return method.map((m) => String(m).toUpperCase()); + return [String(method).toUpperCase()]; +} + +function pathMatches(path: string, patterns: RegExp[] | undefined): boolean { + if (!patterns || patterns.length === 0) return false; + return patterns.some((pattern) => pattern.test(path)); +} + +function buildDefaultSummary(methods: string[], path: string): string { + const methodText = methods.join("/"); + return `${methodText} ${path}`; +} + +function buildDefaultDescription(requiresAuth: boolean): string { + return requiresAuth + ? "Protected endpoint. Requires Bearer token (API key or JWT) or session cookie." + : "Public endpoint."; +} + +export function applyOpenApiRouteStandards(app: FastifyInstance, options: OpenApiRouteStandardsOptions): void { + app.addHook("onRoute", (routeOptions) => { + const methods = asMethods(routeOptions.method); + const path = routeOptions.url; + + const isExplicitlyPublic = pathMatches(path, options.publicPaths); + const isExplicitlyProtected = pathMatches(path, options.protectedPaths); + let requiresAuth = options.protectedByDefault; + if (isExplicitlyPublic) { + requiresAuth = false; + } else if (isExplicitlyProtected) { + requiresAuth = true; + } + + routeOptions.schema = routeOptions.schema ?? {}; + routeOptions.schema.tags = routeOptions.schema.tags ?? [options.tag]; + routeOptions.schema.summary = routeOptions.schema.summary ?? buildDefaultSummary(methods, path); + routeOptions.schema.description = routeOptions.schema.description ?? buildDefaultDescription(requiresAuth); + + if (requiresAuth) { + routeOptions.schema.security = routeOptions.schema.security ?? defaultProtectedSecurity; + routeOptions.schema.response = { + ...(routeOptions.schema.response ?? {}), + 401: (routeOptions.schema.response as Record | undefined)?.[401] ?? { + type: "object", + properties: { + error: { type: "string" }, + code: { type: "string" }, + }, + }, + }; + } else if (routeOptions.schema.security === undefined) { + routeOptions.schema.security = []; + } + }); +}