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
4 changes: 2 additions & 2 deletions app/api/admin/backfill-embeddings/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getSupabase } from "@/lib/supabase";
import { getSupabaseAdmin } from "@/lib/supabase";
import { backfillPromptEmbeddings } from "@/lib/prompt-cache-embedding";

export const dynamic = "force-dynamic";
Expand All @@ -20,7 +20,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
}

const supabase = getSupabase();
const supabase = getSupabaseAdmin();
if (!supabase) {
return NextResponse.json({ error: "Database unavailable." }, { status: 503 });
}
Expand Down
4 changes: 2 additions & 2 deletions app/api/admin/backfill-titles/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getSupabase } from "@/lib/supabase";
import { getSupabaseAdmin } from "@/lib/supabase";
import { backfillPromptTitles } from "@/lib/prompt-cache-title";

export const dynamic = "force-dynamic";
Expand All @@ -20,7 +20,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
}

const supabase = getSupabase();
const supabase = getSupabaseAdmin();
if (!supabase) {
return NextResponse.json({ error: "Database unavailable." }, { status: 503 });
}
Expand Down
4 changes: 2 additions & 2 deletions app/api/increment-views/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
isValidGitHubRepoPath,
normalizeRepoSegment,
} from "@/lib/parse-github-repo";
import { getSupabase } from "@/lib/supabase";
import { getSupabaseAdmin } from "@/lib/supabase";
import {
hashVisitorIp,
isDefaultIpHashSaltInProduction,
Expand Down Expand Up @@ -54,7 +54,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid owner or repo." }, { status: 400 });
}

const supabase = getSupabase();
const supabase = getSupabaseAdmin();
if (!supabase) {
return NextResponse.json({ error: "Database unavailable." }, { status: 503 });
}
Expand Down
4 changes: 2 additions & 2 deletions app/api/reverse-prompt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SYSTEM_PROMPT } from "@/lib/system-prompt";
import { getFileTree, getReadme, getRepoMeta } from "@/lib/github-client";
import { formatAsFilteredTree } from "@/lib/file-tree-formatter";
import { parseGitHubRepoInput } from "@/lib/parse-github-repo";
import { getSupabase } from "@/lib/supabase";
import { getSupabase, getSupabaseAdmin } from "@/lib/supabase";
import {
buildAzureChatCompletionsBody,
buildAzureOpenAiUrl,
Expand Down Expand Up @@ -512,7 +512,7 @@ export async function POST(request: NextRequest) {
);
}

const sb = getSupabase();
const sb = getSupabaseAdmin();
if (sb) {
void sb
.from("prompt_cache")
Expand Down
20 changes: 19 additions & 1 deletion app/api/reverse-website/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidWebsiteSlug, parseWebsiteInput } from "@/lib/parse-website-input";
import { isValidWebsiteSlug, parseWebsiteInput, urlToSlug } from "@/lib/parse-website-input";
import {
ensureWebsiteReversed,
type WebsiteReverseResult,
Expand Down Expand Up @@ -114,6 +114,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

if (!body || typeof body !== "object" || Array.isArray(body) ||
(body.siteSlug !== undefined && typeof body.siteSlug !== "string") ||
(body.targetUrl !== undefined && typeof body.targetUrl !== "string")) {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}

// Refreshes overwrite shared generated content and require explicit admin access.
if (body.force === true) {
const secret = process.env.ADMIN_SECRET?.trim();
if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Unauthorized refresh." }, { status: 403 });
}
}

const siteSlug = body.siteSlug?.trim().toLowerCase();
if (!siteSlug || !isValidWebsiteSlug(siteSlug)) {
return NextResponse.json(
Expand Down Expand Up @@ -148,6 +162,10 @@ export async function POST(request: NextRequest) {
);
}

if (urlToSlug(parsed.hostname) !== siteSlug) {
return NextResponse.json({ error: "Website slug does not match URL." }, { status: 400 });
}

const timer = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("__timeout__")), ROUTE_TIMEOUT_MS)
);
Expand Down
6 changes: 4 additions & 2 deletions app/library/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const getCachedInitialLibrary = unstable_cache(
if (!supabase) throw new Error("Database unavailable.");
return fetchInitialLibrary(supabase, INITIAL_LIMIT);
},
["library-initial-newest-v4"],
["library-initial-newest-v5"],
{ revalidate: 60 }
);

