diff --git a/src/app/admin/api/safety-identifiers/route.ts b/src/app/admin/api/safety-identifiers/route.ts new file mode 100644 index 0000000000..c9cb748672 --- /dev/null +++ b/src/app/admin/api/safety-identifiers/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server'; +import { getUserFromAuth } from '@/lib/user.server'; +import { db } from '@/lib/drizzle'; +import { kilocode_users } from '@kilocode/db'; +import { + generateOpenRouterUpstreamSafetyIdentifier, + generateVercelDownstreamSafetyIdentifier, +} from '@/lib/providerHash'; +import { isNull, count, or, desc, eq } from 'drizzle-orm'; + +const missingEither = or( + isNull(kilocode_users.openrouter_upstream_safety_identifier), + isNull(kilocode_users.vercel_downstream_safety_identifier) +); + +export type SafetyIdentifierCountsResponse = { + missing: number; +}; + +export type BackfillBatchResponse = { + processed: number; + remaining: boolean; +}; + +export async function GET(): Promise< + NextResponse +> { + const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) return authFailedResponse; + + const [result] = await db.select({ count: count() }).from(kilocode_users).where(missingEither); + + return NextResponse.json({ missing: result?.count ?? 0 }); +} + +export async function POST(): Promise> { + const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) return authFailedResponse; + + const processed = await db.transaction(async tran => { + const rows = await tran + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(missingEither) + .orderBy(desc(kilocode_users.created_at)) + .limit(1000); + + for (const user of rows) { + const openrouter_upstream_safety_identifier = generateOpenRouterUpstreamSafetyIdentifier( + user.id + ); + if (openrouter_upstream_safety_identifier === null) { + return null; + } + await tran + .update(kilocode_users) + .set({ + openrouter_upstream_safety_identifier, + vercel_downstream_safety_identifier: generateVercelDownstreamSafetyIdentifier(user.id), + }) + .where(eq(kilocode_users.id, user.id)) + .execute(); + } + + return rows.length; + }); + + if (processed === null) { + return NextResponse.json( + { error: 'OPENROUTER_ORG_ID is not configured on this server' }, + { status: 500 } + ); + } + + return NextResponse.json({ processed, remaining: processed === 1000 }); +} diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index 0fc8480f80..8eddc3788e 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -23,6 +23,7 @@ import { Bell, Server, Network, + KeyRound, } from 'lucide-react'; import { useSession } from 'next-auth/react'; import type { Session } from 'next-auth'; @@ -79,6 +80,11 @@ const userManagementItems: MenuItem[] = [ url: '/admin/blacklisted-domains', icon: () => , }, + { + title: () => 'Safety Identifiers', + url: '/admin/safety-identifiers', + icon: () => , + }, ]; const financialItems: MenuItem[] = [ diff --git a/src/app/admin/components/SafetyIdentifiersBackfill.tsx b/src/app/admin/components/SafetyIdentifiersBackfill.tsx new file mode 100644 index 0000000000..5c7b8ed641 --- /dev/null +++ b/src/app/admin/components/SafetyIdentifiersBackfill.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import type { + SafetyIdentifierCountsResponse, + BackfillBatchResponse, +} from '../api/safety-identifiers/route'; + +type BatchLog = { + processed: number; + timestamp: Date; +}; + +export function SafetyIdentifiersBackfill() { + const [logs, setLogs] = useState([]); + const queryClient = useQueryClient(); + + const { data: counts, isLoading } = useQuery({ + queryKey: ['safety-identifier-counts'], + queryFn: async () => { + const res = await fetch('/admin/api/safety-identifiers'); + return res.json() as Promise; + }, + refetchInterval: false, + }); + + const mutation = useMutation({ + mutationFn: async () => { + const res = await fetch('/admin/api/safety-identifiers', { method: 'POST' }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `HTTP ${res.status}`); + } + return res.json() as Promise; + }, + onSuccess: data => { + setLogs(prev => [{ processed: data.processed, timestamp: new Date() }, ...prev]); + void queryClient.invalidateQueries({ queryKey: ['safety-identifier-counts'] }); + }, + }); + + const isDone = counts?.missing === 0; + + return ( +
+

+ Backfill safety identifiers for users missing either field. Each click processes up to 1 000 + users. Click repeatedly until the counter reaches zero. +

