Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -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/
Expand Down
1 change: 0 additions & 1 deletion .impeccable/hook.cache.json

This file was deleted.

5 changes: 5 additions & 0 deletions .prettierignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,13 +6,15 @@ 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.
- Ensure Supabase-related error logging uses redacted/safe detail formatting.
- 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.
Expand All@@ -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:
Expand All@@ -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.
Expand Down
12 changes: 9 additions & 3 deletions src/app/api/documents/[id]/reindex/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
Expand DownExpand Up@@ -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
Expand All@@ -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);
Expand Down
8 changes: 6 additions & 2 deletions src/app/api/documents/bulk/reindex/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand DownExpand Up@@ -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);
Expand Down
8 changes: 6 additions & 2 deletions src/app/api/upload/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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,
Expand Down
3 changes: 1 addition & 2 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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") => {
Expand Down
4 changes: 1 addition & 3 deletions src/components/applications-launcher-page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -934,9 +934,7 @@ export function ApplicationsLauncherWorkspace({
);
}

return (
<main className={cn("min-w-0 pb-8 text-[color:var(--text)] lg:pb-10", className)}>{workspace}</main>
);
return <main className={cn("min-w-0 pb-8 text-[color:var(--text)] lg:pb-10", className)}>{workspace}</main>;
}

export function ApplicationsLauncherPage() {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
18 changes: 10 additions & 8 deletions src/lib/privacy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,14 +18,16 @@ function redactLogValue(value: unknown): unknown {
}
const htmlTitle = value.match(/<title>\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) {
Expand Down
1 change: 0 additions & 1 deletion src/lib/supabase/auth.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,4 +81,3 @@ export async function requireAuthenticatedUser(request: Request, supabase: Admin

return { id: userId };
}

1 change: 0 additions & 1 deletion src/lib/supabase/client.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -272,4 +272,3 @@ export function useAuthSession() {
}
return context;
}

4 changes: 3 additions & 1 deletion tests/embedding-dimensions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}`));
Expand Down
2 changes: 1 addition & 1 deletion tests/forms-clipboard-fallback.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 }");
});
});
2 changes: 0 additions & 2 deletions tests/private-client-auth.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,3 @@ describe("browser auth helpers", () => {
expect(isUsableBrowserSupabaseKey("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature")).toBe(true);
});
});


4 changes: 3 additions & 1 deletion tests/supabase-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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");
Expand Down
2 changes: 1 addition & 1 deletion tests/worker-visual-capture.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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), {",
);
});

Expand Down
Loading