Expand All @@ -57,7 +57,7 @@ export default async function LibraryRoute() {
description:
"Browse reverse-engineered coding agent prompts from GitHub repositories and live websites.",
url: "https://gitreverse.com/library",
numberOfItems: initialTotal,
numberOfItems: initial.totalIsEstimate ? undefined : initialTotal,
hasPart: initialData.slice(0, 10).map((entry) => ({
"@type": "TechArticle",
name: entry.title,
Expand All @@ -71,6 +71,8 @@ export default async function LibraryRoute() {
<LibraryPage
initialData={initialData}
initialTotal={initialTotal}
initialTotalIsEstimate={initial.totalIsEstimate}
initialHasMore={initial.hasMore}
initialUnavailableSources={initial.unavailableSources}
initialError={initialError}
/>
Expand Down
20 changes: 14 additions & 6 deletions components/library-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,23 @@ function relativeTime(iso: string): string {
type LibraryPageProps = {
initialData: LibraryEntry[];
initialTotal: number;
initialTotalIsEstimate?: boolean;
initialHasMore?: boolean;
initialUnavailableSources?: LibraryEntryKind[];
initialError?: string;
};

export function LibraryPage({
initialData, initialTotal, initialUnavailableSources = [], initialError,
initialData, initialTotal, initialTotalIsEstimate = false,
initialHasMore = initialData.length < initialTotal,
initialUnavailableSources = [], initialError,
}: LibraryPageProps) {
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortOption>("newest");
const [entries, setEntries] = useState<LibraryEntry[]>(initialData);
const [total, setTotal] = useState(initialTotal);
const [totalIsEstimate, setTotalIsEstimate] = useState(initialTotalIsEstimate);
const [hasMore, setHasMore] = useState(initialHasMore);
const [error, setError] = useState<string | undefined>(initialError);
const [unavailableSources, setUnavailableSources] = useState(initialUnavailableSources);
const requestRef = useRef(0);
Expand Down Expand Up @@ -91,6 +97,8 @@ export function LibraryPage({
setEntries(json.data);
}
setTotal(json.total);
setTotalIsEstimate(json.totalIsEstimate ?? false);
setHasMore(json.hasMore ?? false);
setUnavailableSources(json.unavailableSources ?? []);
setPage(pageVal);
} catch {
Expand All @@ -99,6 +107,8 @@ export function LibraryPage({
if (!append) {
setEntries([]);
setTotal(0);
setTotalIsEstimate(false);
setHasMore(false);
setUnavailableSources([]);
}
}
Expand Down Expand Up @@ -135,8 +145,6 @@ export function LibraryPage({
}
}

const hasMore = entries.length < total;

return (
<div className="flex min-h-screen flex-col bg-[#FFFDF8] text-zinc-900">
<Navbar />
Expand All @@ -148,7 +156,7 @@ export function LibraryPage({
<div className="absolute inset-0 translate-x-1.5 translate-y-1.5 rounded-lg bg-zinc-900" />
<div className="relative z-10 rounded-lg border-[3px] border-zinc-900 bg-[#d31611] px-4 py-1">
<span className="text-sm font-bold text-white">
{error ? "Prompt library" : `${total.toLocaleString()}+ prompts`}
{error ? "Prompt library" : `${totalIsEstimate ? "~" : ""}${total.toLocaleString()} prompts`}
</span>
</div>
</div>
Expand Down Expand Up @@ -286,7 +294,7 @@ export function LibraryPage({
{search && !error ? (
<p className="text-sm text-zinc-500">
<span className="font-semibold text-zinc-900">
{total.toLocaleString()}
{totalIsEstimate ? "About " : ""}{total.toLocaleString()}
</span>{" "}
result{total !== 1 ? "s" : ""}{unavailableSources.length > 0 ? " from available sources" : ""}
</p>
Expand Down Expand Up @@ -350,7 +358,7 @@ export function LibraryPage({
Loading…
</>
) : (
<>Load {Math.min(PAGE_SIZE, total - entries.length)} more ↓</>
<>Load {totalIsEstimate ? "" : `${Math.min(PAGE_SIZE, total - entries.length)} `}more ↓</>
)}
</button>
</div>
Expand Down
31 changes: 31 additions & 0 deletions docs/cache-write-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Cache write authorization

Public Supabase keys are intentionally public. Generated cache content must be writable only by trusted server code, not by anonymous or ordinary signed-in API clients.

## Release order

1. Confirm production has `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, and server-only `SUPABASE_SERVICE_ROLE_KEY`. Never print or expose credential values.
2. Deploy this code before applying `20261106092711_restrict_cache_writes.sql`. It keeps public reads on the publishable client and moves cache generation, title/embedding backfills, and view counting to the existing privileged server client.
3. Apply the migration using the authorized production database management path. It removes non-SELECT policies and write grants (including column grants) on eight generated caches and `view_log`, and limits the three view-count RPCs to the service role. Missing legacy tables/functions are skipped.
4. Verify catalog privileges/policies, public library reads, admin backfill authorization, and normal generation/view counting. Do not poison a production row as a security test. Use a staging fixture for write-denial tests.

## Authorization boundaries

- `lib/supabase.ts` is marked `server-only`; it must never be imported into browser code.
- The reverse-prompt endpoint accepts a repository identifier, not arbitrary cached prompt text. Writes contain server-generated output.
- Website keys must match the URL-derived slug. Forced refreshes overwrite shared content and now require `ADMIN_SECRET` authorization. Regular users can still generate cache misses and read existing entries.
- Website storage binds an existing slug to its exact URL, including collision cases, and uses insert rather than upsert for new entries. A racing insert fails instead of overwriting a different URL. No credentials or arbitrary supplied prompt contents are accepted from the browser.
- Admin title/embedding endpoints retain their existing authorization checks before obtaining privileged access.
- This does not change JWT verification, billing, or user-history ownership policies.

## Verification

`pnpm test:cache-security` runs actual PostgreSQL role/RLS behavior in isolated PGlite databases, without production connectivity. It checks anonymous/authenticated write denials, column-grant removal, direct view-RPC denials, public reads, service-role writes, idempotence, and absent legacy objects. Route tests reject untrusted slug/refresh requests and verify privileged website writes with a local fetch stub.

Run the full suite with `node --conditions=react-server --import tsx --test tests/*.test.ts`, plus TypeScript and targeted lint. The React server condition is required when testing server-only imports outside Next.js.

## Rollback and limitations

Do not restore insecure public write policies to undo an application problem. Keep the migrated database locked down and forward-fix server writes; reverting to an older application that uses anonymous writes would lose cache persistence and view updates. Preserve the previous deployment for inspection but do not promote it after migration without equivalent privileged writers.

This closes direct API cache poisoning; it does not establish whether historical cache rows were modified. Review historical integrity separately. It also does not claim to solve adversarial source content or all existing SQL/security advisories. Legacy feature writers absent from current main must use server credentials before being reintroduced.
33 changes: 33 additions & 0 deletions docs/library-code-counts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Library code-source timeout repair

## Diagnosis (2026-10-29)

Blank production Library browse returned only website cards with `unavailableSources: ["code"]`; code-only browse returned HTTP 503. PostgreSQL logs showed repeated SQLSTATE `57014` cancellations for code browse's combined page plus exact-count statement. Code rows, the cached-at/trending/FTS indexes, the existing security-invoker view, and its SELECT visibility were present.

Read-only anonymous-role probes using the existing 3-second statement limit isolated the count dependency. Count-free PostgREST-shaped pages passed: newest ~17 ms, oldest ~28 ms, trending ~39 ms, accounting FTS ~172 ms, and no-match FTS ~150 ms. Combined newest page/count probes took ~2.5–2.9 seconds and production requests timed out. Separate exact trending and FTS counts also timed out, so merely splitting row/count requests is not a sufficient repair. Runtime varies; this is not evidence of a deterministic prompt-hydration or parallel-query issue.

## Change

- Code browse and keyword fallback use PostgREST `count: "planned"`, avoiding a full exact-count scan. This uses the same public view, publishable client, RLS, fields, filters, ordering, and 96-row per-source fetch bound.
- Responses label estimated totals with `totalIsEstimate`. The UI shows `~` / `About`, and structured data omits an estimated `numberOfItems`.
- Returned rows are authoritative: stale nonzero estimates cannot skip search fallback on an empty response, and estimates cannot report fewer results than the rows already fetched.
- `hasMore` follows the fetched merged window instead of an estimate. It stops on a partial page and at 96 displayed results. The prior cap was per source, which could expose more mixed entries, but merging deeper pages without fetching additional source rows is not reliable. This change makes the safe global stopping boundary explicit instead of repeatedly loading an exhausted window.
- Existing partial-source and true-outage reporting remains intact; the initial cached result key is bumped.

[PostgREST count documentation](https://postgrest.org/en/stable/references/api/pagination_count.html) describes the planned-count tradeoff. Counts are intentionally approximate; cards are real returned rows.

## Scope and rollout

No migration, function, index, grants, RLS, secrets, embeddings provider, or timeout setting is changed. This is independent of draft PR #168 and does not apply its denied/pending RPC migration. Some keyword metadata queries can still time out even without exact counts; this does not claim to solve all keyword-query planning issues.

The code must still be reviewed, merged and deployed with owner authorization before production behavior changes. After deployment, check blank Library (both sources), code-only newest/oldest/trending, pagination, and an existing keyword result. Verify the deployed commit and absence of code-source errors; don't infer deployment from a green test run.

## Verification

- Credential-free full suite: `node --import tsx --test tests/*.test.ts` (72 tests, including 34 Library tests).
- TypeScript and focused ESLint pass; production Next build passes without production credentials (local-only `VIEWS_IP_SALT` placeholder).
- Independent review found no blockers.
- Read-only production SQL shape checks pass as noted above; these are not deployed REST/UI verification.
- Repository-wide lint retains one pre-existing error in `lib/use-partner-preview.ts` and two pre-existing accessibility warnings in `components/reverse-prompt-home.tsx`.
- Local API and server-rendered HTML smoke checks passed with a credential-free mock database that rejects exact code counts; code cards, approximate labels, and `hasMore` were present without an outage warning.
- Local interactive Chromium QA was blocked by the executor's IPC/sandbox startup limitation; cloud browser cannot access this executor's loopback server. Do not count the attempted browser suite as passed.
Loading
Loading