diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a469f77d..b9d9698bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,9 @@ jobs: - name: Production readiness (CI-safe) run: npm run check:production-readiness:ci + - name: Format check + run: npm run format:check + - name: Lint run: npm run lint diff --git a/.gitignore b/.gitignore index 25e923da9..d53f67081 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,8 @@ next-env.d.ts # agent/QA artifacts .codex-screenshots/ +# local hook tool cache — machine-local, never commit +.impeccable/ # Local debugging scratch space — never commit (accidentally landed once via 'Save Codex local changes') scratch/ .qa-smoke/ diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json deleted file mode 100644 index a2d4e1658..000000000 --- a/.impeccable/hook.cache.json +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"sessions":{"70c96a73-49a6-4422-a310-d0294a45dc49":{"updatedAt":1782977742215,"files":{"C:\\Users\\joshs\\.copilot\\repos\\copilot-worktrees\\Database\\bigsimmo-bookish-barnacle\\supabase\\functions\\indexing-v3-agent\\index.ts":{"editCount":1,"findings":[]}}}}} \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index fa1ce8a5a..f08ea3e5e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,6 +12,11 @@ public/demo-documents/ .tmp-visual/ scratch/ .claude/worktrees/ +.impeccable/ +# tests/worker-visual-capture.test.ts asserts on the exact source text of this +# file (line-level guards on redaction/normalization order); prettier's +# line-wrapping would break those assertions, so it owns its own layout. +worker/main.ts # Generated by `supabase gen types`; keep the generator's formatting so # regeneration stays churn-free. src/lib/supabase/database.types.ts diff --git a/docs/superpowers/specs/2026-07-02-triage-security-reliability-design.md b/docs/superpowers/specs/2026-07-02-triage-security-reliability-design.md index e586e7e85..16b595835 100644 --- a/docs/superpowers/specs/2026-07-02-triage-security-reliability-design.md +++ b/docs/superpowers/specs/2026-07-02-triage-security-reliability-design.md @@ -6,6 +6,7 @@ Scope: Issue #53 (security), #55 (reliability gaps only), #56 (no change unless ## 1. Goals and non-goals ### Goals + - Remove authentication bypass behavior so protected APIs fail closed. - Stop client-side reliance on persisted localStorage auth token hints. - Redact high-risk identifiers from generated image captions before persistence. @@ -13,6 +14,7 @@ Scope: Issue #53 (security), #55 (reliability gaps only), #56 (no change unless - Close currently open reliability gaps from #55 with minimal, targeted changes (especially embedding-dimension drift safeguards). ### Non-goals + - No front-end refactor work from #51. - No performance-oriented search/ingestion optimization from #52. - No broad operational redesign for #56; only patch ops docs/scripts if implementation reveals a concrete missing step. @@ -32,16 +34,19 @@ This is preferred over broader rewrites because it directly addresses active ris ## 3. Architecture and component changes ### 3.1 Authentication boundary + - Primary file: `src/lib/supabase/auth.ts`. - Change: remove environment-based no-auth fallback paths for protected API authorization. - Result: auth gate requires valid Supabase-authenticated user identity; invalid/missing identity returns explicit unauthorized response paths. ### 3.2 Client-side session handling + - Primary files: `src/lib/supabase/client.tsx`, `src/components/ClinicalDashboard.tsx`. - Change: stop relying on localStorage token presence scans and persisted auth-email hints for deciding private API/session capability. - Result: UI behavior follows actual Supabase session/auth state only. ### 3.3 Caption redaction before persistence + - Primary file: `worker/main.ts` (with helper placement in shared privacy utility area where appropriate). - Change: sanitize generated caption text before database/cache writes. - Baseline redaction targets: @@ -51,11 +56,13 @@ This is preferred over broader rewrites because it directly addresses active ris - Result: ingestion remains functional, but persisted captions are safer by default. ### 3.4 Safe Supabase logging + - Primary files: worker/server/script callsites identified during implementation. - Change: route Supabase-related error detail formatting through existing safe redaction utilities instead of direct raw detail logging. - Result: operational logs remain actionable without leaking secret/token/identifier content. ### 3.5 Embedding-dimension drift safeguards + - Primary file: `src/lib/embedding-dimensions.ts` and nearby ingestion assertions/tests. - Change: align expected dimension checks to a single configuration source used by ingestion-time assertions. - Result: mismatches fail fast and predictably, avoiding silent search-quality corruption. diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index f2a05b0f7..32057ab41 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -115,7 +115,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const { data: document, error: documentError } = await supabase .from("documents") - .select("id,owner_id,title,file_name,source_path,import_batch_id,status,error_message,page_count,chunk_count,image_count,metadata") + .select( + "id,owner_id,title,file_name,source_path,import_batch_id,status,error_message,page_count,chunk_count,image_count,metadata", + ) .eq("id", id) .eq("owner_id", user.id) .maybeSingle(); @@ -240,7 +242,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: .in("status", ["pending", "processing"]) .limit(1); if (competingJobsError) { - throw new Error(`Failed to enqueue reindex job: ${jobError.message}; competing-job check failed: ${competingJobsError.message}`); + throw new Error( + `Failed to enqueue reindex job: ${jobError.message}; competing-job check failed: ${competingJobsError.message}`, + ); } if ((competingJobs?.length ?? 0) === 0) { const { error: rollbackError } = await supabase @@ -250,7 +254,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: .eq("owner_id", user.id) .eq("updated_at", rollbackFence); if (rollbackError) { - throw new Error(`Failed to enqueue reindex job: ${jobError.message}; rollback failed: ${rollbackError.message}`); + throw new Error( + `Failed to enqueue reindex job: ${jobError.message}; rollback failed: ${rollbackError.message}`, + ); } } throw new Error(jobError.message); diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 06391bfcb..15d327012 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -105,7 +105,9 @@ export async function POST(request: Request) { const documentIds = Array.from(new Set(parsed.documentIds)); const { data: documents, error: documentError } = await supabase .from("documents") - .select("id,owner_id,title,file_name,source_path,import_batch_id,status,error_message,page_count,chunk_count,image_count,metadata") + .select( + "id,owner_id,title,file_name,source_path,import_batch_id,status,error_message,page_count,chunk_count,image_count,metadata", + ) .eq("owner_id", user.id) .in("id", documentIds); if (documentError) throw new Error(documentError.message); @@ -244,7 +246,9 @@ export async function POST(request: Request) { .eq("owner_id", user.id) .eq("updated_at", rollbackFence); if (rollbackError) { - throw new Error(`Failed to enqueue bulk reindex job: ${jobError.message}; rollback failed: ${rollbackError.message}`); + throw new Error( + `Failed to enqueue bulk reindex job: ${jobError.message}; rollback failed: ${rollbackError.message}`, + ); } } throw new Error(jobError.message); diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 36dff1c2d..7aacea2c0 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -166,7 +166,9 @@ export async function POST(request: Request) { .eq("id", documentId) .eq("owner_id", user.id); if (rollbackDocumentError) { - throw new Error(`Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`); + throw new Error( + `Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`, + ); } insertedDocumentId = null; insertedDocumentOwnerId = null; @@ -208,7 +210,9 @@ export async function POST(request: Request) { if (uploadedPath && supabase) { try { - const { error: cleanupStorageError } = await supabase.storage.from(env.SUPABASE_DOCUMENT_BUCKET).remove([uploadedPath]); + const { error: cleanupStorageError } = await supabase.storage + .from(env.SUPABASE_DOCUMENT_BUCKET) + .remove([uploadedPath]); if (cleanupStorageError) { logger.error("Upload cleanup failed; storage object may be orphaned", { storagePath: uploadedPath, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 02172e1ae..7276ea261 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -5778,8 +5778,7 @@ export function ClinicalDashboard({ demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true" || browserAuthUnavailableDemoFallback; const localDevCanAttemptPrivateApis = process.env.NODE_ENV !== "production" && hasReadyPublicSearchSetup(setupChecks); const canUsePrivateApis = - localProjectReady && - (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); + localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); const canRunSearch = explicitDemoMode || (hasReadyPublicSearchSetup(setupChecks) && canUsePrivateApis); const closeDashboardTransientSurfaces = useCallback( (except?: "guide" | "settings" | "mobileSidebar" | "documents" | "upload") => { diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index dcfca0b37..940674732 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -934,9 +934,7 @@ export function ApplicationsLauncherWorkspace({ ); } - return ( -
{workspace}
- ); + return
{workspace}
; } export function ApplicationsLauncherPage() { diff --git a/src/components/clinical-dashboard/global-mockup-search-shell.tsx b/src/components/clinical-dashboard/global-mockup-search-shell.tsx index a05e1387b..20058df26 100644 --- a/src/components/clinical-dashboard/global-mockup-search-shell.tsx +++ b/src/components/clinical-dashboard/global-mockup-search-shell.tsx @@ -16,7 +16,13 @@ import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { FormsSearchResultsPage } from "@/components/forms/forms-search-results-page"; import { cn } from "@/components/ui-primitives"; -import { appModeHomeHref, isAppModeId, isAppModeVisible, visibleAppModeDefinitions, type AppModeId } from "@/lib/app-modes"; +import { + appModeHomeHref, + isAppModeId, + isAppModeVisible, + visibleAppModeDefinitions, + type AppModeId, +} from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { useAuthSession } from "@/lib/supabase/client"; diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 94cb8c28d..557d176b2 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -27,14 +27,7 @@ import Link from "next/link"; import { useState } from "react"; import { ModeHomeTemplate } from "@/components/mode-home-template"; -import { - cn, - toneDanger, - toneInfo, - toneNeutral, - toneSuccess, - toneWarning, -} from "@/components/ui-primitives"; +import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; type MedicationPrescribingWorkspaceProps = { query: string; diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index 17f4595da..78d472706 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -18,14 +18,16 @@ function redactLogValue(value: unknown): unknown { } const htmlTitle = value.match(/\s*([^<]+?)\s*<\/title>/i)?.[1]?.trim(); const normalizedValue = htmlTitle ? `HTML response: ${htmlTitle}` : value; - return normalizedValue - .replace(/\b[A-Za-z]:\\[^\s'\")]+/g, "[path]") - .replace(/\/(?:[^\s'\")]+\/)+[^\s'\")]+/g, "[path]") - .replace(/https?:\/\/[^\s'\")]+/g, "[url]") - // Redact common secret/token formats, including modern Supabase keys like sb_secret_ and sb_publishable_ - .replace(/\b(?:sk|pk|sbp|sb_secret_|sb_publishable_|eyJ)[A-Za-z0-9._-]{8,}\b/g, "[secret]") - .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]") - .slice(0, 500); + return ( + normalizedValue + .replace(/\b[A-Za-z]:\\[^\s'\")]+/g, "[path]") + .replace(/\/(?:[^\s'\")]+\/)+[^\s'\")]+/g, "[path]") + .replace(/https?:\/\/[^\s'\")]+/g, "[url]") + // Redact common secret/token formats, including modern Supabase keys like sb_secret_ and sb_publishable_ + .replace(/\b(?:sk|pk|sbp|sb_secret_|sb_publishable_|eyJ)[A-Za-z0-9._-]{8,}\b/g, "[secret]") + .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "[email]") + .slice(0, 500) + ); } export function safeErrorLogDetails(error: unknown) { diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 721c4a7dd..e408be34a 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -81,4 +81,3 @@ export async function requireAuthenticatedUser(request: Request, supabase: Admin return { id: userId }; } - diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index 905638996..aa20e8e10 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -272,4 +272,3 @@ export function useAuthSession() { } return context; } - diff --git a/tests/embedding-dimensions.test.ts b/tests/embedding-dimensions.test.ts index 6641fe588..c35a4f597 100644 --- a/tests/embedding-dimensions.test.ts +++ b/tests/embedding-dimensions.test.ts @@ -22,7 +22,9 @@ describe("strict embedding dimension guard", () => { const { EXPECTED_EMBED_DIM, assertEmbeddingDim } = await import("../src/lib/embedding-dimensions"); expect(() => assertEmbeddingDim("not-a-vector", "test_vector")).toThrow(/must be an array/); - expect(() => assertEmbeddingDim([0.1, 0.2], "test_vector")).toThrow(new RegExp(`2 dimensions; expected ${EXPECTED_EMBED_DIM}`)); + expect(() => assertEmbeddingDim([0.1, 0.2], "test_vector")).toThrow( + new RegExp(`2 dimensions; expected ${EXPECTED_EMBED_DIM}`), + ); expect(() => assertEmbeddingDim([...Array.from({ length: EXPECTED_EMBED_DIM - 1 }, () => 0), Infinity], "test_vector"), ).toThrow(new RegExp(`non-finite value at index ${EXPECTED_EMBED_DIM - 1}`)); diff --git a/tests/forms-clipboard-fallback.test.ts b/tests/forms-clipboard-fallback.test.ts index a41b96329..a1e2f7dce 100644 --- a/tests/forms-clipboard-fallback.test.ts +++ b/tests/forms-clipboard-fallback.test.ts @@ -8,7 +8,7 @@ describe("form detail clipboard fallback", () => { expect(source).toContain("if (navigator.clipboard?.writeText)"); expect(source).toContain("await navigator.clipboard.writeText(value)"); expect(source).toContain("Fall through to the legacy selection path for restricted browser contexts."); - expect(source).toContain("document.execCommand?.(\"copy\")"); + expect(source).toContain('document.execCommand?.("copy")'); expect(source).toContain("finally {\n document.body.removeChild(textArea);\n }"); }); }); diff --git a/tests/private-client-auth.test.ts b/tests/private-client-auth.test.ts index 6a5ca93cc..b23444e46 100644 --- a/tests/private-client-auth.test.ts +++ b/tests/private-client-auth.test.ts @@ -26,5 +26,3 @@ describe("browser auth helpers", () => { expect(isUsableBrowserSupabaseKey("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature")).toBe(true); }); }); - - diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index ee9469372..d9d7d0556 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -271,7 +271,9 @@ describe("Supabase schema Data API grants", () => { expect(schema).toContain("alter table public.ingestion_job_stages enable row level security"); expect(schema).toContain('create policy "ingestion job stages service role all" on public.ingestion_job_stages'); expect(schema).toContain("alter table public.indexing_v3_agent_jobs enable row level security"); - expect(schema).toContain('create policy "indexing v3 agent jobs service role all" on public.indexing_v3_agent_jobs'); + expect(schema).toContain( + 'create policy "indexing v3 agent jobs service role all" on public.indexing_v3_agent_jobs', + ); const authenticatedSelectGrant = schema.match(/grant select on table ([^;]+) to authenticated;/)?.[1] ?? ""; expect(authenticatedSelectGrant).not.toContain("public.ingestion_job_stages"); expect(authenticatedSelectGrant).not.toContain("public.indexing_v3_agent_jobs"); diff --git a/tests/worker-visual-capture.test.ts b/tests/worker-visual-capture.test.ts index e155820ab..1e61e0f10 100644 --- a/tests/worker-visual-capture.test.ts +++ b/tests/worker-visual-capture.test.ts @@ -75,7 +75,7 @@ describe("worker visual capture hardening", () => { expect(workerSource).toContain("let classification = redactImageClassification(resolved.classification);"); expect(workerSource).toContain("caption: classification.caption"); expect(workerSource).toContain( - 'const structuredProfile = normalizeStructuredVisualProfile(redactCaptionMetadataValue(metadata.structured_visual_profile), {', + "const structuredProfile = normalizeStructuredVisualProfile(redactCaptionMetadataValue(metadata.structured_visual_profile), {", ); });