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
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export { GET, OPTIONS, dynamic, runtime } from "../route";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";

export { GET, OPTIONS } from "../route";
24 changes: 23 additions & 1 deletion app/admin/env/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <main style={{ padding: 24, fontFamily: "system-ui, sans-serif" }}>
<h1>Pandora Env Broker</h1>
<p>Env Broker access is locked. Use an env-admin Supabase session or an internal operator unlock token.</p>
<section style={{ border: "1px solid #ddd", padding: 12, maxWidth: 760 }}>
<h2>Operator unlock</h2>
<p>The key is accepted server-side, stored only in an HttpOnly cookie for 30 minutes, and is never rendered back to the page.</p>
<form method="post" action="/api/admin/env/status">
<label>Operator key <input name="operator_key" type="password" placeholder="Paste operator key once" style={{ minWidth: 320 }} /></label>
<button type="submit">Unlock Env Broker actions</button>
</form>
</section>
<p><a href="/auth/login?next=%2Fadmin%2Fenv">Start Supabase session</a></p>
</main>;
}

export default async function AdminEnvPage() {
const guard = await requireEnvAdmin(false);
if (guard.response) return <LockedEnvBrokerPage />;

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

<section style={{ border: "1px solid #ddd", padding: 12, maxWidth: 760 }}>
<h2>Operator unlock</h2>
<p>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.</p>
<p>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.</p>
<form method="post" action="/api/admin/env/status">
<label>Operator key <input name="operator_key" type="password" placeholder="Paste operator key once" style={{ minWidth: 320 }} /></label>
<button type="submit">Unlock Env Broker actions</button>
Expand Down
2 changes: 1 addition & 1 deletion app/api/admin/env/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope the operator-token cookie to env admin routes

After a successful unlock, this stores the raw operator/job token as a Path=/ cookie, so every subsequent request to unrelated routes such as /dashboard carries pandora_env_admin even though only /admin/env and /api/admin/env/* need it. Because getConfiguredOperatorTokens() accepts internal production tokens, this broadens where a secret is exposed inside the app; use a route-scoped/opaque unlock cookie instead of sending the raw credential site-wide.

Useful? React with 👍 / 👎.

return response;
}
29 changes: 26 additions & 3 deletions lib/services/env-admin-route-guard.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
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;
authType: "supabase" | "operator_token";
};

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) {
Expand All @@ -30,6 +41,18 @@ export async function requireEnvAdmin(mutation = true): Promise<{ user: EnvAdmin
return { user, response: null };
}

export function hasEnvAdminCapability(user: Pick<User, "app_metadata"> | 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<boolean> {
const configured = getConfiguredOperatorTokens();
if (!configured.length) return false;
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 19 additions & 8 deletions scripts/verify-first-reviewed-memory-fixture.ts
Original file line number Diff line number Diff line change
@@ -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); });
50 changes: 50 additions & 0 deletions supabase/migrations/20260704103859_adaptive_log_rls_policies.sql
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Constrain adaptive log inserts to valid namespaces

This new authenticated insert policy only checks ownership, so any signed-in user can now insert memory_retrieval_logs rows for their own user_id with namespace = 'foo' or another cross-domain value; the same pattern is repeated for memory_model_call_logs below. Since these tables feed adaptive memory/retrieval audit data and the project requires strict real_life/au separation, the RLS with check should also enforce the allowed namespace before opening authenticated writes.

Useful? React with 👍 / 👎.

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 $$;
111 changes: 111 additions & 0 deletions tests/unit/env-admin-route-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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");
});
});
4 changes: 2 additions & 2 deletions tests/unit/first-reviewed-memory-fixture.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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";
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();

Expand All @@ -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(<Page />); 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); });
Expand Down
Loading
Loading