diff --git a/.gitignore b/.gitignore index 0e1f721..c084b15 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test-results outputs/* work/* packages/rage-cli/dist +.vercel diff --git a/README.md b/README.md index 8e10915..6dbceee 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Rage AI is a local-first frustration leaderboard for Claude Code and Codex. It s - Better Auth with GitHub OAuth and API-key publish tokens - Neon Postgres via Drizzle ORM and `pg` - Upstash Redis for product rate limits +- Copy-ready X and LinkedIn share drafts from public aggregate scores - Biome, Vitest, Playwright, and `@wiseiodev/guardrails` ## Development diff --git a/apps/web/package.json b/apps/web/package.json index d56cf14..27940ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "next dev", + "env:dev": "cd ../.. && pnpm dlx vercel@latest env pull apps/web/.env --yes", "build": "next build", "start": "next start", "check": "biome check .", diff --git a/apps/web/src/app/api/auth/device/approve/route.ts b/apps/web/src/app/api/auth/device/approve/route.ts index da9fac7..f36513c 100644 --- a/apps/web/src/app/api/auth/device/approve/route.ts +++ b/apps/web/src/app/api/auth/device/approve/route.ts @@ -14,16 +14,28 @@ const approveSchema = z.object({ .trim() .min(2) .max(32) - .regex(/^[a-zA-Z0-9_-]+$/), + .regex(/^[a-zA-Z0-9_-]+$/) + .optional() + .or(z.literal('')) + .transform((value) => (value ? value : undefined)), }) -async function parseBody(request: Request) { +async function parseBody(request: Request): Promise<{ body: unknown; isForm: boolean }> { const contentType = request.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { - return request.json() + return { body: await request.json(), isForm: false } } const formData = await request.formData() - return Object.fromEntries(formData.entries()) + return { body: Object.fromEntries(formData.entries()), isForm: true } +} + +function redirectToDevicePage(request: Request, userCode: string, approved = false) { + const url = new URL('/auth/device', request.url) + url.searchParams.set('code', userCode) + if (approved) { + url.searchParams.set('approved', '1') + } + return Response.redirect(url, 303) } export async function POST(request: Request) { @@ -32,7 +44,8 @@ export async function POST(request: Request) { return Response.json({ ok: false, message: 'Sign in with GitHub first' }, { status: 401 }) } - const body = approveSchema.safeParse(await parseBody(request).catch(() => null)) + const parsedBody = await parseBody(request).catch(() => null) + const body = approveSchema.safeParse(parsedBody?.body) if (!body.success) { return Response.json({ ok: false, message: 'Invalid approval request' }, { status: 400 }) } @@ -51,25 +64,30 @@ export async function POST(request: Request) { .limit(1) if (!pending) { + if (parsedBody?.isForm) { + return redirectToDevicePage(request, body.data.userCode) + } return Response.json( { ok: false, message: 'Device code not found or expired' }, { status: 404 }, ) } - await db - .insert(rageProfile) - .values({ - userId: session.user.id, - handle: body.data.handle, - }) - .onConflictDoUpdate({ - target: rageProfile.userId, - set: { + if (body.data.handle) { + await db + .insert(rageProfile) + .values({ + userId: session.user.id, handle: body.data.handle, - updatedAt: new Date(), - }, - }) + }) + .onConflictDoUpdate({ + target: rageProfile.userId, + set: { + handle: body.data.handle, + updatedAt: new Date(), + }, + }) + } await db .update(deviceAuthRequest) @@ -80,6 +98,10 @@ export async function POST(request: Request) { }) .where(eq(deviceAuthRequest.id, pending.id)) + if (parsedBody?.isForm) { + return redirectToDevicePage(request, body.data.userCode, true) + } + const isAdmin = adminLogins().has(session.user.name?.toLowerCase() ?? '') return Response.json({ ok: true, diff --git a/apps/web/src/app/api/auth/device/complete/route.ts b/apps/web/src/app/api/auth/device/complete/route.ts index b3b3246..eed26c6 100644 --- a/apps/web/src/app/api/auth/device/complete/route.ts +++ b/apps/web/src/app/api/auth/device/complete/route.ts @@ -19,35 +19,45 @@ export async function POST(request: Request) { const db = getDb() const installIdHash = hmacIdentifier(body.data.installId) - const [approved] = await db + const [requestRecord] = await db .select() .from(deviceAuthRequest) .where( and( eq(deviceAuthRequest.deviceCodeHash, hmacIdentifier(body.data.deviceCode)), eq(deviceAuthRequest.installIdHash, installIdHash), - eq(deviceAuthRequest.status, 'approved'), isNull(deviceAuthRequest.apiKeyId), gt(deviceAuthRequest.expiresAt, new Date()), ), ) .limit(1) - if (!approved?.userId) { + if (!requestRecord) { return Response.json( - { ok: false, message: 'Device is not approved or has expired' }, + { ok: false, message: 'Device auth request has expired or was already completed' }, { status: 404 }, ) } + if (requestRecord.status !== 'approved' || !requestRecord.userId) { + return Response.json( + { + ok: false, + message: 'Device is waiting for browser approval', + status: requestRecord.status, + }, + { status: 202 }, + ) + } + const token = randomToken('rage_sk') const apiKeyId = randomUUID() await db.insert(apiKeyTable).values({ id: apiKeyId, - name: approved.deviceLabel, + name: requestRecord.deviceLabel, prefix: 'rage_sk', key: hmacIdentifier(token), - userId: approved.userId, + userId: requestRecord.userId, metadata: { installIdHash, scope: 'publish', @@ -60,12 +70,12 @@ export async function POST(request: Request) { apiKeyId, updatedAt: new Date(), }) - .where(eq(deviceAuthRequest.id, approved.id)) + .where(eq(deviceAuthRequest.id, requestRecord.id)) const [profile] = await db .select({ handle: rageProfile.handle }) .from(rageProfile) - .where(eq(rageProfile.userId, approved.userId)) + .where(eq(rageProfile.userId, requestRecord.userId)) .limit(1) return Response.json({ diff --git a/apps/web/src/app/api/publish/route.ts b/apps/web/src/app/api/publish/route.ts index 6ad66db..186349a 100644 --- a/apps/web/src/app/api/publish/route.ts +++ b/apps/web/src/app/api/publish/route.ts @@ -4,6 +4,8 @@ import { type PublishBatchRequest, publishBatchRequestSchema, publishRequestSchema, + type ShareUrl, + type WindowKind, } from '@rageai/core' import { apiKey as apiKeyTable, getDb, leaderboardRow, rageProfile, submission } from '@rageai/db' import { and, eq } from 'drizzle-orm' @@ -181,11 +183,35 @@ export async function POST(request: Request) { } }) + const shareUrls: ShareUrl[] = [] + const shareWindows = new Set(firstPayload.windows.map((window) => window.window)) + for (const window of shareWindows) { + const [row] = await db + .select({ id: leaderboardRow.id }) + .from(leaderboardRow) + .where( + and( + eq(leaderboardRow.userId, keyRecord.userId), + eq(leaderboardRow.installIdHash, installIdHash), + eq(leaderboardRow.hostApp, firstPayload.hostApp), + eq(leaderboardRow.window, window), + ), + ) + .limit(1) + if (row) { + shareUrls.push({ + window, + url: `${appUrl()}/share/${row.id}`, + }) + } + } + return Response.json({ ok: true, message: parsed.publicPayloads.length === 1 ? 'Rage score published.' : 'Rage scores published.', leaderboardUrl: `${appUrl()}/leaderboard`, published: parsed.publicPayloads.map((payload) => payload.hostApp), + shareUrls, }) } diff --git a/apps/web/src/app/auth/device/page.tsx b/apps/web/src/app/auth/device/page.tsx index afb408d..0351fb7 100644 --- a/apps/web/src/app/auth/device/page.tsx +++ b/apps/web/src/app/auth/device/page.tsx @@ -7,9 +7,12 @@ export const dynamic = 'force-dynamic' export default async function DevicePage({ searchParams, }: { - searchParams: Promise<{ code?: string }> + searchParams: Promise<{ approved?: string; code?: string }> }) { const params = await searchParams + const callbackURL = params.code + ? `/auth/device?code=${encodeURIComponent(params.code)}` + : '/auth/device' const session = await getAuth() .api.getSession({ headers: await headers() }) .catch(() => null) @@ -17,6 +20,11 @@ export default async function DevicePage({ return (

Approve Rage CLI

+ {params.approved ? ( +
+ Device approved. You can return to the terminal. +
+ ) : null} {session ? (
- +

+ This stores a local publish token for this device. You can choose your public handle + when publishing. +

diff --git a/apps/web/src/app/leaderboard/page.tsx b/apps/web/src/app/leaderboard/page.tsx index 77199eb..d73c894 100644 --- a/apps/web/src/app/leaderboard/page.tsx +++ b/apps/web/src/app/leaderboard/page.tsx @@ -1,3 +1,5 @@ +import type { Route } from 'next' +import Link from 'next/link' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Table, Td, Th } from '@/components/ui/table' import { getLeaderboardRows } from '@/lib/leaderboard' @@ -38,6 +40,7 @@ export default async function LeaderboardPage() { Swears Words Top + Share @@ -50,6 +53,14 @@ export default async function LeaderboardPage() { {row.scoredProfanityCount} {row.userWordCount} {row.topIntensity ?? '-'} + + + Open + + ))} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 87392cf..3ef164c 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,3 +1,4 @@ +import type { Route } from 'next' import Link from 'next/link' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader } from '@/components/ui/card' @@ -48,6 +49,7 @@ export default async function HomePage() { Host Rage/1k Words + Share @@ -58,6 +60,14 @@ export default async function HomePage() { {row.hostApp} {row.ratePerThousandWords} {row.userWordCount} + + + Open + + ))} diff --git a/apps/web/src/app/share/[rowId]/opengraph-image.tsx b/apps/web/src/app/share/[rowId]/opengraph-image.tsx new file mode 100644 index 0000000..add82c6 --- /dev/null +++ b/apps/web/src/app/share/[rowId]/opengraph-image.tsx @@ -0,0 +1,143 @@ +import { ImageResponse } from 'next/og' +import { getPublicShareData } from '@/lib/share' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export const size = { + width: 1200, + height: 630, +} + +export const contentType = 'image/png' + +type ShareImageProps = { + params: Promise<{ rowId: string }> +} + +function formatWindow(window: string): string { + return window === 'all_time' ? 'all-time' : window +} + +function formatHostApp(hostApp: string): string { + return hostApp === 'claude' ? 'Claude' : 'Codex' +} + +function formatRate(rate: number | string): string { + const numericRate = typeof rate === 'number' ? rate : Number(rate) + return Number.isFinite(numericRate) ? numericRate.toFixed(2).replace(/\.?0+$/u, '') : String(rate) +} + +export default async function Image({ params }: ShareImageProps) { + const { rowId } = await params + const data = await getPublicShareData(rowId) + + if (!data) { + return new ImageResponse( +
+
Rage AI
+
, + size, + ) + } + + const scores = data.scores.slice(0, 2) + const firstScore = scores[0] ?? data.scores[0] + const leader = firstScore + ? scores.reduce((best, score) => { + const bestRate = Number(best.ratePerThousandWords) + const scoreRate = Number(score.ratePerThousandWords) + return scoreRate > bestRate ? score : best + }, firstScore) + : null + const title = + scores.length > 1 + ? 'Claude vs Codex frustration readout' + : firstScore + ? `${formatHostApp(firstScore.hostApp)} frustration readout` + : 'AI frustration readout' + + return new ImageResponse( +
+
+
+ Rage AI +
+
+ {formatWindow(data.window)} aggregate share +
+
+ +
+
{data.handle}
+
{title}
+
+ +
+ {scores.map((score) => ( +
+
{formatHostApp(score.hostApp)}
+
+ {formatRate(score.ratePerThousandWords)} +
+
rage hits per 1,000 words
+
+ ))} +
+ +
+
+ No raw transcripts. No matched words. No model names. +
+
+ {leader ? `${formatHostApp(leader.hostApp)} leads` : 'Local-first'} +
+
+
, + size, + ) +} diff --git a/apps/web/src/app/share/[rowId]/page.tsx b/apps/web/src/app/share/[rowId]/page.tsx new file mode 100644 index 0000000..06d11ec --- /dev/null +++ b/apps/web/src/app/share/[rowId]/page.tsx @@ -0,0 +1,141 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { Badge } from '@/components/ui/badge' +import { getPublicShareData } from '@/lib/share' +import { ShareComposer } from './share-composer' + +export const dynamic = 'force-dynamic' + +type SharePageProps = { + params: Promise<{ rowId: string }> +} + +function formatWindow(window: string): string { + return window === 'all_time' ? 'all-time' : window +} + +function formatHostApp(hostApp: string): string { + return hostApp === 'claude' ? 'Claude' : 'Codex' +} + +function formatRate(rate: number | string): string { + const numericRate = typeof rate === 'number' ? rate : Number(rate) + return Number.isFinite(numericRate) ? numericRate.toFixed(2).replace(/\.?0+$/u, '') : String(rate) +} + +export async function generateMetadata({ params }: SharePageProps): Promise { + const { rowId } = await params + const data = await getPublicShareData(rowId) + + if (!data) { + return { + title: 'Rage AI share', + } + } + + const title = `${data.handle}'s ${formatWindow(data.window)} Rage AI score` + const description = data.scores + .map((score) => `${formatHostApp(score.hostApp)} ${formatRate(score.ratePerThousandWords)}/1k`) + .join(' vs ') + const image = `/share/${data.rowId}/opengraph-image` + + return { + title, + description, + openGraph: { + title, + description, + url: data.shareUrl, + images: [ + { + url: image, + width: 1200, + height: 630, + alt: title, + }, + ], + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: [image], + }, + } +} + +export default async function SharePage({ params }: SharePageProps) { + const { rowId } = await params + const data = await getPublicShareData(rowId) + + if (!data) { + notFound() + } + + return ( +
+
+ aggregate-only share +
+

+ {data.handle}'s {formatWindow(data.window)} AI frustration readout +

+

+ Public Rage AI scores never include raw transcripts, exact matched words, transcript + paths, model names, raw IPs, or account identity. +

+
+
+ +
+
+ {`${data.handle}'s + +
+ + + + + + + + + + + + {data.scores.map((score) => ( + + + + + + + + ))} + +
HostRage/1kHitsWordsTop
{formatHostApp(score.hostApp)} + {formatRate(score.ratePerThousandWords)} + {score.scoredProfanityCount}{score.userWordCount}{score.topIntensity ?? '-'}
+
+
+ + +
+
+ ) +} diff --git a/apps/web/src/app/share/[rowId]/share-composer.tsx b/apps/web/src/app/share/[rowId]/share-composer.tsx new file mode 100644 index 0000000..1f15e28 --- /dev/null +++ b/apps/web/src/app/share/[rowId]/share-composer.tsx @@ -0,0 +1,116 @@ +'use client' + +import { + createShareDraft, + type SharePlatform, + type ShareScore, + type ShareTone, + type WindowKind, +} from '@rageai/core/share' +import { Copy, Link2 } from 'lucide-react' +import { useMemo, useState } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +type ShareComposerProps = { + handle: string + window: WindowKind + shareUrl: string + scores: ShareScore[] +} + +type CopyTarget = 'post' | 'url' | null + +function labelClass(active: boolean): string { + return cn( + 'h-9 rounded-md border px-3 text-sm font-medium transition-colors', + active + ? 'border-zinc-950 bg-zinc-950 text-white' + : 'border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-100', + ) +} + +export function ShareComposer({ handle, window, shareUrl, scores }: ShareComposerProps) { + const [platform, setPlatform] = useState('linkedin') + const [tone, setTone] = useState('professional') + const [copied, setCopied] = useState(null) + + const draft = useMemo( + () => + createShareDraft({ + handle, + platform, + tone, + window, + url: shareUrl, + scores, + }), + [handle, platform, scores, shareUrl, tone, window], + ) + + async function copy(value: string, target: CopyTarget) { + await navigator.clipboard.writeText(value) + setCopied(target) + globalThis.setTimeout(() => setCopied(null), 1500) + } + + return ( +
+
+ + +
+ +
+ + +
+ +