diff --git a/app/.well-known/oauth-authorization-server/[...resource]/route.ts b/app/.well-known/oauth-authorization-server/[...resource]/route.ts index e5d04ea..5499f88 100644 --- a/app/.well-known/oauth-authorization-server/[...resource]/route.ts +++ b/app/.well-known/oauth-authorization-server/[...resource]/route.ts @@ -1 +1,4 @@ -export { GET, OPTIONS, dynamic, runtime } from "../route"; +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export { GET, OPTIONS } from "../route"; diff --git a/app/admin/env/page.tsx b/app/admin/env/page.tsx index f91a01f..d70fc84 100644 --- a/app/admin/env/page.tsx +++ b/app/admin/env/page.tsx @@ -1,7 +1,29 @@ import { getEnvBrokerStatus, PHASE5A_QUEUE_SAFE, PHASE5C_SAFE_PRODUCTION } from "@/lib/services/env-broker-service"; import { buildEnvDriftReport } from "@/lib/services/env-drift-service"; +import { requireEnvAdmin } from "@/lib/services/env-admin-route-guard"; + +export const dynamic = "force-dynamic"; + +function LockedEnvBrokerPage() { + return
+

Pandora Env Broker

+

Env Broker access is locked. Use an env-admin Supabase session or an internal operator unlock token.

+
+

Operator unlock

+

The key is accepted server-side, stored only in an HttpOnly cookie for 30 minutes, and is never rendered back to the page.

+
+ + +
+
+

Start Supabase session

+
; +} export default async function AdminEnvPage() { + const guard = await requireEnvAdmin(false); + if (guard.response) return ; + const status = getEnvBrokerStatus(); const drift = await buildEnvDriftReport(); const driftColor = drift.severity === "green" ? "#0a7f27" : drift.severity === "yellow" ? "#9a6700" : "#b42318"; @@ -11,7 +33,7 @@ export default async function AdminEnvPage() {

Operator unlock

-

Mutation buttons require either a Supabase session or an operator unlock. The key is accepted server-side, stored only in an HttpOnly cookie for 30 minutes, and is never rendered back to the page.

+

Mutation buttons require an env-admin Supabase session or an operator unlock. The key is accepted server-side, stored only in an HttpOnly cookie for 30 minutes, and is never rendered back to the page.

diff --git a/app/api/admin/env/status/route.ts b/app/api/admin/env/status/route.ts index 8c2d9b7..55b5064 100644 --- a/app/api/admin/env/status/route.ts +++ b/app/api/admin/env/status/route.ts @@ -15,6 +15,6 @@ export async function POST(request: NextRequest) { if (!valid) return NextResponse.json({ ok: false, error: { code: "invalid_operator_key", message: "Env Broker operator unlock was not accepted." } }, { status: 401 }); const response = NextResponse.redirect(new URL("/admin/env", request.url), { status: 303 }); - response.cookies.set({ name: ENV_ADMIN_COOKIE_NAME, value: candidate, httpOnly: true, sameSite: "strict", secure: true, path: "/api/admin/env", maxAge: 60 * 30 }); + response.cookies.set({ name: ENV_ADMIN_COOKIE_NAME, value: candidate, httpOnly: true, sameSite: "strict", secure: true, path: "/", maxAge: 60 * 30 }); return response; } diff --git a/lib/services/env-admin-route-guard.ts b/lib/services/env-admin-route-guard.ts index 2454a93..f47ce2c 100644 --- a/lib/services/env-admin-route-guard.ts +++ b/lib/services/env-admin-route-guard.ts @@ -1,10 +1,13 @@ import { timingSafeEqual } from "crypto"; import { cookies, headers } from "next/headers"; import { NextResponse } from "next/server"; +import type { User } from "@supabase/supabase-js"; import { requireApiUser } from "@/lib/security/api-auth"; import { isBrokerEnabled } from "@/lib/services/env-broker-service"; export const ENV_ADMIN_COOKIE_NAME = "pandora_env_admin"; +const ENV_ADMIN_CAPABILITIES = new Set(["env:admin", "env:broker", "admin:env", "pandora:env"]); +const ENV_ADMIN_ROLES = new Set(["admin", "env_admin"]); export type EnvAdminActor = { id: string; @@ -12,11 +15,19 @@ export type EnvAdminActor = { }; export async function requireEnvAdmin(mutation = true): Promise<{ user: EnvAdminActor | null; response: NextResponse | null }> { - const auth = await requireApiUser(); - let user: EnvAdminActor | null = auth.response ? null : { id: auth.user.id, authType: "supabase" }; + let user: EnvAdminActor | null = null; - if (!user && (await hasValidEnvAdminOperatorToken())) { + if (await hasValidEnvAdminOperatorToken()) { user = { id: "env-operator-token", authType: "operator_token" }; + } else { + const auth = await requireApiUser(); + if (auth.response) { + return { user: null, response: NextResponse.json({ ok: false, error: { code: "unauthenticated", message: "Env Broker operator unlock required." } }, { status: 401 }) }; + } + if (!hasEnvAdminCapability(auth.user)) { + return { user: null, response: NextResponse.json({ ok: false, error: { code: "forbidden", message: "Env Broker admin capability required." } }, { status: 403 }) }; + } + user = { id: auth.user.id, authType: "supabase" }; } if (!user) { @@ -30,6 +41,18 @@ export async function requireEnvAdmin(mutation = true): Promise<{ user: EnvAdmin return { user, response: null }; } +export function hasEnvAdminCapability(user: Pick | null | undefined): boolean { + const metadata = user?.app_metadata; + if (!metadata || typeof metadata !== "object") return false; + + const role = typeof metadata.role === "string" ? metadata.role : ""; + if (ENV_ADMIN_ROLES.has(role)) return true; + + const capabilities = Array.isArray(metadata.adminCapabilities) ? metadata.adminCapabilities : []; + const roles = Array.isArray(metadata.roles) ? metadata.roles : []; + return [...capabilities, ...roles].some((value) => typeof value === "string" && ENV_ADMIN_CAPABILITIES.has(value)); +} + export async function hasValidEnvAdminOperatorToken(): Promise { const configured = getConfiguredOperatorTokens(); if (!configured.length) return false; diff --git a/package-lock.json b/package-lock.json index b0ef53c..91d5a85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5125,9 +5125,9 @@ } }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -5144,9 +5144,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" diff --git a/package.json b/package.json index 30efbfa..c9a178d 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,9 @@ "eslint-config-next": "^15.0.0", "typescript": "^5.6.2" }, + "overrides": { + "postcss": "^8.5.16" + }, "engines": { "node": ">=20.0.0", "npm": ">=10.0.0" diff --git a/scripts/verify-first-reviewed-memory-fixture.ts b/scripts/verify-first-reviewed-memory-fixture.ts index 4688c7c..b19e62a 100644 --- a/scripts/verify-first-reviewed-memory-fixture.ts +++ b/scripts/verify-first-reviewed-memory-fixture.ts @@ -1,17 +1,28 @@ import { buildAllFirstReviewedMemoryFixtures } from "../lib/services/first-reviewed-memory-fixture-builder"; import { runManualWorkflowFixtureHarness } from "../lib/services/operator-manual-workflow-fixture-harness"; -async function main() { - console.log("TEST-ONLY FIXTURE HARNESS"); - console.log("NO PRODUCTION WRITES"); - console.log("NO MODEL CALLS"); - console.log("NO SEMANTIC RETRIEVAL"); +export async function runFirstReviewedMemoryFixtureCli() { + const lines = [ + "TEST-ONLY FIXTURE HARNESS", + "NO PRODUCTION WRITES", + "NO MODEL CALLS", + "NO SEMANTIC RETRIEVAL", + ]; const results = []; for (const fixture of buildAllFirstReviewedMemoryFixtures()) { const result = await runManualWorkflowFixtureHarness({ fixture }); results.push(result); - console.log(`${result.ok ? "PASS" : "FAIL"} ${result.scenario} blocked=${result.expectedBlocked} productionSeed=${result.safeSummary.productionSeed} publicPersistence=${result.safeSummary.publicPersistenceEnabled}`); + lines.push(`${result.ok ? "PASS" : "FAIL"} ${result.scenario} blocked=${result.expectedBlocked} productionSeed=${result.safeSummary.productionSeed} publicPersistence=${result.safeSummary.publicPersistenceEnabled}`); } - if (results.some((r) => !r.ok)) process.exit(1); + return { ok: results.every((r) => r.ok), output: `${lines.join("\n")}\n` }; +} + +async function main() { + const result = await runFirstReviewedMemoryFixtureCli(); + process.stdout.write(result.output); + if (!result.ok) process.exit(1); +} + +if (process.argv[1]?.replace(/\\/g, "/").endsWith("scripts/verify-first-reviewed-memory-fixture.ts")) { + main().catch(() => { console.error("FAIL fixture harness error (redacted)"); process.exit(1); }); } -main().catch(() => { console.error("FAIL fixture harness error (redacted)"); process.exit(1); }); diff --git a/supabase/migrations/20260704103859_adaptive_log_rls_policies.sql b/supabase/migrations/20260704103859_adaptive_log_rls_policies.sql new file mode 100644 index 0000000..dc89478 --- /dev/null +++ b/supabase/migrations/20260704103859_adaptive_log_rls_policies.sql @@ -0,0 +1,50 @@ +-- Add explicit owner-scoped RLS policies for adaptive log tables. +-- These tables were created with RLS enabled in Phase 4C, but no authenticated +-- policies were attached, so user-scoped log writes/reads were blocked by +-- default. Service-role/admin clients continue to bypass RLS as before. + +alter table public.memory_retrieval_logs enable row level security; +alter table public.memory_retrieval_logs force row level security; + +alter table public.memory_model_call_logs enable row level security; +alter table public.memory_model_call_logs force row level security; + +do $$ +begin + create policy "memory_retrieval_logs_select_own" + on public.memory_retrieval_logs + for select + to authenticated + using ((select auth.uid()) = user_id); +exception when duplicate_object then null; +end $$; + +do $$ +begin + create policy "memory_retrieval_logs_insert_own" + on public.memory_retrieval_logs + for insert + to authenticated + with check ((select auth.uid()) = user_id); +exception when duplicate_object then null; +end $$; + +do $$ +begin + create policy "memory_model_call_logs_select_own" + on public.memory_model_call_logs + for select + to authenticated + using ((select auth.uid()) = user_id); +exception when duplicate_object then null; +end $$; + +do $$ +begin + create policy "memory_model_call_logs_insert_own" + on public.memory_model_call_logs + for insert + to authenticated + with check ((select auth.uid()) = user_id); +exception when duplicate_object then null; +end $$; diff --git a/tests/unit/env-admin-route-guard.test.ts b/tests/unit/env-admin-route-guard.test.ts new file mode 100644 index 0000000..ca872a1 --- /dev/null +++ b/tests/unit/env-admin-route-guard.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { cookies, headers } from "next/headers"; +import { requireApiUser } from "@/lib/security/api-auth"; +import { POST as unlockEnvAdmin } from "@/app/api/admin/env/status/route"; +import { hasEnvAdminCapability, requireEnvAdmin } from "@/lib/services/env-admin-route-guard"; + +vi.mock("next/headers", () => ({ + cookies: vi.fn(), + headers: vi.fn(), +})); + +vi.mock("@/lib/security/api-auth", () => ({ + requireApiUser: vi.fn(), +})); + +const envKeys = ["PANDORA_INTERNAL_OPERATOR_TOKEN", "PANDORA_INTERNAL_JOB_TOKEN", "PANDORA_ENV_BROKER_ENABLED"] as const; +const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + +function mockRequestTokens(input: { bearer?: string; explicit?: string; cookie?: string } = {}) { + vi.mocked(headers).mockResolvedValue({ + get(name: string) { + const normalized = name.toLowerCase(); + if (normalized === "authorization" && input.bearer) return `Bearer ${input.bearer}`; + if (normalized === "x-pandora-env-admin-token") return input.explicit ?? null; + return null; + }, + } as never); + vi.mocked(cookies).mockResolvedValue({ + get(name: string) { + return name === "pandora_env_admin" && input.cookie ? { value: input.cookie } : undefined; + }, + } as never); +} + +function mockApiUser(appMetadata: Record) { + vi.mocked(requireApiUser).mockResolvedValue({ + response: null, + user: { id: "user-1", app_metadata: appMetadata }, + } as never); +} + +describe("Env Broker admin guard", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.PANDORA_ENV_BROKER_ENABLED = "true"; + delete process.env.PANDORA_INTERNAL_OPERATOR_TOKEN; + delete process.env.PANDORA_INTERNAL_JOB_TOKEN; + mockRequestTokens(); + }); + + afterEach(() => { + for (const key of envKeys) { + const value = originalEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("rejects plain Supabase sessions without env-admin app metadata", async () => { + mockApiUser({}); + + const result = await requireEnvAdmin(); + + expect(result.user).toBeNull(); + expect(result.response?.status).toBe(403); + }); + + it("accepts explicit env-admin capabilities from app metadata", async () => { + mockApiUser({ adminCapabilities: ["env:admin"] }); + + const result = await requireEnvAdmin(); + + expect(result.response).toBeNull(); + expect(result.user).toEqual({ id: "user-1", authType: "supabase" }); + }); + + it("accepts the internal operator token without a Supabase session", async () => { + process.env.PANDORA_INTERNAL_OPERATOR_TOKEN = "0123456789abcdef01234567"; + mockRequestTokens({ bearer: "0123456789abcdef01234567" }); + + const result = await requireEnvAdmin(); + + expect(requireApiUser).not.toHaveBeenCalled(); + expect(result.response).toBeNull(); + expect(result.user).toEqual({ id: "env-operator-token", authType: "operator_token" }); + }); + + it("does not treat user-editable profile metadata as authorization", () => { + expect(hasEnvAdminCapability({ app_metadata: {} } as never)).toBe(false); + expect(hasEnvAdminCapability({ app_metadata: { role: "env_admin" } } as never)).toBe(true); + expect(hasEnvAdminCapability({ app_metadata: { roles: ["env:broker"] } } as never)).toBe(true); + }); + + it("scopes the operator unlock cookie to the guarded page and API routes", async () => { + const token = "0123456789abcdef01234567"; + process.env.PANDORA_INTERNAL_OPERATOR_TOKEN = token; + const form = new FormData(); + form.set("operator_key", token); + + const response = await unlockEnvAdmin(new NextRequest("https://example.test/api/admin/env/status", { method: "POST", body: form })); + const setCookie = response.headers.get("set-cookie") ?? ""; + + expect(response.status).toBe(303); + expect(setCookie).toContain("pandora_env_admin="); + expect(setCookie).toContain("Path=/"); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=strict"); + expect(setCookie).toContain("Secure"); + }); +}); diff --git a/tests/unit/first-reviewed-memory-fixture.test.tsx b/tests/unit/first-reviewed-memory-fixture.test.tsx index 619a9bf..f1585c6 100644 --- a/tests/unit/first-reviewed-memory-fixture.test.tsx +++ b/tests/unit/first-reviewed-memory-fixture.test.tsx @@ -1,5 +1,4 @@ import { describe, expect, it } from "vitest"; -import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; @@ -7,6 +6,7 @@ import Page from "@/app/admin/memory/fixture-dry-run/page"; import { buildAllFirstReviewedMemoryFixtures, buildFirstReviewedMemoryFixture } from "@/lib/services/first-reviewed-memory-fixture-builder"; import { createInMemoryFirstReviewedMemoryFixtureRepository } from "@/lib/db/first-reviewed-memory-fixture-repository"; import { runManualWorkflowFixtureHarness } from "@/lib/services/operator-manual-workflow-fixture-harness"; +import { runFirstReviewedMemoryFixtureCli } from "@/scripts/verify-first-reviewed-memory-fixture"; const root = process.cwd(); @@ -16,7 +16,7 @@ describe("first reviewed-memory fixture dry-run pack", () => { it("blocks contamination, missing audit, and non-append decisions", async () => { for (const scenario of ["blocked_au_to_real_life_contamination", "blocked_missing_audit", "blocked_non_append_decision"] as const) { const result = await runManualWorkflowFixtureHarness({ fixture: buildFirstReviewedMemoryFixture({ scenario }) }); expect(result.ok).toBe(true); expect(result.expectedBlocked).toBe(true); } }); it("repository is in-memory only and never writes actual memory tables", async () => { const repo = createInMemoryFirstReviewedMemoryFixtureRepository(buildAllFirstReviewedMemoryFixtures()); expect(repo.testOnly).toBe(true); expect(repo.productionSeed).toBe(false); await expect(repo.createReviewQueueItem({ userId: "u", namespace: "real_life" }, {} as never)).resolves.toMatchObject({ ok: false }); }); it("harness verifies preview ordering, injected executor, readback, browser, audit, and receipt", async () => { const result = await runManualWorkflowFixtureHarness({ fixture: buildFirstReviewedMemoryFixture({ scenario: "real_life_fact_append" }) }); expect(result).toMatchObject({ ok: true, previewBeforeExecutor: true, executorInjectedOnly: true, readbackVerified: true, browserVerified: true, auditVerified: true, receiptVerified: true }); }); - it("CLI prints safe output only", () => { const out = execFileSync("npm", ["run", "verify:first-reviewed-memory-fixture"], { cwd: root, encoding: "utf8" }); expect(out).toContain("TEST-ONLY FIXTURE HARNESS"); expect(out).toContain("NO PRODUCTION WRITES"); expect(out).toContain("NO MODEL CALLS"); expect(out).toContain("NO SEMANTIC RETRIEVAL"); expect(out).not.toMatch(/SUPABASE_SERVICE_ROLE|sk-|secret/i); }); + it("CLI prints safe output only", async () => { const result = await runFirstReviewedMemoryFixtureCli(); const out = result.output; expect(result.ok).toBe(true); expect(out).toContain("TEST-ONLY FIXTURE HARNESS"); expect(out).toContain("NO PRODUCTION WRITES"); expect(out).toContain("NO MODEL CALLS"); expect(out).toContain("NO SEMANTIC RETRIEVAL"); expect(out).not.toMatch(/SUPABASE_SERVICE_ROLE|sk-|secret/i); }); it("UI renders test-only safety copy", () => { const html = renderToStaticMarkup(); expect(html).toContain("First reviewed-memory fixture dry-run"); expect(html).toContain("Test-only fixture harness"); expect(html).toContain("No production writes"); expect(html).toContain("No public persistence"); expect(html).toContain("No production ingest writes"); expect(html).toContain("No model calls, embeddings, or semantic retrieval"); expect(html).toContain("AU/story memory cannot become real-life evidence"); }); it("fixture files avoid Supabase service-role, model, retrieval, vector, pgvector, GPT Actions, and MCP imports", () => { const files = ["lib/services/first-reviewed-memory-fixture-contract.ts", "lib/services/first-reviewed-memory-fixture-builder.ts", "lib/db/first-reviewed-memory-fixture-repository.ts", "lib/services/operator-manual-workflow-fixture-harness.ts", "scripts/verify-first-reviewed-memory-fixture.ts", "app/admin/memory/fixture-dry-run/page.tsx"]; const text = files.map((f) => readFileSync(join(root, f), "utf8")).join("\n"); expect(text).not.toMatch(/service-role|SUPABASE_SERVICE_ROLE|from ["'].*(supabase|openai|anthropic|retrieval|pgvector|vector|embedding|gpt-actions|mcp)/i); }); it("critical production boundaries remain disabled or read-only", () => { expect(readFileSync(join(root, "lib/api/memory-ingest-route-handler.ts"), "utf8")).toMatch(/intentionally disabled|production/i); expect(readFileSync(join(root, "app/api/memory/review/[id]/persist/route.ts"), "utf8")).toMatch(/disabled/i); expect(readFileSync(join(root, "lib/services/persisted-memory-browser-loader.ts"), "utf8")).not.toMatch(/executeApproved|appendReviewDecision|deleteMemory|updateMemory/); expect(readFileSync(join(root, "app/api/admin/memory/qa-flow/route.ts"), "utf8")).toMatch(/enabled: false/); expect(readFileSync(join(root, "lib/api/operator-manual-memory-workflow-route-handler.ts"), "utf8")).toMatch(/manual_workflow_disabled|disabled/i); }); diff --git a/tests/unit/rls-policies.test.ts b/tests/unit/rls-policies.test.ts index be86ea0..c267773 100644 --- a/tests/unit/rls-policies.test.ts +++ b/tests/unit/rls-policies.test.ts @@ -14,6 +14,11 @@ const rlsSql = migrationFiles .toLowerCase() .replace(/\s+/g, " "); +const adaptiveLogRlsSql = readFileSync( + path.resolve(process.cwd(), "supabase/migrations/20260704103859_adaptive_log_rls_policies.sql"), + "utf8", +).toLowerCase().replace(/\s+/g, " "); + const requiredTables = [ "memory_items", "memory_sources", @@ -70,4 +75,13 @@ describe("RLS policy migrations", () => { expect(rlsSql).not.toContain(` for ${changeOperation} `); expect(rlsSql).not.toContain(` for ${removeOperation} `); }); + + it("covers adaptive memory log tables created after the foundation migrations", () => { + for (const tableName of ["memory_retrieval_logs", "memory_model_call_logs"]) { + expect(adaptiveLogRlsSql).toContain(`alter table public.${tableName} enable row level security`); + expect(adaptiveLogRlsSql).toContain(`alter table public.${tableName} force row level security`); + expect(adaptiveLogRlsSql).toContain(`create policy "${tableName}_select_own" on public.${tableName} for select to authenticated using ((select auth.uid()) = user_id)`); + expect(adaptiveLogRlsSql).toContain(`create policy "${tableName}_insert_own" on public.${tableName} for insert to authenticated with check ((select auth.uid()) = user_id)`); + } + }); });