+ +
+
+ Users missing a safety identifier + {isLoading ? ( + Loading… + ) : isDone ? ( + + All filled + + ) : ( + {(counts?.missing ?? 0).toLocaleString()} missing + )} +
+ + {mutation.isError && ( + + {mutation.error.message} + + )} + + +
+ + {logs.length > 0 && ( +
+

Batch log

+
+ {logs.map((log, i) => ( +
+ {log.timestamp.toLocaleTimeString()} + processed {log.processed.toLocaleString()} users +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx b/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx index 3566922e28..f60db21738 100644 --- a/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx +++ b/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx @@ -108,6 +108,21 @@ export function UserAdminAccountInfo(user: UserAdminAccountInfoProps) {

N/A

)} +
+

+ Vercel Downstream Safety Identifier +

+ {user.vercel_downstream_safety_identifier ? ( +
+

+ {user.vercel_downstream_safety_identifier} +

+ +
+ ) : ( +

N/A

+ )} +
diff --git a/src/app/admin/safety-identifiers/page.tsx b/src/app/admin/safety-identifiers/page.tsx new file mode 100644 index 0000000000..f0ed5dbb0f --- /dev/null +++ b/src/app/admin/safety-identifiers/page.tsx @@ -0,0 +1,24 @@ +import { SafetyIdentifiersBackfill } from '../components/SafetyIdentifiersBackfill'; +import AdminPage from '../components/AdminPage'; +import { BreadcrumbItem, BreadcrumbPage } from '@/components/ui/breadcrumb'; + +const breadcrumbs = ( + <> + + Safety Identifiers + + +); + +export default function SafetyIdentifiersPage() { + return ( + +
+
+

Safety Identifier Backfill

+
+ +
+
+ ); +} diff --git a/src/lib/user.test.ts b/src/lib/user.test.ts index 04109844ee..612a005dc0 100644 --- a/src/lib/user.test.ts +++ b/src/lib/user.test.ts @@ -92,6 +92,7 @@ describe('User', () => { linkedin_url: 'https://linkedin.com/in/testuser', github_url: 'https://github.com/testuser', openrouter_upstream_safety_identifier: 'openrouter_upstream_safety_identifier', + vercel_downstream_safety_identifier: 'vercel_downstream_safety_identifier', customer_source: 'A YouTube video', is_admin: true, }); @@ -108,6 +109,7 @@ describe('User', () => { expect(softDeleted!.github_url).toBeNull(); expect(softDeleted!.discord_server_membership_verified_at).toBeNull(); expect(softDeleted!.openrouter_upstream_safety_identifier).toBeNull(); + expect(softDeleted!.vercel_downstream_safety_identifier).toBeNull(); expect(softDeleted!.customer_source).toBeNull(); expect(softDeleted!.api_token_pepper).toBeNull(); expect(softDeleted!.default_model).toBeNull(); diff --git a/src/scripts/openrouter/backfill-safety-identifier.ts b/src/scripts/openrouter/backfill-safety-identifier.ts deleted file mode 100644 index c8e1f690c6..0000000000 --- a/src/scripts/openrouter/backfill-safety-identifier.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { db } from '@/lib/drizzle'; -import { generateOpenRouterUpstreamSafetyIdentifier } from '@/lib/providerHash'; -import { kilocode_users } from '@kilocode/db'; -import { isNull, desc, eq } from 'drizzle-orm'; - -export async function run() { - while (true) { - const count = await db.transaction(async tran => { - const rows = await tran - .select({ - id: kilocode_users.id, - }) - .from(kilocode_users) - .where(isNull(kilocode_users.openrouter_upstream_safety_identifier)) - .orderBy(desc(kilocode_users.created_at)) - .limit(1000); - if (rows.length === 0) { - return 0; - } - console.log(`Batch of ${rows.length} users`); - for (const user of rows) { - const openrouter_upstream_safety_identifier = generateOpenRouterUpstreamSafetyIdentifier( - user.id - ); - await tran - .update(kilocode_users) - .set({ - openrouter_upstream_safety_identifier, - }) - .where(eq(kilocode_users.id, user.id)) - .execute(); - } - console.log('Commit'); - return rows.length; - }); - if (count === 0) { - break; - } - } -}