feat(confidence-engine): add authenticated investigation persistence

This commit is contained in:
2026-09-08 17:15:46 +01:00
parent b949eea831
commit 6dd447e56a
6 changed files with 193 additions and 3 deletions
+14
View File
@@ -0,0 +1,14 @@
import { loadInvestigation } from "@/lib/storage/server-investigation-persistence.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
async function get(_request, { params }) {
try {
const snapshot = await loadInvestigation(params.id);
if (!snapshot) return Response.json({ error: "Investigation not found" }, { status: 404 });
return Response.json({ snapshot });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
export const GET = withAuthenticatedApi(get);
+29
View File
@@ -0,0 +1,29 @@
import {
listInvestigations,
saveInvestigation,
} from "@/lib/storage/server-investigation-persistence.js";
import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js";
async function get() {
try {
return Response.json({ investigations: await listInvestigations() });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
async function post(request) {
try {
const { id, snapshot } = await request.json();
if (!id || !snapshot || typeof snapshot !== "object") {
return Response.json({ error: "An investigation id and snapshot are required" }, { status: 400 });
}
const savedSnapshot = await saveInvestigation(snapshot, id);
return Response.json({ snapshot: savedSnapshot }, { status: 200 });
} catch {
return Response.json({ error: "Investigation persistence request failed" }, { status: 500 });
}
}
export const GET = withAuthenticatedApi(get);
export const POST = withAuthenticatedApi(post);
+2 -2
View File
@@ -14,8 +14,8 @@ Initial-decomposition hardening is frozen for the current MVP stage.
## Database foundation (v0.62b)
- Version-controlled migration defines `confidence_engine.investigations`: the existing CE UUID is the row ID, platform ownership is `user_id`, and the opaque CE snapshot is JSONB. RLS permits authenticated users only where `user_id = auth.uid()`; timestamps include automatic `updated_at` maintenance.
- The migration has not been applied to the self-hosted Supabase environment. Application persistence remains localStorage-backed; the external infrastructure step is to apply the migration and add `confidence_engine` to PostgREST's exposed schemas before server persistence is wired.
- The version-controlled `confidence_engine.investigations` migration is live and PostgREST exposure was configured externally. Live SQL proved RLS rejects one authenticated user inserting a row owned by another.
- Authenticated server save/load/list capability now uses the RLS-scoped `confidence_engine` schema with server-derived identity. Production CE persistence remains localStorage-backed: no migration or cutover has occurred.
**Current product checkpoint:** Read `docs/confidence-engine-product-checkpoint-2026-09-08.md` before planning new product, live-evidence, or commercial work. The core investigation loop is now sufficiently established to prioritise realistic end-to-end use, report experience, prospective-user value, repeat use, and willingness to pay—not endless isolated reasoning-mechanics experiments. Preserve user ownership and address trust-critical defects when found.
+1 -1
View File
@@ -36,7 +36,7 @@ The product direction is a **facilitated investigation** presented across three
**Authentication boundary:** Supabase Auth magic links gate product and CE API routes. Sessions are cookie-backed and `/auth/callback` exchanges the auth code before returning to `/`. This does not alter localStorage investigation persistence or introduce user ownership into CE snapshots; dedicated `confidence_engine` PostgreSQL persistence remains future work.
**Database contract (v0.62b):** A pending version-controlled migration defines `confidence_engine.investigations` outside `public`. Its platform metadata is `id`, `user_id`, and timestamps; the CE payload remains an opaque JSONB `snapshot`. Authenticated RLS ownership is `user_id = auth.uid()`. The migration is not yet applied, `confidence_engine` is not yet exposed through PostgREST, and localStorage remains the production persistence authority.
**Database contract (v0.62b):** The applied `confidence_engine.investigations` schema sits outside `public`. Its platform metadata is `id`, `user_id`, and timestamps; the CE payload remains an opaque JSONB `snapshot`. Authenticated RLS ownership is `user_id = auth.uid()`, and external PostgREST configuration exposes the schema. Server save/load/list capability is available through the authenticated/RLS path, but localStorage remains the production persistence authority; controlled cutover and legacy migration are future work.
The user controls which question to investigate, how deeply to investigate it, when to say Done for now, whether Current Understanding is sufficient, whether to reopen work, and when to review the Report. The engine facilitates — it does not steer or prioritise.
@@ -0,0 +1,66 @@
import {
createServerSupabaseClient,
getAuthenticatedUser,
} from "@/lib/supabase/server.js";
const SCHEMA = "confidence_engine";
const TABLE = "investigations";
async function getAuthenticatedPersistenceContext() {
const user = await getAuthenticatedUser();
if (!user) return null;
return { user, supabase: createServerSupabaseClient() };
}
function throwIfDatabaseError(error) {
if (error) throw new Error("Investigation persistence request failed");
}
export async function saveInvestigation(snapshot, id = snapshot?.id) {
if (!snapshot || typeof snapshot !== "object" || !id) {
throw new Error("Investigation snapshot and id are required");
}
const context = await getAuthenticatedPersistenceContext();
if (!context) return null;
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.upsert({ id, user_id: context.user.id, snapshot }, { onConflict: "id" })
.select("id, snapshot, created_at, updated_at")
.single();
throwIfDatabaseError(error);
return data?.snapshot ?? null;
}
export async function loadInvestigation(id) {
if (!id) return null;
const context = await getAuthenticatedPersistenceContext();
if (!context) return null;
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.select("snapshot")
.eq("id", id)
.maybeSingle();
throwIfDatabaseError(error);
return data?.snapshot ?? null;
}
export async function listInvestigations() {
const context = await getAuthenticatedPersistenceContext();
if (!context) return null;
const { data, error } = await context.supabase
.schema(SCHEMA)
.from(TABLE)
.select("id, created_at, updated_at")
.order("updated_at", { ascending: false });
throwIfDatabaseError(error);
return (data ?? []).map((record) => ({
id: record.id,
createdAt: record.created_at,
updatedAt: record.updated_at,
}));
}
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockGetAuthenticatedUser = vi.fn();
const mockCreateServerSupabaseClient = vi.fn();
vi.mock("@/lib/supabase/server.js", () => ({
getAuthenticatedUser: () => mockGetAuthenticatedUser(),
createServerSupabaseClient: () => mockCreateServerSupabaseClient(),
}));
function makeClient(result) {
const query = {
from: vi.fn(() => query),
upsert: vi.fn(() => query),
select: vi.fn(() => query),
eq: vi.fn(() => query),
maybeSingle: vi.fn(() => Promise.resolve(result)),
single: vi.fn(() => Promise.resolve(result)),
order: vi.fn(() => Promise.resolve(result)),
};
return { client: { schema: vi.fn(() => query) }, query };
}
describe("server investigation persistence", () => {
beforeEach(() => vi.clearAllMocks());
it("rejects unauthenticated persistence access", async () => {
mockGetAuthenticatedUser.mockResolvedValue(null);
const { saveInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(saveInvestigation({ id: "investigation-1" })).resolves.toBeNull();
expect(mockCreateServerSupabaseClient).not.toHaveBeenCalled();
});
it("saves an unchanged snapshot with user identity derived on the server", async () => {
const snapshot = { id: "investigation-1", scenario: "A situation", user_id: "untrusted" };
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { saveInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(saveInvestigation(snapshot)).resolves.toBe(snapshot);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.from).toHaveBeenCalledWith("investigations");
expect(query.upsert).toHaveBeenCalledWith(
{ id: "investigation-1", user_id: "trusted-user", snapshot },
{ onConflict: "id" },
);
});
it("loads the RLS-scoped stored snapshot", async () => {
const snapshot = { id: "investigation-1", findings: [] };
const { client, query } = makeClient({ data: { snapshot }, error: null });
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { loadInvestigation } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(loadInvestigation("investigation-1")).resolves.toBe(snapshot);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.eq).toHaveBeenCalledWith("id", "investigation-1");
});
it("lists only RLS-visible persistence records", async () => {
const { client, query } = makeClient({
data: [{ id: "investigation-1", created_at: "2026-09-08T00:00:00Z", updated_at: "2026-09-08T01:00:00Z" }],
error: null,
});
mockGetAuthenticatedUser.mockResolvedValue({ id: "trusted-user" });
mockCreateServerSupabaseClient.mockReturnValue(client);
const { listInvestigations } = await import("@/lib/storage/server-investigation-persistence.js");
await expect(listInvestigations()).resolves.toEqual([{
id: "investigation-1",
createdAt: "2026-09-08T00:00:00Z",
updatedAt: "2026-09-08T01:00:00Z",
}]);
expect(client.schema).toHaveBeenCalledWith("confidence_engine");
expect(query.select).toHaveBeenCalledWith("id, created_at, updated_at");
expect(query.order).toHaveBeenCalledWith("updated_at", { ascending: false });
});
});