From 4a205f5348d1c0eba397df360e7ade7553c841b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Apr 2026 22:30:33 +0200 Subject: [PATCH] chore(repo): untrack docs/superpowers + ignore AI tool artefacts Past Claude / Superpowers session output (plans + design specs) was landing in the public repo. Files stay on disk locally but are no longer tracked. .gitignore extended to cover the usual AI-tooling scratch directories (.claude/, .cursor/, .codex/, .aider*) so future sessions don't sneak anything in either. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 + .../plans/2026-04-06-codex-oauth-insights.md | 1675 ----------------- .../2026-04-06-codex-oauth-insights-design.md | 359 ---- ...026-04-18-blood-glucose-tracking-design.md | 207 -- .../2026-04-18-builtin-feedback-design.md | 162 -- .../2026-04-18-custom-thresholds-design.md | 227 --- ...26-04-18-dashboard-customization-design.md | 117 -- .../2026-04-18-multi-provider-ai-design.md | 216 --- 8 files changed, 7 insertions(+), 2963 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-06-codex-oauth-insights.md delete mode 100644 docs/superpowers/specs/2026-04-06-codex-oauth-insights-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-blood-glucose-tracking-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-builtin-feedback-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-custom-thresholds-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-dashboard-customization-design.md delete mode 100644 docs/superpowers/specs/2026-04-18-multi-provider-ai-design.md diff --git a/.gitignore b/.gitignore index 385cf5f4c..b16ecc063 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,10 @@ next-env.d.ts # prisma generated /src/generated/prisma + +# AI session artefacts (kept locally, not committed) +/docs/superpowers/ +.claude/ +.cursor/ +.codex/ +.aider* diff --git a/docs/superpowers/plans/2026-04-06-codex-oauth-insights.md b/docs/superpowers/plans/2026-04-06-codex-oauth-insights.md deleted file mode 100644 index 82f6a0deb..000000000 --- a/docs/superpowers/plans/2026-04-06-codex-oauth-insights.md +++ /dev/null @@ -1,1675 +0,0 @@ -# Codex OAuth + Medical Insights Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace OpenAI API-key flow with ChatGPT Codex OAuth, add admin AI key fallback, and improve medical insight prompts with guideline-based analysis. - -**Architecture:** Provider abstraction layer (`src/lib/ai/`) with Codex OAuth client and standard OpenAI client. Provider hierarchy: User Codex OAuth → Admin key → local fallback. Each insight generator delegates to `resolveProvider()` instead of directly calling OpenAI. New Prisma fields for Codex tokens + admin AI settings. OAuth flow follows existing Withings pattern. - -**Tech Stack:** Next.js 16, Prisma 7, PostgreSQL, TypeScript strict, Vitest, Zod v4, AES-256-GCM encryption, OAuth 2.1 + PKCE - -**Spec:** `docs/superpowers/specs/2026-04-06-codex-oauth-insights-design.md` - ---- - -## Phase 1: Core AI Provider Abstraction - -### Task 1: Prisma Schema Changes - -**Files:** -- Modify: `prisma/schema.prisma` (User model lines 12-71, AppSettings model lines 352-378) - -- [ ] **Step 1: Add new fields to User model** - -In `prisma/schema.prisma`, replace the `openaiKeyEncrypted` line in the User model: - -```prisma - // Codex OAuth (ChatGPT subscription) - codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") - codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") - codexTokenExpiresAt DateTime? @map("codex_token_expires_at") - codexConnectedAt DateTime? @map("codex_connected_at") - codexConnectionStatus String @default("disconnected") @map("codex_connection_status") - - // Insights - insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") - insightsCachedAt DateTime? @map("insights_cached_at") - insightsCachedText String? @map("insights_cached_text") -``` - -Remove the old `openaiKeyEncrypted` line. - -- [ ] **Step 2: Add admin AI fields to AppSettings** - -Add at the end of the AppSettings model: - -```prisma - // Admin AI Provider (fallback for users without Codex OAuth) - adminAiKeyEncrypted String? @map("admin_ai_key_encrypted") - adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model") - adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url") -``` - -- [ ] **Step 3: Create and apply migration** - -```bash -cd ~/projects/HealthLog -pnpm db:migrate --name codex-oauth-admin-ai -``` - -- [ ] **Step 4: Generate Prisma client** - -```bash -pnpm db:generate -``` - -- [ ] **Step 5: Verify build compiles** - -```bash -pnpm typecheck -``` - -Expected: Errors in files that reference `openaiKeyEncrypted` — these will be fixed in subsequent tasks. - -- [ ] **Step 6: Commit** - -```bash -git add prisma/ src/generated/ -git commit -m "feat(db): add Codex OAuth tokens and admin AI settings, remove openaiKeyEncrypted" -``` - ---- - -### Task 2: AI Types & Zod Schema - -**Files:** -- Create: `src/lib/ai/types.ts` - -- [ ] **Step 1: Write the types file** - -```typescript -import { z } from "zod/v4"; - -// ─── Insight Result Schema ───────────────────────────────── - -export const insightFindingSchema = z.object({ - label: z.string(), - value: z.string(), - assessment: z.enum(["positive", "neutral", "attention", "warning"]), - guideline: z.string().optional(), -}); - -export const insightCorrelationSchema = z.object({ - factor: z.string(), - effect: z.string(), - confidence: z.enum(["hoch", "mittel", "gering"]), -}); - -export const insightDataQualitySchema = z.object({ - coverage: z.string(), - gaps: z.array(z.string()), - confidence: z.enum(["hoch", "mittel", "gering"]), -}); - -export const insightResultSchema = z.object({ - summary: z.string(), - classification: z.enum(["optimal", "gut", "grenzwertig", "erhoht", "kritisch"]), - findings: z.array(insightFindingSchema), - correlations: z.array(insightCorrelationSchema), - recommendations: z.array(z.string()), - dataQuality: insightDataQualitySchema, - disclaimer: z.string(), -}); - -export type InsightResult = z.infer; -export type InsightFinding = z.infer; - -// ─── Provider Types ──────────────────────────────────────── - -export type ProviderType = "codex" | "admin-key" | "none"; - -export interface AIProvider { - type: ProviderType; - generateCompletion(params: CompletionParams): Promise; -} - -export interface CompletionParams { - systemPrompt: string; - userPrompt: string; - temperature?: number; - maxTokens?: number; -} - -export interface CompletionResult { - content: string; - tokensUsed: number | null; - model: string; - providerType: ProviderType; -} - -// ─── Legacy Output (for migration compatibility) ─────────── - -export interface LegacyInsightsOutput { - changed: string; - stable: string; - drivers: string; - nextSteps: string; - confidence: "niedrig" | "mittel" | "hoch"; - limitations: string; -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/lib/ai/types.ts -git commit -m "feat(ai): add AI provider types and InsightResult Zod schema" -``` - ---- - -### Task 3: OpenAI Client (Admin Key) - -**Files:** -- Create: `src/lib/ai/openai-client.ts` -- Test: `src/lib/ai/__tests__/openai-client.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { OpenAIClient } from "../openai-client"; - -describe("OpenAIClient", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("sends correct request format", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - choices: [{ message: { content: '{"summary":"test"}' } }], - usage: { total_tokens: 42 }, - }), - }); - vi.stubGlobal("fetch", mockFetch); - - const client = new OpenAIClient({ - apiKey: "sk-test-key", - model: "gpt-4o-mini", - baseUrl: "https://api.openai.com/v1", - }); - - const result = await client.generateCompletion({ - systemPrompt: "You are a doctor.", - userPrompt: "Analyze this data.", - }); - - expect(result.content).toBe('{"summary":"test"}'); - expect(result.tokensUsed).toBe(42); - expect(result.model).toBe("gpt-4o-mini"); - expect(result.providerType).toBe("admin-key"); - - const call = mockFetch.mock.calls[0]; - expect(call[0]).toBe("https://api.openai.com/v1/chat/completions"); - const body = JSON.parse(call[1].body); - expect(body.model).toBe("gpt-4o-mini"); - expect(body.messages).toHaveLength(2); - expect(body.response_format).toEqual({ type: "json_object" }); - }); - - it("throws on non-ok response", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: false, - status: 429, - text: () => Promise.resolve("rate limited"), - }), - ); - - const client = new OpenAIClient({ - apiKey: "sk-test", - model: "gpt-4o-mini", - baseUrl: "https://api.openai.com/v1", - }); - - await expect( - client.generateCompletion({ - systemPrompt: "test", - userPrompt: "test", - }), - ).rejects.toThrow("OpenAI request failed (429)"); - }); - - it("uses custom base URL for OpenRouter", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - choices: [{ message: { content: '{"test":true}' } }], - usage: { total_tokens: 10 }, - }), - }); - vi.stubGlobal("fetch", mockFetch); - - const client = new OpenAIClient({ - apiKey: "sk-or-test", - model: "openai/gpt-4o-mini", - baseUrl: "https://openrouter.ai/api/v1", - }); - - await client.generateCompletion({ - systemPrompt: "test", - userPrompt: "test", - }); - - expect(mockFetch.mock.calls[0][0]).toBe( - "https://openrouter.ai/api/v1/chat/completions", - ); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -pnpm test src/lib/ai/__tests__/openai-client.test.ts -``` - -Expected: FAIL — module not found - -- [ ] **Step 3: Write implementation** - -```typescript -import type { AIProvider, CompletionParams, CompletionResult } from "./types"; - -interface OpenAIClientConfig { - apiKey: string; - model: string; - baseUrl: string; -} - -export class OpenAIClient implements AIProvider { - readonly type = "admin-key" as const; - private config: OpenAIClientConfig; - - constructor(config: OpenAIClientConfig) { - this.config = config; - } - - async generateCompletion(params: CompletionParams): Promise { - const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; - - const res = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify({ - model: this.config.model, - messages: [ - { role: "system", content: params.systemPrompt }, - { role: "user", content: params.userPrompt }, - ], - temperature: params.temperature ?? 0.3, - max_tokens: params.maxTokens ?? 1000, - response_format: { type: "json_object" }, - }), - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`OpenAI request failed (${res.status}): ${body}`); - } - - const json = await res.json(); - const content = json.choices?.[0]?.message?.content; - - if (!content) { - throw new Error("OpenAI returned empty content"); - } - - return { - content, - tokensUsed: json.usage?.total_tokens ?? null, - model: this.config.model, - providerType: "admin-key", - }; - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -pnpm test src/lib/ai/__tests__/openai-client.test.ts -``` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/ai/openai-client.ts src/lib/ai/__tests__/openai-client.test.ts -git commit -m "feat(ai): add OpenAI client with custom base URL support" -``` - ---- - -### Task 4: Codex OAuth Utilities - -**Files:** -- Create: `src/lib/ai/codex-oauth.ts` -- Test: `src/lib/ai/__tests__/codex-oauth.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -import { describe, it, expect } from "vitest"; -import { generatePKCE, buildAuthorizationUrl } from "../codex-oauth"; - -describe("codex-oauth", () => { - describe("generatePKCE", () => { - it("generates verifier of correct length", () => { - const { verifier, challenge } = generatePKCE(); - // Base64url of 96 bytes = 128 chars - expect(verifier.length).toBeGreaterThanOrEqual(43); - expect(challenge.length).toBeGreaterThanOrEqual(43); - // No padding chars - expect(verifier).not.toContain("="); - expect(challenge).not.toContain("="); - }); - - it("generates different values each time", () => { - const a = generatePKCE(); - const b = generatePKCE(); - expect(a.verifier).not.toBe(b.verifier); - }); - }); - - describe("buildAuthorizationUrl", () => { - it("builds correct URL with all params", () => { - const url = buildAuthorizationUrl({ - codeChallenge: "test-challenge", - state: "test-state", - redirectUri: "https://example.com/callback", - }); - - const parsed = new URL(url); - expect(parsed.origin).toBe("https://chatgpt.com"); - expect(parsed.pathname).toBe("/authorize"); - expect(parsed.searchParams.get("response_type")).toBe("code"); - expect(parsed.searchParams.get("code_challenge")).toBe("test-challenge"); - expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); - expect(parsed.searchParams.get("state")).toBe("test-state"); - expect(parsed.searchParams.get("redirect_uri")).toBe( - "https://example.com/callback", - ); - }); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -pnpm test src/lib/ai/__tests__/codex-oauth.test.ts -``` - -- [ ] **Step 3: Write implementation** - -```typescript -import { createHash, randomBytes } from "node:crypto"; -import { encrypt, decrypt } from "@/lib/crypto"; - -function base64url(buffer: Buffer): string { - return buffer.toString("base64url"); -} - -export function generatePKCE(): { verifier: string; challenge: string } { - const verifier = base64url(randomBytes(96)); - const challenge = base64url(createHash("sha256").update(verifier).digest()); - return { verifier, challenge }; -} - -export function generateState(): string { - return base64url(randomBytes(32)); -} - -export function buildAuthorizationUrl(params: { - codeChallenge: string; - state: string; - redirectUri: string; -}): string { - const url = new URL("https://chatgpt.com/authorize"); - url.searchParams.set("response_type", "code"); - url.searchParams.set("code_challenge", params.codeChallenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", params.state); - url.searchParams.set("redirect_uri", params.redirectUri); - return url.toString(); -} - -interface TokenResponse { - access_token: string; - refresh_token: string; - expires_in: number; - token_type: string; -} - -export async function exchangeCodeForTokens(params: { - code: string; - codeVerifier: string; - redirectUri: string; -}): Promise { - const res = await fetch("https://chatgpt.com/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "authorization_code", - code: params.code, - code_verifier: params.codeVerifier, - redirect_uri: params.redirectUri, - }), - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`Codex token exchange failed (${res.status}): ${body}`); - } - - return res.json(); -} - -export async function refreshAccessToken( - refreshToken: string, -): Promise { - const res = await fetch("https://chatgpt.com/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - }), - }); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`Codex token refresh failed (${res.status}): ${body}`); - } - - return res.json(); -} - -export function encryptTokens(tokens: { - accessToken: string; - refreshToken: string; -}): { accessEncrypted: string; refreshEncrypted: string } { - return { - accessEncrypted: encrypt(tokens.accessToken), - refreshEncrypted: encrypt(tokens.refreshToken), - }; -} - -export function decryptTokens(encrypted: { - accessEncrypted: string; - refreshEncrypted: string; -}): { accessToken: string; refreshToken: string } { - return { - accessToken: decrypt(encrypted.accessEncrypted), - refreshToken: decrypt(encrypted.refreshEncrypted), - }; -} -``` - -- [ ] **Step 4: Run test** - -```bash -pnpm test src/lib/ai/__tests__/codex-oauth.test.ts -``` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/ai/codex-oauth.ts src/lib/ai/__tests__/codex-oauth.test.ts -git commit -m "feat(ai): add Codex OAuth PKCE utilities and token management" -``` - ---- - -### Task 5: Codex Client - -**Files:** -- Create: `src/lib/ai/codex-client.ts` -- Test: `src/lib/ai/__tests__/codex-client.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { CodexClient } from "../codex-client"; - -describe("CodexClient", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("sends request to codex responses endpoint", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - output: [ - { type: "message", content: [{ type: "output_text", text: '{"summary":"ok"}' }] }, - ], - usage: { total_tokens: 50 }, - }), - }); - vi.stubGlobal("fetch", mockFetch); - - const client = new CodexClient({ - accessToken: "test-token", - onTokenRefresh: vi.fn(), - }); - - const result = await client.generateCompletion({ - systemPrompt: "You are a doctor.", - userPrompt: "Analyze this.", - }); - - expect(result.content).toBe('{"summary":"ok"}'); - expect(result.providerType).toBe("codex"); - expect(mockFetch.mock.calls[0][0]).toBe( - "https://chatgpt.com/backend-api/codex/responses", - ); - const headers = mockFetch.mock.calls[0][1].headers; - expect(headers.Authorization).toBe("Bearer test-token"); - }); - - it("calls onTokenRefresh on 401 and retries", async () => { - const newToken = "refreshed-token"; - const onRefresh = vi.fn().mockResolvedValue(newToken); - - const mockFetch = vi - .fn() - .mockResolvedValueOnce({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") }) - .mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - output: [ - { type: "message", content: [{ type: "output_text", text: '{"test":true}' }] }, - ], - usage: { total_tokens: 10 }, - }), - }); - vi.stubGlobal("fetch", mockFetch); - - const client = new CodexClient({ - accessToken: "expired-token", - onTokenRefresh: onRefresh, - }); - - const result = await client.generateCompletion({ - systemPrompt: "test", - userPrompt: "test", - }); - - expect(onRefresh).toHaveBeenCalledOnce(); - expect(result.content).toBe('{"test":true}'); - // Second call uses refreshed token - expect(mockFetch.mock.calls[1][1].headers.Authorization).toBe( - `Bearer ${newToken}`, - ); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -pnpm test src/lib/ai/__tests__/codex-client.test.ts -``` - -- [ ] **Step 3: Write implementation** - -```typescript -import type { AIProvider, CompletionParams, CompletionResult } from "./types"; - -const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; -const CODEX_MODEL = "gpt-5.3-codex"; - -interface CodexClientConfig { - accessToken: string; - onTokenRefresh: () => Promise; -} - -export class CodexClient implements AIProvider { - readonly type = "codex" as const; - private accessToken: string; - private onTokenRefresh: () => Promise; - - constructor(config: CodexClientConfig) { - this.accessToken = config.accessToken; - this.onTokenRefresh = config.onTokenRefresh; - } - - async generateCompletion(params: CompletionParams): Promise { - const result = await this.doRequest(params); - - // Retry once on 401 after refreshing token - if (result.status === 401) { - this.accessToken = await this.onTokenRefresh(); - return this.parseResponse(await this.doRequest(params, true)); - } - - return this.parseResponse(result); - } - - private async doRequest( - params: CompletionParams, - isRetry = false, - ): Promise { - const res = await fetch(CODEX_ENDPOINT, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.accessToken}`, - }, - body: JSON.stringify({ - model: CODEX_MODEL, - instructions: params.systemPrompt, - input: params.userPrompt, - stream: false, - }), - }); - - if (!res.ok && res.status !== 401) { - const body = await res.text(); - throw new Error(`Codex request failed (${res.status}): ${body}`); - } - - if (!res.ok && res.status === 401 && isRetry) { - throw new Error("Codex request failed after token refresh (401)"); - } - - return res as Response & { status: number }; - } - - private async parseResponse(res: Response): Promise { - const json = await res.json(); - - // Codex Responses API format - const messageOutput = json.output?.find( - (o: any) => o.type === "message", - ); - const textContent = messageOutput?.content?.find( - (c: any) => c.type === "output_text", - ); - - if (!textContent?.text) { - throw new Error("Codex returned empty content"); - } - - return { - content: textContent.text, - tokensUsed: json.usage?.total_tokens ?? null, - model: CODEX_MODEL, - providerType: "codex", - }; - } -} -``` - -- [ ] **Step 4: Run test** - -```bash -pnpm test src/lib/ai/__tests__/codex-client.test.ts -``` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/ai/codex-client.ts src/lib/ai/__tests__/codex-client.test.ts -git commit -m "feat(ai): add Codex client with auto-refresh on 401" -``` - ---- - -### Task 6: Provider Resolution - -**Files:** -- Create: `src/lib/ai/provider.ts` -- Test: `src/lib/ai/__tests__/provider.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -import { describe, it, expect, vi, beforeEach } from "vitest"; - -// Mock prisma before importing provider -vi.mock("@/lib/db", () => ({ - prisma: { - user: { findUnique: vi.fn() }, - appSettings: { findUnique: vi.fn() }, - }, -})); -vi.mock("@/lib/crypto", () => ({ - decrypt: vi.fn((v: string) => `decrypted:${v}`), - encrypt: vi.fn((v: string) => `encrypted:${v}`), -})); - -import { resolveProvider } from "../provider"; -import { prisma } from "@/lib/db"; - -describe("resolveProvider", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns codex provider when user has valid tokens", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - codexAccessTokenEncrypted: "enc-access", - codexRefreshTokenEncrypted: "enc-refresh", - codexTokenExpiresAt: new Date(Date.now() + 3600000), - codexConnectionStatus: "connected", - } as any); - - const provider = await resolveProvider("user-123"); - expect(provider.type).toBe("codex"); - }); - - it("returns admin-key when user has no codex but admin key exists", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - codexAccessTokenEncrypted: null, - codexRefreshTokenEncrypted: null, - codexTokenExpiresAt: null, - codexConnectionStatus: "disconnected", - } as any); - - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - adminAiKeyEncrypted: "enc-admin-key", - adminAiModel: "gpt-4o-mini", - adminAiBaseUrl: "https://api.openai.com/v1", - } as any); - - const provider = await resolveProvider("user-123"); - expect(provider.type).toBe("admin-key"); - }); - - it("returns none when nothing is configured", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - codexAccessTokenEncrypted: null, - codexConnectionStatus: "disconnected", - } as any); - - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - adminAiKeyEncrypted: null, - } as any); - - const provider = await resolveProvider("user-123"); - expect(provider.type).toBe("none"); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -pnpm test src/lib/ai/__tests__/provider.test.ts -``` - -- [ ] **Step 3: Write implementation** - -```typescript -import { prisma } from "@/lib/db"; -import { decrypt, encrypt } from "@/lib/crypto"; -import { CodexClient } from "./codex-client"; -import { OpenAIClient } from "./openai-client"; -import { refreshAccessToken, encryptTokens } from "./codex-oauth"; -import type { AIProvider, CompletionParams, CompletionResult } from "./types"; - -class NoProvider implements AIProvider { - readonly type = "none" as const; - async generateCompletion(): Promise { - throw new Error("No AI provider configured"); - } -} - -export async function resolveProvider(userId: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - codexAccessTokenEncrypted: true, - codexRefreshTokenEncrypted: true, - codexTokenExpiresAt: true, - codexConnectionStatus: true, - }, - }); - - // 1. Try Codex OAuth - if ( - user?.codexAccessTokenEncrypted && - user.codexRefreshTokenEncrypted && - user.codexConnectionStatus === "connected" - ) { - let accessToken = decrypt(user.codexAccessTokenEncrypted); - - // Proactive refresh if token expires within 5 minutes - if ( - user.codexTokenExpiresAt && - user.codexTokenExpiresAt.getTime() < Date.now() + 5 * 60 * 1000 - ) { - try { - const refreshToken = decrypt(user.codexRefreshTokenEncrypted); - const tokens = await refreshAccessToken(refreshToken); - const encrypted = encryptTokens({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - }); - - await prisma.user.update({ - where: { id: userId }, - data: { - codexAccessTokenEncrypted: encrypted.accessEncrypted, - codexRefreshTokenEncrypted: encrypted.refreshEncrypted, - codexTokenExpiresAt: new Date( - Date.now() + tokens.expires_in * 1000, - ), - }, - }); - - accessToken = tokens.access_token; - } catch { - // Proactive refresh failed, try with current token - } - } - - return new CodexClient({ - accessToken, - onTokenRefresh: async () => { - const current = await prisma.user.findUnique({ - where: { id: userId }, - select: { codexRefreshTokenEncrypted: true }, - }); - - if (!current?.codexRefreshTokenEncrypted) { - throw new Error("No refresh token available"); - } - - const refreshToken = decrypt(current.codexRefreshTokenEncrypted); - const tokens = await refreshAccessToken(refreshToken); - const encrypted = encryptTokens({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - }); - - await prisma.user.update({ - where: { id: userId }, - data: { - codexAccessTokenEncrypted: encrypted.accessEncrypted, - codexRefreshTokenEncrypted: encrypted.refreshEncrypted, - codexTokenExpiresAt: new Date( - Date.now() + tokens.expires_in * 1000, - ), - }, - }); - - return tokens.access_token; - }, - }); - } - - // 2. Try admin key - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { - adminAiKeyEncrypted: true, - adminAiModel: true, - adminAiBaseUrl: true, - }, - }); - - if (settings?.adminAiKeyEncrypted) { - return new OpenAIClient({ - apiKey: decrypt(settings.adminAiKeyEncrypted), - model: settings.adminAiModel, - baseUrl: settings.adminAiBaseUrl, - }); - } - - // 3. No provider - return new NoProvider(); -} -``` - -- [ ] **Step 4: Run test** - -```bash -pnpm test src/lib/ai/__tests__/provider.test.ts -``` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/ai/provider.ts src/lib/ai/__tests__/provider.test.ts -git commit -m "feat(ai): add provider resolution with codex → admin-key → none hierarchy" -``` - ---- - -## Phase 2: OAuth Routes & Settings - -### Task 7: OAuth Authorize Route - -**Files:** -- Create: `src/app/api/auth/codex/authorize/route.ts` - -- [ ] **Step 1: Write the route** - -```typescript -import { NextRequest, NextResponse } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { generatePKCE, generateState, buildAuthorizationUrl } from "@/lib/ai/codex-oauth"; -import { cookies } from "next/headers"; -import { annotate } from "@/lib/logging/context"; - -export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - - const { verifier, challenge } = generatePKCE(); - const state = generateState(); - - const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL; - if (!appUrl) throw new Error("APP_URL not configured"); - - const redirectUri = `${appUrl}/api/auth/codex/callback`; - - // Store PKCE verifier and state in short-lived cookies (5 min) - const cookieStore = await cookies(); - const cookieOptions = { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax" as const, - path: "/", - maxAge: 300, - }; - - cookieStore.set("codex_verifier", verifier, cookieOptions); - cookieStore.set("codex_state", state, cookieOptions); - - const authUrl = buildAuthorizationUrl({ - codeChallenge: challenge, - state, - redirectUri, - }); - - annotate({ action: { name: "codex.oauth.authorize" } }); - - return NextResponse.redirect(authUrl); -}); -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/app/api/auth/codex/authorize/route.ts -git commit -m "feat(oauth): add Codex OAuth authorize endpoint with PKCE" -``` - ---- - -### Task 8: OAuth Callback Route - -**Files:** -- Create: `src/app/api/auth/codex/callback/route.ts` - -- [ ] **Step 1: Write the route** - -```typescript -import { NextRequest, NextResponse } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { exchangeCodeForTokens, encryptTokens } from "@/lib/ai/codex-oauth"; -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; -import { getClientIp } from "@/lib/api-response"; -import { cookies } from "next/headers"; -import { annotate } from "@/lib/logging/context"; - -export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - const url = new URL(request.url); - - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const error = url.searchParams.get("error"); - - const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL; - if (!appUrl) throw new Error("APP_URL not configured"); - - // Handle OAuth error - if (error) { - annotate({ meta: { oauth_error: error } }); - return NextResponse.redirect( - `${appUrl}/settings?codex_error=${encodeURIComponent(error)}`, - ); - } - - if (!code || !state) { - return NextResponse.redirect(`${appUrl}/settings?codex_error=missing_params`); - } - - // Validate state against cookie - const cookieStore = await cookies(); - const storedState = cookieStore.get("codex_state")?.value; - const storedVerifier = cookieStore.get("codex_verifier")?.value; - - // Clean up cookies immediately - cookieStore.delete("codex_state"); - cookieStore.delete("codex_verifier"); - - if (!storedState || !storedVerifier || state !== storedState) { - return NextResponse.redirect(`${appUrl}/settings?codex_error=invalid_state`); - } - - // Exchange code for tokens - const redirectUri = `${appUrl}/api/auth/codex/callback`; - - try { - const tokens = await exchangeCodeForTokens({ - code, - codeVerifier: storedVerifier, - redirectUri, - }); - - const encrypted = encryptTokens({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - }); - - await prisma.user.update({ - where: { id: user.id }, - data: { - codexAccessTokenEncrypted: encrypted.accessEncrypted, - codexRefreshTokenEncrypted: encrypted.refreshEncrypted, - codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), - codexConnectedAt: new Date(), - codexConnectionStatus: "connected", - // Clear insight cache so new provider generates fresh insights - insightsCachedAt: null, - insightsCachedText: null, - }, - }); - - await auditLog("codex.oauth.connected", { - userId: user.id, - ipAddress: getClientIp(request), - }); - - annotate({ action: { name: "codex.oauth.callback.success" } }); - - return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`); - } catch (err) { - const message = err instanceof Error ? err.message : "unknown"; - annotate({ meta: { codex_token_error: message } }); - return NextResponse.redirect( - `${appUrl}/settings?codex_error=token_exchange_failed`, - ); - } -}); -``` - -- [ ] **Step 2: Add callback to PUBLIC_PATHS in proxy.ts** - -In `src/proxy.ts`, add to the PUBLIC_PATHS array: - -```typescript - "/api/auth/codex/callback", -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/app/api/auth/codex/callback/route.ts src/proxy.ts -git commit -m "feat(oauth): add Codex OAuth callback with CSRF validation and token storage" -``` - ---- - -### Task 9: OAuth Disconnect Route - -**Files:** -- Create: `src/app/api/auth/codex/disconnect/route.ts` - -- [ ] **Step 1: Write the route** - -```typescript -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess } from "@/lib/api-response"; -import { getClientIp } from "@/lib/api-response"; -import { NextRequest } from "next/server"; -import { annotate } from "@/lib/logging/context"; - -export const DELETE = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - - await prisma.user.update({ - where: { id: user.id }, - data: { - codexAccessTokenEncrypted: null, - codexRefreshTokenEncrypted: null, - codexTokenExpiresAt: null, - codexConnectedAt: null, - codexConnectionStatus: "disconnected", - insightsCachedAt: null, - insightsCachedText: null, - }, - }); - - await auditLog("codex.oauth.disconnected", { - userId: user.id, - ipAddress: getClientIp(request), - }); - - annotate({ action: { name: "codex.oauth.disconnect" } }); - - return apiSuccess({ disconnected: true }); -}); -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/app/api/auth/codex/disconnect/route.ts -git commit -m "feat(oauth): add Codex disconnect endpoint" -``` - ---- - -### Task 10: Admin AI Settings Route - -**Files:** -- Create: `src/app/api/admin/ai-settings/route.ts` - -- [ ] **Step 1: Write the route** - -```typescript -import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; -import { encrypt, decrypt } from "@/lib/crypto"; -import { NextRequest } from "next/server"; -import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; - -export const GET = apiHandler(async () => { - await requireAdmin(); - - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { - adminAiKeyEncrypted: true, - adminAiModel: true, - adminAiBaseUrl: true, - }, - }); - - annotate({ action: { name: "admin.ai-settings.get" } }); - - return apiSuccess({ - hasKey: !!settings?.adminAiKeyEncrypted, - keyPreview: settings?.adminAiKeyEncrypted - ? `${decrypt(settings.adminAiKeyEncrypted).slice(0, 7)}...` - : null, - model: settings?.adminAiModel ?? "gpt-4o-mini", - baseUrl: settings?.adminAiBaseUrl ?? "https://api.openai.com/v1", - }); -}); - -export const PUT = apiHandler(async (request: NextRequest) => { - await requireAdmin(); - - const { data: body, error: jsonError } = await safeJson>(request); - if (jsonError) return jsonError; - - const data: Record = {}; - - if (typeof body.apiKey === "string") { - const key = (body.apiKey as string).trim(); - if (key === "") { - data.adminAiKeyEncrypted = null; - } else { - data.adminAiKeyEncrypted = encrypt(key); - } - } - - if (typeof body.model === "string") { - data.adminAiModel = (body.model as string).trim(); - } - - if (typeof body.baseUrl === "string") { - const url = (body.baseUrl as string).trim(); - if (url && !url.startsWith("https://")) { - return apiError("Base URL must use HTTPS", 422); - } - data.adminAiBaseUrl = url || "https://api.openai.com/v1"; - } - - if (Object.keys(data).length === 0) { - return apiError("No changes", 422); - } - - await prisma.appSettings.upsert({ - where: { id: "singleton" }, - update: data, - create: { id: "singleton", ...data }, - }); - - annotate({ action: { name: "admin.ai-settings.update" } }); - - return apiSuccess({ updated: true }); -}); -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/app/api/admin/ai-settings/route.ts -git commit -m "feat(admin): add AI settings route for admin key management" -``` - ---- - -## Phase 3: Update Insight Generators - -### Task 11: Update insights/settings route - -**Files:** -- Modify: `src/app/api/insights/settings/route.ts` - -- [ ] **Step 1: Rewrite to use Codex status** - -Replace the entire file with: - -```typescript -import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; -import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; - -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - codexConnectionStatus: true, - codexConnectedAt: true, - insightsPrivacyMode: true, - insightsCachedAt: true, - }, - }); - - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { adminAiKeyEncrypted: true }, - }); - - annotate({ action: { name: "insights.settings.get" } }); - - return apiSuccess({ - codexStatus: dbUser?.codexConnectionStatus ?? "disconnected", - codexConnectedAt: dbUser?.codexConnectedAt ?? null, - hasAdminKey: !!settings?.adminAiKeyEncrypted, - privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated", - lastInsightAt: dbUser?.insightsCachedAt ?? null, - }); -}); - -export const PUT = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - - const { data: body, error: jsonError } = await safeJson>(request); - if (jsonError) return jsonError; - - const data: Record = {}; - - if (typeof body.privacyMode === "string") { - const mode = body.privacyMode as string; - if (!["aggregated", "raw"].includes(mode)) { - return apiError("Invalid privacy mode", 422); - } - data.insightsPrivacyMode = mode; - data.insightsCachedAt = null; - data.insightsCachedText = null; - } - - if (Object.keys(data).length === 0) { - return apiError("No changes", 422); - } - - await prisma.user.update({ - where: { id: user.id }, - data, - }); - - annotate({ action: { name: "insights.settings.update" } }); - - return apiSuccess({ updated: true }); -}); -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/app/api/insights/settings/route.ts -git commit -m "refactor(insights): update settings route for Codex OAuth status" -``` - ---- - -### Task 12: Update insights/generate route - -**Files:** -- Modify: `src/app/api/insights/generate/route.ts` - -- [ ] **Step 1: Rewrite to use provider abstraction** - -Replace the entire file content. Key changes: use `resolveProvider()` instead of direct OpenAI fetch, validate output with Zod schema, use new InsightResult type. - -```typescript -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; -import { extractFeatures } from "@/lib/insights/features"; -import { buildGeneratePrompts } from "@/lib/ai/prompts/general-status"; -import { insightResultSchema, type InsightResult } from "@/lib/ai/types"; -import { resolveProvider } from "@/lib/ai/provider"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; - -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - const userId = user.id; - - const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000); - if (!rl.allowed) { - return apiError("Maximum 2 insight generations per hour.", 429); - } - - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - insightsPrivacyMode: true, - insightsCachedAt: true, - insightsCachedText: true, - }, - }); - - const body = await request.json().catch(() => ({})); - const forceRefresh = body.force === true; - - if ( - !forceRefresh && - dbUser?.insightsCachedAt && - dbUser.insightsCachedText && - Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 - ) { - try { - const cached = JSON.parse(dbUser.insightsCachedText) as InsightResult; - annotate({ action: { name: "insights.generate" }, meta: { cached: true } }); - return apiSuccess({ insights: cached, cached: true, cachedAt: dbUser.insightsCachedAt }); - } catch { - // Invalid cache, regenerate - } - } - - const provider = await resolveProvider(userId); - - if (provider.type === "none") { - return apiError("No AI provider configured. Connect ChatGPT or ask your admin to set up an API key.", 422); - } - - const includeRaw = dbUser?.insightsPrivacyMode === "raw"; - const features = await extractFeatures(userId, includeRaw); - const { systemPrompt, userPrompt } = buildGeneratePrompts( - JSON.stringify(features, null, 2), - dbUser?.insightsPrivacyMode ?? "aggregated", - ); - - const result = await provider.generateCompletion({ - systemPrompt, - userPrompt, - temperature: 0.3, - maxTokens: 1500, - }); - - let insights: InsightResult; - try { - const parsed = JSON.parse(result.content); - insights = insightResultSchema.parse(parsed); - } catch { - return apiError("Failed to parse AI response", 502); - } - - await prisma.user.update({ - where: { id: userId }, - data: { - insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(insights), - }, - }); - - await auditLog("insights.generate", { - userId, - ipAddress: getClientIp(request), - details: { - privacyMode: dbUser?.insightsPrivacyMode, - tokensUsed: result.tokensUsed, - providerType: result.providerType, - model: result.model, - }, - }); - - annotate({ - action: { name: "insights.generate" }, - meta: { - cached: false, - providerType: result.providerType, - model: result.model, - tokensUsed: result.tokensUsed, - }, - }); - - return apiSuccess({ insights, cached: false }); -}); -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/app/api/insights/generate/route.ts -git commit -m "refactor(insights): use provider abstraction in generate route" -``` - ---- - -## Phase 4: Medical Prompts - -### Task 13: Create prompt files - -**Files:** -- Create: `src/lib/ai/prompts/base-system.ts` -- Create: `src/lib/ai/prompts/general-status.ts` -- Create: `src/lib/ai/prompts/blood-pressure.ts` -- Create: `src/lib/ai/prompts/weight.ts` -- Create: `src/lib/ai/prompts/pulse.ts` -- Create: `src/lib/ai/prompts/bmi.ts` -- Create: `src/lib/ai/prompts/medication-compliance.ts` -- Create: `src/lib/ai/prompts/schema.ts` - -This task creates all prompt files. Each follows a consistent pattern with the base system prompt + domain-specific instructions + the new InsightResult JSON schema. - -Detailed prompt content will be generated during implementation based on the spec's medical guidelines (ESC/ESH 2023, WHO BMI, DGE). The prompts instruct the model to return the `InsightResult` JSON schema and include leitlinien references. - -- [ ] **Step 1: Create base-system.ts with shared medical context** - -- [ ] **Step 2: Create all 6 domain-specific prompt files** - -- [ ] **Step 3: Create schema.ts with the JSON schema description for the model** - -- [ ] **Step 4: Write tests that verify prompt assembly** - -- [ ] **Step 5: Commit** - -```bash -git add src/lib/ai/prompts/ -git commit -m "feat(prompts): add medical insight prompts with ESC/ESH/WHO guidelines" -``` - ---- - -## Phase 5: Update Individual Insight Generators - -### Task 14: Refactor insight generators to use provider - -Each of the 6 insight generators (`general-status.ts`, `blood-pressure-status.ts`, `weight-status.ts`, `pulse-status.ts`, `bmi-status.ts`, `medication-compliance-status.ts`) follows the same pattern. Refactor each to: - -1. Replace `decrypt(user.openaiKeyEncrypted)` with `resolveProvider(userId)` -2. Replace direct `fetch("https://api.openai.com/...")` with `provider.generateCompletion()` -3. Use new domain-specific prompts from `src/lib/ai/prompts/` -4. Return `InsightResult` structure instead of `{ summary: string }` - -- [ ] **Step 1: Refactor general-status.ts** -- [ ] **Step 2: Refactor blood-pressure-status.ts** -- [ ] **Step 3: Refactor weight-status.ts** -- [ ] **Step 4: Refactor pulse-status.ts** -- [ ] **Step 5: Refactor bmi-status.ts** -- [ ] **Step 6: Refactor medication-compliance-status.ts** -- [ ] **Step 7: Update reminder-worker.ts to pass through correctly** -- [ ] **Step 8: Run full test suite** - -```bash -pnpm test -``` - -- [ ] **Step 9: Commit** - -```bash -git add src/lib/insights/ src/lib/jobs/ -git commit -m "refactor(insights): use provider abstraction in all 6 insight generators" -``` - ---- - -## Phase 6: Remove Legacy Code - -### Task 15: Clean up old OpenAI references - -- [ ] **Step 1: Remove `src/lib/insights/prompt.ts` (replaced by `src/lib/ai/prompts/`)** - -- [ ] **Step 2: Update `src/app/api/insights/comprehensive/route.ts`** — change `hasOpenAiKey` to check provider availability - -- [ ] **Step 3: Update CSP in `src/proxy.ts`** — add `https://chatgpt.com` to `connect-src` - -- [ ] **Step 4: Search for any remaining `openaiKeyEncrypted` references** - -```bash -grep -r "openaiKeyEncrypted\|openai_key_encrypted" src/ --include="*.ts" -``` - -Fix any found. - -- [ ] **Step 5: Run typecheck** - -```bash -pnpm typecheck -``` - -- [ ] **Step 6: Run all tests** - -```bash -pnpm test -``` - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "chore: remove legacy OpenAI key references, update CSP" -``` - ---- - -## Phase 7: Build, Deploy, Verify - -### Task 16: Build and deploy - -- [ ] **Step 1: Run full build** - -```bash -pnpm build -``` - -- [ ] **Step 2: Push to GitHub (triggers auto-deploy via webhook)** - -```bash -git push origin main -``` - -- [ ] **Step 3: Monitor deployment** - -Check Coolify deployment status for HealthLog on apps-01. - -- [ ] **Step 4: Run database migration on production** - -The migration should run automatically via the Dockerfile entrypoint. Verify in logs. - -- [ ] **Step 5: Verify app is running** - -```bash -curl -s https://healthlog.bombeck.io/api/health -``` - ---- - -### Task 17: Security Audit - -- [ ] **Step 1: Pre-deploy code review** — Check all new files for: - - Token leaks (tokens never sent to client) - - Proper encryption (AES-256-GCM for all secrets) - - CSRF protection (state parameter validation) - - Input validation (Zod schemas) - - Rate limiting on new endpoints - -- [ ] **Step 2: OAuth flow audit** — Validate against OWASP OAuth 2.0 Security Checklist - -- [ ] **Step 3: Penetration test** — Test OAuth callback with: - - Invalid state parameter - - Replayed authorization codes - - Missing PKCE verifier - - Token injection attempts - ---- - -### Task 18: Post-deploy verification - -- [ ] **Step 1: Test OAuth flow** — Navigate to settings, click "Mit ChatGPT verbinden", complete flow -- [ ] **Step 2: Test insight generation** — Trigger manual insight generation -- [ ] **Step 3: Test admin key** — Set admin key in settings, verify fallback works -- [ ] **Step 4: Test background jobs** — Check pg-boss logs for insight job execution -- [ ] **Step 5: Verify Loki logs** — No errors from new code paths diff --git a/docs/superpowers/specs/2026-04-06-codex-oauth-insights-design.md b/docs/superpowers/specs/2026-04-06-codex-oauth-insights-design.md deleted file mode 100644 index 2f3b42a0d..000000000 --- a/docs/superpowers/specs/2026-04-06-codex-oauth-insights-design.md +++ /dev/null @@ -1,359 +0,0 @@ -# Codex OAuth + Medizinische Insights — Design Spec - -**Datum:** 2026-04-06 -**Status:** Approved -**Scope:** Replace OpenAI API-Key flow with ChatGPT Codex OAuth, improve medical insight prompts - -## 1. Problem - -HealthLog uses user-provided OpenAI API keys for AI-powered health insights. The API quota ran out (`insufficient_quota`), breaking all insight generation and background jobs. Users shouldn't need to manage API billing. - -## 2. Solution - -Replace the API-key model with **OpenAI Codex OAuth** — users authenticate with their ChatGPT Pro/Max subscription, and insights run against their flat-rate plan. An admin-level API key serves as a global fallback. - -## 3. Provider Hierarchy - -``` -1. User has Codex OAuth connection → Codex Responses API (gpt-5.3-codex) -2. Admin key configured → Standard OpenAI Chat Completions API (configurable model) -3. Nothing configured → Local fallback texts (no-key-fallbacks.ts) -``` - -## 4. Architecture - -### New Files - -``` -src/lib/ai/ - provider.ts — resolveProvider(userId) → { type, client, model } - codex-oauth.ts — PKCE generation, token exchange, refresh - codex-client.ts — Codex Responses API client with auto-refresh - openai-client.ts — Standard OpenAI client (admin key, configurable base URL) - types.ts — Shared types (InsightResult, ProviderConfig, etc.) - prompts/ - base-system.ts — Shared medical context, role, constraints - general-status.ts — Holistic health assessment prompt - blood-pressure.ts — ESC/ESH guideline-based BP analysis - weight.ts — BMI context, trend analysis, plateau detection - pulse.ts — Resting HR trends, stress correlation - bmi.ts — WHO classification, age-adjusted - medication-compliance.ts — Intake patterns, efficacy correlation - schema.ts — Zod schema for InsightResult validation -``` - -### Modified Files - -``` -src/app/api/auth/codex/authorize/route.ts — NEW: Start OAuth flow -src/app/api/auth/codex/callback/route.ts — NEW: Handle OAuth callback -src/app/api/auth/codex/disconnect/route.ts — NEW: Remove tokens -src/app/api/admin/ai-settings/route.ts — NEW: Admin key management -src/app/api/insights/generate/route.ts — MODIFY: Use provider abstraction -src/app/api/insights/settings/route.ts — MODIFY: Show Codex status instead of API key -src/app/api/insights/*/route.ts — MODIFY: New output schema -src/lib/insights/*.ts — MODIFY: Use new prompts + provider -src/lib/jobs/reminder-worker.ts — MODIFY: Use provider with auto-refresh -src/proxy.ts — MODIFY: Add /api/auth/codex/callback to PUBLIC_PATHS -prisma/schema.prisma — MODIFY: New fields, remove openaiKeyEncrypted -``` - -### Removed Files/Code - -``` -src/app/api/insights/settings/route.ts — OpenAI key PUT handler (replaced) -src/lib/insights/prompt.ts — Single prompt file (replaced by prompts/) -User.openaiKeyEncrypted — DB field removed -``` - -## 5. OAuth Flow - -### Authorization (User-initiated) - -``` -1. User clicks "Mit ChatGPT verbinden" in Settings -2. GET /api/auth/codex/authorize - - Generate PKCE code_verifier (128 bytes, base64url) - - Compute code_challenge = SHA-256(code_verifier), base64url - - Generate random state parameter - - Store code_verifier + state in session - - Redirect to: chatgpt.com/authorize? - response_type=code - &code_challenge= - &code_challenge_method=S256 - &redirect_uri=https://healthlog.bombeck.io/api/auth/codex/callback - &state= -3. User logs in at ChatGPT, grants access -4. Redirect to /api/auth/codex/callback?code=...&state=... -5. Validate state against session -6. Exchange code for tokens (POST chatgpt.com/oauth/token) -7. Encrypt access_token + refresh_token with AES-256-GCM -8. Store in User record with expiresAt -9. Redirect to /settings with success toast -``` - -### Token Refresh (Automatic) - -``` -Before every API call: - if codexTokenExpiresAt < now + 5min: - POST chatgpt.com/oauth/token (grant_type=refresh_token) - Store new encrypted tokens + new expiresAt - If refresh fails: - Increment failCount - If failCount >= 3 over 72h: - Set codexConnectionStatus = 'expired' - Fall back to admin key silently - Else: - Retry on next call - -On 401 response: - Refresh token immediately, retry request once -``` - -### Disconnect - -``` -DELETE /api/auth/codex/disconnect - - Delete tokens from DB - - Revoke at OpenAI if endpoint exists - - Audit log entry -``` - -## 6. Database Changes - -### Prisma Schema - -```prisma -model User { - // REMOVE: - // openaiKeyEncrypted String? - - // ADD: - codexAccessTokenEncrypted String? - codexRefreshTokenEncrypted String? - codexTokenExpiresAt DateTime? - codexConnectedAt DateTime? - codexConnectionStatus String @default("disconnected") - // Values: "connected", "disconnected", "expired" -} - -model AppSettings { - // ADD: - adminAiKeyEncrypted String? - adminAiModel String @default("gpt-4o-mini") - adminAiBaseUrl String @default("https://api.openai.com/v1") -} -``` - -### Migration - -```sql --- Add new columns -ALTER TABLE users ADD COLUMN codex_access_token_encrypted TEXT; -ALTER TABLE users ADD COLUMN codex_refresh_token_encrypted TEXT; -ALTER TABLE users ADD COLUMN codex_token_expires_at TIMESTAMP; -ALTER TABLE users ADD COLUMN codex_connected_at TIMESTAMP; -ALTER TABLE users ADD COLUMN codex_connection_status TEXT NOT NULL DEFAULT 'disconnected'; - -ALTER TABLE app_settings ADD COLUMN admin_ai_key_encrypted TEXT; -ALTER TABLE app_settings ADD COLUMN admin_ai_model TEXT NOT NULL DEFAULT 'gpt-4o-mini'; -ALTER TABLE app_settings ADD COLUMN admin_ai_base_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1'; - --- Remove old column -ALTER TABLE users DROP COLUMN openai_key_encrypted; -``` - -## 7. Insight Output Schema - -### New InsightResult (replaces old changed/stable/drivers/nextSteps) - -```typescript -interface InsightResult { - summary: string - classification: 'optimal' | 'gut' | 'grenzwertig' | 'erhoht' | 'kritisch' - findings: { - label: string - value: string - assessment: 'positive' | 'neutral' | 'attention' | 'warning' - guideline?: string - }[] - correlations: { - factor: string - effect: string - confidence: 'hoch' | 'mittel' | 'gering' - }[] - recommendations: string[] - dataQuality: { - coverage: string - gaps: string[] - confidence: 'hoch' | 'mittel' | 'gering' - } - disclaimer: string -} -``` - -### Zod Validation - -Every AI response is validated against a Zod schema before being stored. -Invalid responses are logged as errors and the user gets a generic fallback. - -## 8. Prompt Design - -### Base System Prompt - -``` -Du bist ein klinischer Gesundheitsanalyst mit Expertise in Innerer Medizin -und Präventivmedizin. Deine Analyse basiert auf aktuellen Leitlinien -(ESC/ESH 2023, WHO, DGE, DEGAM). - -Regeln: -- Evidenzbasiert: Referenziere Grenzwerte und Leitlinien explizit -- Muster erkennen: Tageszeit, Wochentag, saisonale Trends -- Korrelationen: Wechselwirkungen zwischen Medikation und Vitalwerten benennen -- Limitationen: Klar kommunizieren was die Datenlage hergibt und was nicht -- Kein Ersatz für ärztliche Diagnose — immer explizit als Disclaimer -- Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich -- Antworte ausschließlich im vorgegebenen JSON-Schema -``` - -### Per-Type Specialization - -Each insight type adds domain-specific instructions: - -- **Blood Pressure**: ESC/ESH classification (optimal <120/80, normal <130/85, ...), morning surge detection, white-coat vs. masked hypertension hints, medication correlation -- **Weight**: BMI-adjusted analysis, 7/30/90-day slope, plateau detection (±0.5kg over 14+ days), realistic goal projection -- **Pulse**: Resting HR zones (bradycardia <50, athletic 50-60, normal 60-100, tachycardia >100), trend stability, stress/recovery indicators -- **BMI**: WHO classification, waist-to-height hints if available, age context -- **Medication Compliance**: Pattern analysis (morning vs evening adherence), streak tracking, correlation with vital sign improvements -- **General Status**: Cross-domain synthesis, risk stratification, most impactful change recommendation - -## 9. UI Changes - -### User Settings (/settings) - -Replace "OpenAI API Key" section with: - -``` -KI-Insights -┌─────────────────────────────────────────┐ -│ Status: 🟢 Verbunden mit ChatGPT │ -│ Verbunden seit: 02.04.2026 │ -│ │ -│ [Trennen] │ -│ │ -│ ℹ️ Insights werden über dein ChatGPT- │ -│ Abo generiert (keine zusätzlichen │ -│ Kosten). │ -└─────────────────────────────────────────┘ -``` - -Or when disconnected: - -``` -KI-Insights -┌─────────────────────────────────────────┐ -│ Status: ⚪ Nicht verbunden │ -│ │ -│ [Mit ChatGPT verbinden] │ -│ │ -│ ℹ️ Verbinde dein ChatGPT Pro/Max-Konto │ -│ um KI-gestützte Gesundheitsanalysen zu │ -│ erhalten. │ -└─────────────────────────────────────────┘ -``` - -### Admin Settings (/settings — ADMIN only) - -``` -KI-Anbieter (Global) -┌─────────────────────────────────────────┐ -│ API Key: [sk-...****________] [Testen] │ -│ Modell: [gpt-4o-mini ▾] │ -│ Base URL: [https://api.openai.com/v1] │ -│ │ -│ ℹ️ Wird als Fallback verwendet, wenn │ -│ ein User keine eigene ChatGPT- │ -│ Verbindung hat. │ -│ │ -│ [Speichern] │ -└─────────────────────────────────────────┘ -``` - -### Insights Display - -Update insight cards to render the richer InsightResult: -- Classification badge with color (green/yellow/orange/red) -- Findings list with assessment icons -- Collapsible correlations section -- Data quality indicator (small badge) -- Disclaimer footer (smaller text) - -## 10. Security - -### OAuth Security -- PKCE (S256) prevents authorization code interception -- State parameter prevents CSRF -- Tokens encrypted at rest (AES-256-GCM via existing crypto.ts) -- Tokens never sent to client/browser -- Refresh token rotation on every refresh -- Callback route validates state against session - -### Admin Key Security -- Only ADMIN role can read/write -- Key displayed masked in UI (sk-...****) -- Rate limiting on admin key usage (prevents abuse by many users) -- Encrypted at rest - -### Audit Trail -- Every OAuth connect/disconnect logged -- Every insight generation logs: provider type, user, model, duration -- Admin key changes logged - -### Security Audits (3x) -1. **Pre-Implementation**: OAuth flow validated against OWASP OAuth Security Checklist -2. **Post-Implementation**: Code review agent checks all new files for injection, token leaks, unencrypted secrets -3. **Post-Deploy**: Penetration test of OAuth endpoints (CSRF, token theft, callback manipulation) - -## 11. Testing Strategy (TDD) - -### Unit Tests (vitest) - -| Suite | Coverage | -|-------|----------| -| `ai/provider.test.ts` | Provider hierarchy, correct client selection | -| `ai/codex-oauth.test.ts` | PKCE generation, token exchange, refresh, error scenarios | -| `ai/codex-client.test.ts` | Request format, auto-refresh on 401, retry with backoff | -| `ai/openai-client.test.ts` | Chat completions format, custom base URL, error handling | -| `ai/prompts/*.test.ts` | Prompt assembly per type, output schema validation with Zod | -| `insights/*.test.ts` | Updated generators with new output schema | -| `api/codex-callback.test.ts` | State validation, CSRF protection, token storage | -| `api/admin-settings.test.ts` | ADMIN-only access, key encryption, validation | - -### Integration Tests -- OAuth flow end-to-end (mocked ChatGPT endpoint) -- Background job token refresh + insight generation -- Provider fallback chain under different DB states - -## 12. CSP Changes - -```diff -- connect-src 'self' https://api.openai.com https://wbsapi.withings.net -+ connect-src 'self' https://api.openai.com https://chatgpt.com https://wbsapi.withings.net -``` - -## 13. Migration Path - -1. Deploy new code with both old (openaiKeyEncrypted) and new fields -2. Run migration to add new columns -3. Remove old column in follow-up migration -4. Users see "Nicht verbunden" in settings, can connect via OAuth -5. Admin can configure fallback key immediately -6. Old cached insights remain visible until refreshed - -## 14. Out of Scope - -- Multiple AI providers per user (only Codex OAuth OR admin key) -- Custom model selection per user (model is fixed per provider) -- Streaming responses (batch only, like current implementation) -- OpenAI API key input for individual users (removed entirely) diff --git a/docs/superpowers/specs/2026-04-18-blood-glucose-tracking-design.md b/docs/superpowers/specs/2026-04-18-blood-glucose-tracking-design.md deleted file mode 100644 index e0c6b99be..000000000 --- a/docs/superpowers/specs/2026-04-18-blood-glucose-tracking-design.md +++ /dev/null @@ -1,207 +0,0 @@ -# Blood Glucose Tracking — Design Spec - -**Datum:** 2026-04-18 -**Status:** Draft -**Scope:** Add `BLOOD_GLUCOSE` as a first-class measurement type with context-aware classification (fasting / postprandial / random / bedtime), user-selectable display unit (mg/dL ↔ mmol/L), reference ranges from ADA 2024 and DGIM 2023, and full integration across form, charts, targets, doctor PDF, and insights. - -## 1. Problem Statement - -Users repeatedly request blood-sugar logging — most recently in **GitHub issue #73, point 8**. Diabetics, pre-diabetics, and people on metabolic-impacting medication (steroids, atypical antipsychotics) need a single place to log glucose alongside weight, BP, and pulse so the doctor PDF reflects the complete metabolic picture. Today they fall back to spreadsheets or vendor-locked apps. Adding glucose to HealthLog closes the largest remaining gap in the "vital sign" set and unlocks correlation insights (e.g. weight ↔ fasting glucose, sleep ↔ glucose variability). - -## 2. Scope - -In v1 we ship: - -- A new `BLOOD_GLUCOSE` measurement type stored canonically in **mg/dL**. -- A required **context tag** per reading: `FASTING`, `POSTPRANDIAL`, `RANDOM`, `BEDTIME`. -- A user preference `glucoseUnit` (`MG_DL` | `MMOL_L`, default `MG_DL` for `de` users, can flip in settings) — display only; storage is canonical. -- Context-aware classification (see §4) and a target card on `/targets` for fasting glucose; secondary line for postprandial when data exists. -- Health chart with diabetes/pre-diabetes band overlays. -- Doctor PDF row + classification line. -- Insights descriptor "glucose control" (fasting avg + variability). - -**Stretch (v1.1, not in this spec):** HbA1c as a separate measurement type with its own classification (<5.7% / 5.7–6.4% / ≥6.5%, ADA 2024). Schema is forward-compatible (just add another enum value). - -## 3. Data Model - -```prisma -enum MeasurementType { - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE // NEW -} - -enum GlucoseContext { // NEW - FASTING - POSTPRANDIAL - RANDOM - BEDTIME - - @@map("glucose_context") -} - -model Measurement { - // ...existing fields - glucoseContext GlucoseContext? @map("glucose_context") // NEW, only set when type = BLOOD_GLUCOSE -} - -model User { - // ...existing fields - glucoseUnit String @default("MG_DL") @map("glucose_unit") // "MG_DL" | "MMOL_L" -} -``` - -Migration `20260418_add_blood_glucose`: -1. `ALTER TYPE measurement_type ADD VALUE 'BLOOD_GLUCOSE';` -2. `CREATE TYPE glucose_context AS ENUM (...);` -3. `ALTER TABLE measurements ADD COLUMN glucose_context glucose_context;` -4. `ALTER TABLE users ADD COLUMN glucose_unit text NOT NULL DEFAULT 'MG_DL';` -5. Add `CHECK ((type = 'BLOOD_GLUCOSE') = (glucose_context IS NOT NULL))` to enforce coupling. - -The unique constraint `(userId, type, measuredAt, source)` already prevents duplicates; context is intentionally **not** part of the key — the same timestamp can't legally carry two contexts. - -Storage unit is always **mg/dL** (`unit = "mg/dL"` in the row) so downstream analytics, exports, and Withings ingest don't need to branch. - -## 4. Classifications (`src/lib/analytics/classifications.ts`) - -New helpers, ranges from **ADA Standards of Care 2024** (Diabetes Care, Vol. 47, Suppl. 1) and **DGIM/DDG S3-Leitlinie 2023**: - -```ts -export type GlucoseContext = "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME"; - -export interface GlucoseClassification { - category: string; - color: string; - severity: "info" | "normal" | "warning" | "danger"; -} - -export function classifyGlucose( - mgdl: number, - context: GlucoseContext, -): GlucoseClassification; -``` - -Boundaries (mg/dL — mmol/L conversion shown for traceability): - -| Context | Hypo (<70 / <3.9) | Normal | Pre-diabetes | Diabetes | -|---------|-------------------|--------|--------------|----------| -| FASTING | danger | 70–99 (3.9–5.5) normal | 100–125 (5.6–6.9) warning | ≥126 (≥7.0) danger | -| POSTPRANDIAL (2h) | danger | <140 (<7.8) normal | 140–199 (7.8–11.0) warning | ≥200 (≥11.1) danger | -| RANDOM | danger | <140 normal | 140–199 warning | ≥200 danger | -| BEDTIME | danger | 90–150 normal (info if outside) | — | ≥250 danger | - -Color tokens reuse the existing Dracula palette (`#50fa7b` normal, `#f1fa8c` info, `#ffb86c` / `#ff79c6` warning, `#ff5555` danger). Add `getGlucoseFastingRange()` returning `{ min: 70, max: 99 }` for the target card range bar, and `getGlucosePostprandialRange()` for the optional secondary bar. - -## 5. UI - -**Measurement form (`src/components/measurements/measurement-form.tsx`):** -- New entry in `MEASUREMENT_TYPES`: `{ value: "BLOOD_GLUCOSE", labelKey: "measurements.typeGlucose", unitKey: "measurements.unitGlucose", placeholder: "95" }`. -- When `type === "BLOOD_GLUCOSE"`, render an additional `