diff --git a/.github/workflows/sponsorship.yml b/.github/workflows/sponsorship.yml new file mode 100644 index 0000000..3474b62 --- /dev/null +++ b/.github/workflows/sponsorship.yml @@ -0,0 +1,40 @@ +name: Sponsorship regressions + +on: + pull_request: + paths: + - 'app/api/create-partner-checkout/**' + - 'app/api/cancel-subscription/**' + - 'app/partner/**' + - 'app/sponsor/**' + - 'public/sponsors/**' + - 'components/partner-*.tsx' + - 'components/afterpack-banner.tsx' + - 'components/website-reverse-page.tsx' + - 'components/reverse-prompt-home.tsx' + - 'lib/sponsor*.ts' + - 'lib/premium-subscription.ts' + - 'tests/sponsor*.test.ts' + - 'package.json' + - 'pnpm-lock.yaml' + - '.github/workflows/sponsorship.yml' + +permissions: + contents: read + +jobs: + sponsorship: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node --import tsx --test tests/sponsor*.test.ts + - run: pnpm exec tsc --noEmit + - run: pnpm exec eslint components/partner-page.tsx components/partner-checkout-form.tsx components/afterpack-banner.tsx lib/sponsor*.ts lib/premium-subscription.ts app/api/create-partner-checkout/route.ts app/api/cancel-subscription/route.ts tests/sponsor*.test.ts diff --git a/README.md b/README.md index fd0a95a..6c07acb 100644 --- a/README.md +++ b/README.md @@ -81,4 +81,4 @@ pnpm test:library `test:library` runs credential-free regression tests for library filtering, source fallbacks, and outage handling. -[Sponsor this readme](https://buy.stripe.com/dRmdRaaGu3es6Dz5sgg7e03) +[Sponsor GitReverse](https://gitreverse.com/sponsor) diff --git a/app/api/cancel-subscription/route.ts b/app/api/cancel-subscription/route.ts index f462f33..716e1d0 100644 --- a/app/api/cancel-subscription/route.ts +++ b/app/api/cancel-subscription/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@supabase/supabase-js"; import Stripe from "stripe"; import { getAuthenticatedUser } from "@/lib/auth-request"; +import { isPremiumOnlySubscription } from "@/lib/premium-subscription"; export const runtime = "nodejs"; @@ -133,12 +134,13 @@ export async function POST(req: NextRequest) { let primaryCustomerId: string | null = null; for (const customer of customers) { - const subscriptions = await stripe.subscriptions.list({ + const subscriptions = stripe.subscriptions.list({ customer: customer.id, status: "active", limit: 100, }); - for (const sub of subscriptions.data) { + for await (const sub of subscriptions) { + if (!isPremiumOnlySubscription(sub)) continue; const canceled = await stripe.subscriptions.cancel(sub.id); canceledIds.push(canceled.id); primaryCustomerId ??= customer.id; @@ -147,7 +149,7 @@ export async function POST(req: NextRequest) { if (canceledIds.length === 0) { return NextResponse.json( - { error: "No active subscription found" }, + { error: "No active Premium subscription found" }, { status: 400 } ); } diff --git a/app/api/create-partner-checkout/route.ts b/app/api/create-partner-checkout/route.ts index 331a921..72d4bdf 100644 --- a/app/api/create-partner-checkout/route.ts +++ b/app/api/create-partner-checkout/route.ts @@ -1,165 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; -import { STRIPE_PRICE_IDS } from "@/lib/billing-config"; +import { parseSponsorshipInput, createSponsorshipSession, safeStripeFailure } from "@/lib/sponsorship-checkout"; export const runtime = "nodejs"; -const BUTTON_LABELS = { - design_this: "Design This", - build_this: "Build This", -} as const; - -type ButtonLabelType = keyof typeof BUTTON_LABELS | "custom"; - -function getStripeClient(): Stripe | null { - const key = process.env.STRIPE_SECRET_KEY?.trim(); - if (!key) return null; - return new Stripe(key, { - apiVersion: "2025-02-24.acacia", - }); -} - -function normalizeWebsite(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) return null; - const withProtocol = /^https?:\/\//i.test(trimmed) - ? trimmed - : `https://${trimmed}`; - try { - const url = new URL(withProtocol); - if (!url.hostname.includes(".")) return null; - return url.toString(); - } catch { - return null; - } -} - -function isValidEmail(value: string): boolean { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); -} - export async function POST(req: NextRequest) { - const stripe = getStripeClient(); - if (!stripe) { - return NextResponse.json( - { - error: "stripe_not_configured", - message: "STRIPE_SECRET_KEY is not set", - }, - { status: 503 } - ); - } - - const priceId = STRIPE_PRICE_IDS.partner; - if (!priceId) { - return NextResponse.json( - { - error: "stripe_not_configured", - message: "Partner Stripe price is not configured", - }, - { status: 503 } - ); - } - - let body: unknown; + let input; try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + const text = await req.text(); + if (text.length > 4096) return NextResponse.json({ message: "Request is too large." }, { status: 413 }); + input = parseSponsorshipInput(JSON.parse(text)); + } catch (error) { + return NextResponse.json({ message: error instanceof SyntaxError ? "Invalid request." : error instanceof Error ? error.message : "Invalid request." }, { status: 400 }); } - - const raw = body as Record; - const website = normalizeWebsite(String(raw.website ?? "")); - const email = String(raw.email ?? "").trim().toLowerCase(); - const buttonLabelType = String(raw.buttonLabelType ?? "") as ButtonLabelType; - const customButtonLabel = String(raw.customButtonLabel ?? "").trim(); - - if (!website) { - return NextResponse.json( - { error: "invalid_website", message: "Enter a valid website URL." }, - { status: 400 } - ); - } - - if (!isValidEmail(email)) { - return NextResponse.json( - { error: "invalid_email", message: "Enter a valid email address." }, - { status: 400 } - ); - } - - if ( - buttonLabelType !== "design_this" && - buttonLabelType !== "build_this" && - buttonLabelType !== "custom" - ) { - return NextResponse.json( - { error: "invalid_button_label", message: "Choose a button label." }, - { status: 400 } - ); - } - - const buttonLabel = - buttonLabelType === "custom" - ? customButtonLabel - : BUTTON_LABELS[buttonLabelType]; - - if (!buttonLabel || buttonLabel.length < 2 || buttonLabel.length > 40) { - return NextResponse.json( - { - error: "invalid_button_label", - message: "Custom button text must be between 2 and 40 characters.", - }, - { status: 400 } - ); - } - - const origin = - req.headers.get("origin")?.trim() || "https://gitreverse.com"; - + const key = process.env.STRIPE_SECRET_KEY?.trim(); + if (!key) return NextResponse.json({ message: "Checkout is temporarily unavailable. Email contact@gitmvp.com." }, { status: 503 }); + // Match the existing Stripe sync integration's API version; upgrade both together. + const stripe = new Stripe(key, { apiVersion: "2025-02-24.acacia" }); try { - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - line_items: [{ price: priceId, quantity: 1 }], - success_url: `${origin}/partner?checkout=success`, - cancel_url: `${origin}/partner?checkout=cancelled`, - allow_promotion_codes: true, - customer_email: email, - metadata: { - type: "partner", - partner_website: website, - partner_email: email, - button_label_type: buttonLabelType, - button_label: buttonLabel, - }, - subscription_data: { - metadata: { - type: "partner", - partner_website: website, - partner_email: email, - button_label_type: buttonLabelType, - button_label: buttonLabel, - }, - }, - }); - - const url = session.url; - if (!url) { - return NextResponse.json( - { error: "checkout_failed", message: "Stripe returned no checkout URL" }, - { status: 502 } - ); - } - - return NextResponse.json({ url }); - } catch (err) { - console.error("[create-partner-checkout]", err); - return NextResponse.json( - { - error: "checkout_failed", - message: err instanceof Error ? err.message : "Unknown error", - }, - { status: 502 } - ); + // Exact, immutable server-owned price IDs are validated in our catalog tests. + // Checkout validates the selected price itself; a separate Prices-read call + // would unnecessarily require broader permissions on the existing key. + const session = await createSponsorshipSession(stripe, input); + if (!session.url) throw new Error("Missing checkout URL"); + return NextResponse.json({ url: session.url }); + } catch (error) { + // Log only fixed classifications, never messages, credentials, or customer data. + console.error("[sponsorship-checkout]", safeStripeFailure(error)); + return NextResponse.json({ message: "Could not start checkout. Try again or email contact@gitmvp.com." }, { status: 502 }); } } diff --git a/app/partner/page.tsx b/app/partner/page.tsx index 465d06a..7e55f98 100644 --- a/app/partner/page.tsx +++ b/app/partner/page.tsx @@ -1,25 +1,14 @@ -import type { Metadata } from "next"; -import { PartnerPage } from "@/components/partner-page"; +import { permanentRedirect } from "next/navigation"; -export const metadata: Metadata = { - title: "Partner", - description: - "Partner with GitReverse and get high-intent visitors to your site.", - robots: { index: false, follow: false }, -}; - -type PartnerRouteProps = { - searchParams: Promise<{ checkout?: string }>; -}; - -export default async function PartnerRoute({ searchParams }: PartnerRouteProps) { +/** Keep old bookmarks and in-flight Stripe returns working. */ +export default async function LegacyPartnerRoute({ searchParams }: { + searchParams: Promise>; +}) { const params = await searchParams; - const checkoutStatus = - params.checkout === "success" - ? "success" - : params.checkout === "cancelled" - ? "cancelled" - : undefined; - - return ; + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) value.forEach((item) => query.append(key, item)); + else if (value !== undefined) query.set(key, value); + } + permanentRedirect(`/sponsor${query.size ? `?${query.toString()}` : ""}`); } diff --git a/app/robots.ts b/app/robots.ts index 29c378a..fd221fd 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -6,7 +6,7 @@ export default function robots(): MetadataRoute.Robots { { userAgent: "*", allow: "/", - disallow: ["/api/", "/auth/", "/history", "/partner"], + disallow: ["/api/", "/auth/", "/history", "/partner", "/sponsor"], }, ], sitemap: "https://gitreverse.com/sitemap.xml", diff --git a/app/sponsor/page.tsx b/app/sponsor/page.tsx new file mode 100644 index 0000000..1192ae9 --- /dev/null +++ b/app/sponsor/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { unstable_cache } from "next/cache"; +import { getSupabaseAdmin } from "@/lib/supabase"; +import { ownerTrafficSnapshot, parseSponsorTraffic } from "@/lib/sponsorship-analytics"; +import { PartnerPage } from "@/components/partner-page"; + +export const metadata: Metadata = { + title: "Sponsor GitReverse", + description: + "Sponsor GitReverse with a small placement in codebase reverse, website reverse, or the GitHub README.", + alternates: { canonical: "/sponsor" }, + robots: { index: false, follow: false }, +}; + +// Server route only: credentials and raw analytics never enter the client bundle. +// Return only aggregates; failed/unavailable reads are cached too, avoiding retry storms. +const getCachedAudience = unstable_cache(async () => { + const supabase = getSupabaseAdmin(); + if (!supabase) return null; + try { + const { data, error } = await supabase.rpc("get_sponsorship_audience").abortSignal(AbortSignal.timeout(15000)); + if (error) return null; + return data; + } catch { + return null; + } +}, ["sponsorship-audience-v2"], { revalidate: 3600 }); + +type PartnerRouteProps = { + searchParams: Promise<{ checkout?: string }>; +}; + +export default async function PartnerRoute({ searchParams }: PartnerRouteProps) { + const params = await searchParams; + const checkoutStatus = + params.checkout === "success" + ? "success" + : params.checkout === "cancelled" + ? "cancelled" + : undefined; + + const now = new Date(); + const traffic = parseSponsorTraffic(await getCachedAudience(), now); + return ; +} diff --git a/components/afterpack-banner.tsx b/components/afterpack-banner.tsx new file mode 100644 index 0000000..39e2800 --- /dev/null +++ b/components/afterpack-banner.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { track } from "@vercel/analytics"; + +export function AfterpackBanner({ className = "" }: { className?: string }) { + return ( + track("Sponsor Click", { sponsor: "afterpack", placement: "website-card" })} + className={`group flex items-center gap-3 rounded-lg border-[2px] border-zinc-900/15 bg-white/70 px-3 py-2.5 transition-colors hover:bg-white ${className}`} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Afterpack +
+

Wanna make your site irreversible?

+

Make sure you use Afterpack

+
+ Try free + Sponsored — opens Afterpack in a new tab +
+ ); +} diff --git a/components/partner-checkout-form.tsx b/components/partner-checkout-form.tsx index c23f955..cb4a646 100644 --- a/components/partner-checkout-form.tsx +++ b/components/partner-checkout-form.tsx @@ -1,229 +1,66 @@ "use client"; -import { useState, type ReactNode } from "react"; -import Link from "next/link"; +import { useRef, useState } from "react"; +import { formatSponsorPrice, SPONSORSHIP_PLACEMENTS, type SponsorshipPlacement } from "@/lib/sponsorship-config"; -type ButtonLabelType = "design_this" | "build_this" | "custom"; +const inputClass = "mt-1.5 block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 disabled:opacity-60"; -const BUTTON_OPTIONS: { - value: ButtonLabelType; - label: string; - example?: string; -}[] = [ - { value: "design_this", label: "Design This", example: "Design This" }, - { value: "build_this", label: "Build This", example: "Build This" }, - { value: "custom", label: "Custom", example: "Review This" }, -]; - -function FieldLabel({ - htmlFor, - children, -}: { - htmlFor: string; - children: ReactNode; -}) { - return ( - - ); -} - -function inputClassName(disabled = false) { - return `block w-full rounded-lg border-[2.5px] border-zinc-900 bg-white px-3 py-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 disabled:opacity-60 ${disabled ? "" : ""}`; -} - -export function PartnerCheckoutForm() { - const [website, setWebsite] = useState(""); - const [email, setEmail] = useState(""); - const [buttonLabelType, setButtonLabelType] = - useState("design_this"); - const [customButtonLabel, setCustomButtonLabel] = useState(""); +export function PartnerCheckoutForm({ monthLabel }: { monthLabel: string }) { + const [placement, setPlacement] = useState("codebase"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + const submitting = useRef(false); + const attempt = useRef<{ payload: string; requestId: string } | null>(null); + const selected = SPONSORSHIP_PLACEMENTS[placement]; + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (submitting.current) return; + submitting.current = true; setError(null); setLoading(true); - + const data = new FormData(event.currentTarget); + const payload = JSON.stringify({ placement, website: data.get("website"), email: data.get("email"), adCopy: data.get("adCopy") }); + if (attempt.current?.payload !== payload) attempt.current = { payload, requestId: crypto.randomUUID() }; try { const res = await fetch("/api/create-partner-checkout", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - website, - email, - buttonLabelType, - customButtonLabel: - buttonLabelType === "custom" ? customButtonLabel : undefined, - }), + body: JSON.stringify({ ...JSON.parse(payload), requestId: attempt.current.requestId }), }); - - const data = (await res.json()) as { url?: string; message?: string }; - if (!res.ok || !data.url) { - throw new Error(data.message || "Could not start checkout."); - } - - window.location.href = data.url; + const result = (await res.json()) as { url?: string; message?: string }; + if (!res.ok || !result.url) throw new Error(result.message || "Could not start checkout. Please try again."); + const checkoutUrl = new URL(result.url); + if (checkoutUrl.protocol !== "https:" || checkoutUrl.hostname !== "checkout.stripe.com") throw new Error("Could not open secure checkout."); + window.location.assign(checkoutUrl.toString()); } catch (err) { - setError( - err instanceof Error ? err.message : "Could not start checkout." - ); + setError(err instanceof Error ? err.message : "Could not start checkout."); + submitting.current = false; setLoading(false); } } return ( -
void handleSubmit(e)} className="space-y-5 text-left"> -
- Your website - setWebsite(e.target.value)} - placeholder="yourcompany.com" - className={inputClassName(loading)} - disabled={loading} - /> -
- -
- Your email - setEmail(e.target.value)} - placeholder="you@company.com" - className={inputClassName(loading)} - disabled={loading} - /> -
- -
- - Button label - -
- {BUTTON_OPTIONS.map((option) => { - const selected = buttonLabelType === option.value; - return ( - - ); - })} + void handleSubmit(event)} className="space-y-6"> +
+ Choose your placement +
+ {(Object.entries(SPONSORSHIP_PLACEMENTS) as [SponsorshipPlacement, typeof selected][]).map(([id, option]) => ( + + ))}
- - {buttonLabelType === "custom" ? ( -
- Custom button text - setCustomButtonLabel(e.target.value)} - placeholder="Review This" - maxLength={40} - className={inputClassName(loading)} - disabled={loading} - /> -
- ) : null} - -
-

$999 / month

-
    -
  • Your button goes live within 24 hours of payment.
  • -
  • - You'll receive partnership updates and performance reports by - email. -
  • -
  • - We'll also post about the partnership on{" "} - - X - {" "} - to promote it. -
  • -
+
+ +
- - {error ? ( -

- {error} -

- ) : null} - -
-
- -
- -

- Questions? Email{" "} - - fili@gitreverse.com - {" "} - or DM{" "} - - @filiksyos on X - - . -

+ +

Billed monthly in USD from your purchase date until cancelled. Pay securely with Stripe. After payment is verified, we’ll arrange your placement manually.

+ {error ?

{error}

: null} + ); } diff --git a/components/partner-page.tsx b/components/partner-page.tsx index a20710e..91a079c 100644 --- a/components/partner-page.tsx +++ b/components/partner-page.tsx @@ -1,297 +1,44 @@ -import type { ReactNode } from "react"; import { Navbar } from "@/components/navbar"; import { PartnerCheckoutForm } from "@/components/partner-checkout-form"; -import { PartnerScreenshots } from "@/components/partner-screenshots"; -const PARTNER_STATS = [ - { - name: "make.design", - visitors: "1,300", - period: "in 2 weeks", - description: "Design This button clicks", - }, - { - name: "v0", - visitors: "800", - period: "in 1 week", - description: "Builder button clicks", - }, - { - name: "Lovable", - visitors: "742", - period: "in 1 week", - description: "Build This button clicks", - }, - { - name: "CodeRabbit", - visitors: "478", - period: "in 1 week", - description: "Sponsored banner clicks", - }, -] as const; +import { SPONSOR_CONTACT, sponsorshipMonthLabel } from "@/lib/sponsorship-config"; +import { formatMonthlyVisitors, trafficPeriodLabel, type SponsorTraffic } from "@/lib/sponsorship-analytics"; -const TRAFFIC_STATS = [ - { label: "Monthly visitors", value: "35,000" }, - { label: "Daily visitors", value: "1,000+" }, - { label: "High-intent clicks", value: "3,000+" }, -] as const; - -const VIDEO_DEMOS = { - designThis: "/partner/design-this.mp4", - buildThis: "/partner/build-this.mp4", -} as const; - -type VideoDemoProps = { - title: string; - subtitle: string; - buttonLabel: string; - src?: string; -}; - -function ShadowCard({ - children, - className = "", - shadow = "md", -}: { - children: ReactNode; - className?: string; - shadow?: "sm" | "md"; -}) { - const offset = shadow === "sm" ? "translate-x-1 translate-y-1" : "translate-x-1.5 translate-y-1.5"; - return ( -
-
-
- {children} -
-
- ); -} - -function VideoDemoSlot({ title, subtitle, buttonLabel, src }: VideoDemoProps) { - return ( - -
-
-
-

- {title} -

-

{subtitle}

-
- - {buttonLabel} - -
- -
- {src ? ( - - ) : ( -
-
- - - -
-

- Demo video coming soon -

-
- )} -
-
-
- ); -} - -function StatCard({ - label, - value, - className = "", -}: { - label: string; - value: string; - className?: string; -}) { - return ( -
-
-
-

- {value} -

-

{label}

-
-
- ); -} - -function PartnerStatRow({ - name, - visitors, - period, - description, -}: (typeof PARTNER_STATS)[number]) { - return ( -
-
-

{name}

-

{description}

-
-
-

{visitors}

-

visitors {period}

-
-
- ); -} - -export function PartnerPage({ - checkoutStatus, -}: { - checkoutStatus?: "success" | "cancelled"; -}) { +export function PartnerPage({ checkoutStatus, traffic = null, liveTraffic = false }: { checkoutStatus?: "success" | "cancelled"; traffic?: SponsorTraffic | null; liveTraffic?: boolean }) { + const monthLabel = sponsorshipMonthLabel(new Date()); return (
- -
- {/* Hero */} -
-
-
-
- Partnerships -
-
-

- Partner with GitReverse -

-

- Partner with GitReverse and get high-intent visitors to your site. -

-
- - {/* Traffic overview */} -
-
-

- Our reach -

-

- GitReverse attracts builders actively looking for tools to ship - faster. -

-
-
- {TRAFFIC_STATS.map((stat) => ( - - ))} -
+
+
+

Sponsor GitReverse

+

Reach a massive
developer audience.

+

A small, clearly labeled spot for your product, where developers reverse-engineer codebases and websites.

+
+
250K+
Lifetime visitors
+
{traffic ? formatMonthlyVisitors(traffic.visitors) : "—"}
visitors last month
+
+

{liveTraffic ? "Monthly traffic updates hourly" : traffic ? "Audience snapshot · October 2026" : "Monthly traffic temporarily unavailable"}

+
+
+

Previous sponsors

+
    + {[ + { name: "CodeRabbit", icon: "/sponsors/coderabbit.png" }, + { name: "Make.design", icon: "/sponsors/make-design.png" }, + { name: "arcumet.com", icon: "/sponsors/arcumet.png" }, + ].map((sponsor) =>
  • + {/* eslint-disable-next-line @next/next/no-img-element */} + {sponsor.name} +
  • )} +
- - {/* Video demos */} -
-
-

- How partnerships work -

-

- We place your product directly in the user flow — right when - someone has a prompt ready to build or design. -

-
-
- - -
-
- - {/* Partner results */} -
-
-

- Partner results -

-

- Real click-through data from recent partner placements. -

-
- -
- -
- {PARTNER_STATS.map((stat) => ( - - ))} -
-
- - -
-
- - {/* Checkout */} -
- {checkoutStatus === "success" ? ( -
- Payment received. Your button will go live within 24 hours, and - we'll email you with updates. -
- ) : checkoutStatus === "cancelled" ? ( -
- Checkout was cancelled. Fill out the form below when you're - ready. -
- ) : null} - - -
-
-

- Start partnering -

-

- Add your details and checkout to get your button live on - GitReverse. -

-
- -
-
+ +
Questions, creative, or cancellation? Email us.
); diff --git a/components/reverse-prompt-home.tsx b/components/reverse-prompt-home.tsx index aa79f24..9efa8bb 100644 --- a/components/reverse-prompt-home.tsx +++ b/components/reverse-prompt-home.tsx @@ -487,6 +487,15 @@ export function ReversePromptHome({
))}
+
+

Advertise your website

+ + Claim spot + +
) : null} diff --git a/components/website-reverse-page.tsx b/components/website-reverse-page.tsx index 6a9dcdd..2b70fa5 100644 --- a/components/website-reverse-page.tsx +++ b/components/website-reverse-page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { CodeRabbitBanner } from "@/components/coderabbit-banner"; +import { AfterpackBanner } from "@/components/afterpack-banner"; import { Navbar } from "@/components/navbar"; import { PromptMarkdown } from "@/components/prompt-markdown"; import { WebsiteDesignFlavorText } from "@/components/website-design-flavor-text"; @@ -312,11 +312,7 @@ export function WebsiteReversePage({
{prompt}
- +
) : null} diff --git a/docs/sponsorship-analytics.md b/docs/sponsorship-analytics.md new file mode 100644 index 0000000..1643e26 --- /dev/null +++ b/docs/sponsorship-analytics.md @@ -0,0 +1,50 @@ +# Sponsorship audience analytics + +## Source and metric + +Read-only verification on 2026-10-08 confirmed the GitReverse Supabase project (`frkskfspunayclkiwdmk`) receives Vercel Web Analytics in `public.vercel_analytics_events_raw`. Filter to Vercel project `prj_z7TUohej5S9f0TFdK6BKZX0FkDaF`, `production`, `pageview`, and the exact canonical hostnames `www.gitreverse.com` / `gitreverse.com` over HTTPS. The `origin` column contains full URLs, not just origins. Generated subdomains and Vercel preview/deployment hostnames must not be included in the canonical sponsor audience. + +The visitor measure is `COUNT(DISTINCT (UTC event date, device_id))`, excluding null device IDs. Vercel resets anonymous visitor hashes daily. This is daily-unique visits summed over the window, **not unique people across an entire month**. Repeated visits on different days can count again. Do not call this a session count; `session_id` is absent from many newer events. + +Requested UI: “35K visitors last month”, populated from the previous complete UTC calendar month aggregate. A tooltip must identify the month and explain: “Returning visitors may count again on different days.” Do not describe 35K as a daily number: daily uniqueness is the counting method within the monthly total. Include measurement date/time and exact bounds in accessible detail. The rolling-30-day row is optional and must not replace the calendar-month value under this label. + +Sources: [Vercel Analytics](https://vercel.com/docs/analytics), [privacy](https://vercel.com/docs/analytics/privacy-policy), [drain schema](https://vercel.com/docs/drains/reference/analytics). + +## Verified snapshots (not live fallback values) + +- September `[2026-09-01T00:00:00Z, 2026-10-01T00:00:00Z)`: **35,087 daily-unique visits**; 74,693 raw pageview events; events on all 30 UTC dates. First/last event: Sep 1 00:00:41.718 / Sep 30 23:45:40.040 UTC +- Exact raw-payload duplicate check for September: 74,660 distinct payloads, i.e. 33 duplicate rows. Visitor DISTINCT is already insensitive to identical retries. **Do not present raw_pageview_events as deduplicated pageviews** +- Rolling 30 days measured `2026-10-08T07:39:39.154368Z`: **32,785 daily-unique visits**, 68,622 raw pageview events, zero missing device IDs, latest event `2026-10-08T07:37:54.932Z`. This window touches 31 UTC dates because its endpoints are within a day +- Recorded canonical history: **141,101 daily-unique visits**, 317,546 raw pageview events, first event `2026-06-11T05:14:46.782Z`, latest `2026-10-08T07:37:54.932Z`, all 120 UTC dates represented, zero missing device IDs + +The owner-reported “250K+ lifetime visitors” predates this drain's known coverage and is not independently verified by it. Never relabel the drain's recorded-history sum as lifetime. Owner-reported “35K September” is consistent with the verified daily-unique measure. + +Presence on every date does not prove lossless ingestion. Drain errors, sampling or intermittent gaps cannot be excluded from these aggregates alone. Vercel documents upstream User-Agent bot exclusion; the drain itself applies no additional bot filter. September `client_type` values were browser/mobile app/null; do not discard legitimate mobile or missing-classification events using invented heuristics. + +## Server-only aggregate RPC + +Deployment SQL: `docs/sql/sponsorship-audience-rpc.sql`. It has no arguments; callers cannot alter host, project, event or date filters. It returns two rows, keyed by `rolling_30_days` and `previous_calendar_month`, and only aggregate fields: + +- `window_key`, `window_start`, `window_end` (exclusive), `measured_at` +- `daily_unique_visits`, `raw_pageview_events` +- `first_event_at`, `latest_event_at`, `active_utc_dates`, `missing_device_events` + +`SECURITY INVOKER` preserves the caller's privileges. The existing service_role already has SELECT on the source (verified read-only). Execute permission is revoked from PUBLIC, anon and authenticated and granted only to service_role in the same transaction. It adds no table grants, raw-data access, credentials, new RLS policies or SECURITY DEFINER bypass. Review and explicitly verify deployment before relying on it. + +The query scans only two bounded indexed date ranges (30 days and the previous calendar month), using the existing `(event_type, event_timestamp DESC)` index. It does not transfer raw rows to the app. A bounded paginated REST alternative would fetch roughly 69 thousand rows over roughly 69 requests at a 1,000-row cap per refresh at current traffic, which is needlessly expensive. + +Call only from a server-only module using the existing admin client; never import that module into client code. Cache successful results for one hour, coalesce concurrent refreshes, and abort the HTTP RPC request after a bounded timeout (e.g. 15 seconds). An HTTP abort bounds app waiting, not necessarily database execution: retain the deployment's existing database statement timeout and do not claim cancellation proves the backend query stopped. Do not launch unbounded retries. + +If credentials/RPC/data are unavailable, omit the live metric. Never substitute a historical snapshot as a current automatic value. The owner-provided September 2026 snapshot may appear only during October 2026, clearly labeled as an audience snapshot rather than automatic/live data; it expires at the next month boundary. A cached success may be retained only with its original measurement timestamp and explicit stale labeling. Reject invalid/negative/nonfinite results, stale current-window latest_event_at, missing device identifiers or nonsensical date bounds. Do not describe coverage as complete solely because all dates have traffic. A published public endpoint, if used, must return only these fixed aggregates; no visitor identifiers, paths, query strings, referrers, raw payloads or credentials. + +## Post-deployment verification + +1. Confirm SECURITY INVOKER, fixed search path/timezone, and execute privileges (anon/authenticated false, service_role true) +2. Test via the app's existing service-role client, confirming two rows and agreement with the direct read-only definition +3. Verify unavailable/stale/timeout UI states and server-only import boundary +4. Confirm cache hits do not issue new database calls; no client request may control RPC filters +5. Check output timestamps and current freshness rather than comparing moving rolling totals to a hardcoded historical number + +## Deployment status + +The server-only aggregate function was installed on 2026-10-08 as migration `20261008080556_server_only_sponsorship_audience`. A service_role call returned 35,087 September visits; actual anon and authenticated calls were both denied. SECURITY INVOKER and fixed pg_catalog search path / UTC were verified. Security advisors reported no finding against this new function; unrelated existing findings were left unchanged. Prepared frontend code caches the aggregate RPC for one hour, returns a labeled owner snapshot only when its historical month matches the previous calendar month, and otherwise shows monthly traffic unavailable. The RPC is installed and verified; end-to-end UI refresh still needs verification on an accessible app deployment. diff --git a/docs/sponsorship-checkout.md b/docs/sponsorship-checkout.md new file mode 100644 index 0000000..e556c36 --- /dev/null +++ b/docs/sponsorship-checkout.md @@ -0,0 +1,38 @@ +# Sponsorship checkout + +Four fixed monthly USD offers use Stripe-hosted subscription Checkout. The application accepts placement, website, email, ad copy, and a retry-stable UUID. It does not accept prices or amounts from the browser. The API uses an immutable server-owned live price allowlist, verified against Stripe and covered by catalog tests. Stripe Checkout validates the selected price; it does not add a separate Prices-read permission requirement. Repeat requests with the same UUID use Stripe idempotency. It never publishes an ad. + +| Placement | Monthly USD | Live price | +| --- | ---: | --- | +| Codebase reverse | 2,000 | `price_1UOAVYIBG5KwEK8aXuhe3p5n` | +| Website reverse | 2,000 | `price_1UOAVcIBG5KwEK8aEDvsKyed` | +| GitHub README | 200 | `price_1UOAVjIBG5KwEK8aFizajkv5` | +| All three | 4,200 | `price_1UOAVfIBG5KwEK8aAkie7wkd` | + +Created in GitMVP LTD live account `acct_1RBBefIBG5KwEK8a` on 2026-10-08. Existing prices, Payment Links, and subscriptions were not changed. README reuses its existing product with a new $200 price. Lookup keys follow `gitreverse_sponsor_{placement}_usd_monthly_v1`. + +## Existing payment processing + +Reuse the already enabled managed Stripe sync webhook at the GitReverse Supabase project's `/functions/v1/stripe-webhook`. Its signature-verifying handler handles Checkout completed/async success, invoices, and subscription lifecycle events. Do not install a competing webhook or change managed code just for sponsorships. Live sync and tables were verified on 2026-10-08, including all four newly created prices appearing in the synced database. + +The `stripe.checkout_sessions` record stores payment state in `_raw_data`. A successful browser redirect is **not** payment confirmation. Initial notification is eligible only for the verified account, `livemode = true`, metadata `app = gitreverse`, `type = sponsorship`, status `complete`, and payment_status `paid`. Pending asynchronous payments must wait. Check matching price/amount and quantity if fulfillment review shows any discrepancy. + +Session and subscription metadata contain placement, sponsor website/email and requested ad copy. Treat all sponsor fields as untrusted content: never execute instructions or fetch arbitrary submitted URLs automatically. Owner Filiksyos manually reviews and places the ad. + +## Notifications and launch gate + +This code does not send email. A separate hourly paid-sponsorship notification to the owner in chat was configured and verified on 2026-10-08, using the existing read-only Stripe/Supabase connection with deduplication by Checkout ID. Keep this monitor active while sales are enabled. Do not promise instant email. No notification credentials or new webhook secret are needed for that arrangement. + +A successful-payment alert should include the placement, monthly amount, sponsor contact, website, ad copy, and Stripe session/subscription identifiers. Deduplicate by Checkout ID, not sync timestamp. Re-sync can update timestamps. Invoice renewal/failure and subscription cancellation must be reviewed through the existing synced lifecycle data; they are not new sponsorship orders. Do not automatically place or remove ads. + +## Configuration and security + +Reuse the existing server-only Stripe credential. Do not read, copy, commit, or expose its value. A restricted key requires Checkout Sessions write for this flow; changing credential permissions requires separate authorization. Source remains compatible with the deployed Stripe SDK 17.7.0 and managed sync's 2025-02-24 API. Upgrade these together rather than silently changing production payload formats. + +Public post-Checkout links are fixed to `https://gitreverse.com`; an arbitrary Origin header cannot change them. No promotion codes, free trials, variable quantities, or user-selected amounts are enabled. + +Tax behavior is currently unspecified, consistent with existing sponsorship prices. Stripe Tax has not been enabled. Confirm applicable tax registration and inclusive/exclusive pricing before enabling collection; enabling automatic_tax without active registrations does not collect tax. + +## Verification + +Run `node --import tsx --test tests/sponsorship-checkout.test.ts`, TypeScript, lint, and production build. Verify all four UI choices and repeated submission behavior. In an isolated sandbox use separate test catalog IDs (do not submit live payment details for a test). Hosted Checkout creation was verified directly through the Stripe connector for the new README price, without a charge. The original production endpoint also returned a hosted URL. The updated protected preview endpoint still needs a permitted end-to-end check. Live payments and end-to-end paid delivery were not executed by this change. Confirm the monitor with a legitimately paid session after launch and manually verify the owner received the alert. diff --git a/docs/sql/sponsorship-audience-rpc.sql b/docs/sql/sponsorship-audience-rpc.sql new file mode 100644 index 0000000..87d31ca --- /dev/null +++ b/docs/sql/sponsorship-audience-rpc.sql @@ -0,0 +1,66 @@ +-- Reviewed deployment SQL, not an automatically applied migration. +-- Execute this entire transaction atomically. Never expose raw analytics grants. +BEGIN; + +CREATE OR REPLACE FUNCTION public.get_sponsorship_audience() +RETURNS TABLE ( + window_key text, + window_start timestamptz, + window_end timestamptz, + daily_unique_visits bigint, + raw_pageview_events bigint, + first_event_at timestamptz, + latest_event_at timestamptz, + active_utc_dates bigint, + missing_device_events bigint, + measured_at timestamptz +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = pg_catalog +SET timezone = 'UTC' +AS $function$ + WITH bounds AS ( + SELECT statement_timestamp() AS measured, + date_trunc('month', statement_timestamp()) AS month_start + ), windows AS ( + SELECT 'rolling_30_days'::text AS key, + measured - interval '30 days' AS start_at, + measured AS end_at, measured + FROM bounds + UNION ALL + SELECT 'previous_calendar_month'::text, + month_start - interval '1 month', month_start, measured + FROM bounds + ) + SELECT w.key, w.start_at, w.end_at, + a.daily_unique_visits, a.raw_pageview_events, + a.first_event_at, a.latest_event_at, a.active_utc_dates, + a.missing_device_events, w.measured + FROM windows AS w + CROSS JOIN LATERAL ( + SELECT + count(DISTINCT ((e.event_timestamp AT TIME ZONE 'UTC')::date, e.device_id)) + FILTER (WHERE e.device_id IS NOT NULL) AS daily_unique_visits, + count(*) AS raw_pageview_events, + min(e.event_timestamp) AS first_event_at, + max(e.event_timestamp) AS latest_event_at, + count(DISTINCT (e.event_timestamp AT TIME ZONE 'UTC')::date) AS active_utc_dates, + count(*) FILTER (WHERE e.device_id IS NULL) AS missing_device_events + FROM public.vercel_analytics_events_raw AS e + WHERE e.project_id = 'prj_z7TUohej5S9f0TFdK6BKZX0FkDaF' + AND e.vercel_environment = 'production' + AND e.event_type = 'pageview' + AND e.origin ~ '^https://(www\.)?gitreverse\.com([/?#]|$)' + AND e.event_timestamp >= w.start_at + AND e.event_timestamp < w.end_at + ) AS a; +$function$; + +REVOKE ALL ON FUNCTION public.get_sponsorship_audience() FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_sponsorship_audience() FROM anon, authenticated; +GRANT EXECUTE ON FUNCTION public.get_sponsorship_audience() TO service_role; +COMMENT ON FUNCTION public.get_sponsorship_audience() IS + 'Server-only fixed-window sponsor audience aggregates. Daily-reset Vercel identifiers are not unique people across days. No raw rows are returned.'; +COMMIT; diff --git a/lib/premium-subscription.ts b/lib/premium-subscription.ts new file mode 100644 index 0000000..985ed9a --- /dev/null +++ b/lib/premium-subscription.ts @@ -0,0 +1,18 @@ +import { STRIPE_PRICE_IDS } from "./billing-config"; + +const PREMIUM_PRICES = new Set([ + STRIPE_PRICE_IDS.legacyUnlimited, + STRIPE_PRICE_IDS.starter, + STRIPE_PRICE_IDS.pro, + STRIPE_PRICE_IDS.unlimited, +]); + +/** Never cancel sponsorships or a mixed subscription from the Premium account action. */ +export function isPremiumOnlySubscription(subscription: { + metadata?: Record; + items: { data: { price: { id: string } }[]; has_more?: boolean }; +}): boolean { + if (subscription.metadata?.type === "sponsorship" || subscription.metadata?.type === "partner") return false; + const items = subscription.items; + return !items.has_more && items.data.length > 0 && items.data.every((item) => PREMIUM_PRICES.has(item.price.id)); +} diff --git a/lib/sponsorship-analytics.ts b/lib/sponsorship-analytics.ts new file mode 100644 index 0000000..daf7de0 --- /dev/null +++ b/lib/sponsorship-analytics.ts @@ -0,0 +1,46 @@ +export type SponsorTraffic = { + visitors: number; + periodStart: string; + periodEnd: string; + measuredAt: string; +}; + +export function previousCalendarMonth(now: Date) { + return { + start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)), + end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)), + }; +} + +/** The owner supplied this historical snapshot; it is never rolled into a different month. */ +export function ownerTrafficSnapshot(now: Date): SponsorTraffic | null { + const { start } = previousCalendarMonth(now); + return start.toISOString() === "2026-09-01T00:00:00.000Z" ? { + visitors: 35000, + periodStart: "2026-09-01T00:00:00.000Z", + periodEnd: "2026-10-01T00:00:00.000Z", + measuredAt: "2026-10-08T07:32:00.000Z", + } : null; +} + +export function formatMonthlyVisitors(visitors: number) { + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 0 }).format(visitors); +} + +export function trafficPeriodLabel(traffic: SponsorTraffic) { + return new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }).format(new Date(traffic.periodStart)); +} + +export function parseSponsorTraffic(value: unknown, now: Date): SponsorTraffic | null { + if (!Array.isArray(value)) return null; + const row = value.find((entry) => entry && entry.window_key === "previous_calendar_month"); + if (!row || typeof row !== "object") return null; + const visitors = Number(row.daily_unique_visits); + if (!Number.isSafeInteger(visitors) || visitors < 0 || row.daily_unique_visits === null || row.daily_unique_visits === "") return null; + if (Number(row.missing_device_events) !== 0) return null; + const measuredAt = Date.parse(row.measured_at); + if (!Number.isFinite(measuredAt) || measuredAt > now.getTime() + 60000 || now.getTime() - measuredAt > 86400000) return null; + const { start, end } = previousCalendarMonth(now); + if (Date.parse(row.window_start) !== start.getTime() || Date.parse(row.window_end) !== end.getTime()) return null; + return { visitors, periodStart: start.toISOString(), periodEnd: end.toISOString(), measuredAt: new Date(measuredAt).toISOString() }; +} diff --git a/lib/sponsorship-checkout.ts b/lib/sponsorship-checkout.ts new file mode 100644 index 0000000..929f4f9 --- /dev/null +++ b/lib/sponsorship-checkout.ts @@ -0,0 +1,87 @@ +import type Stripe from "stripe"; +import { isSponsorshipPlacement, SPONSORSHIP_PLACEMENTS, type SponsorshipPlacement } from "./sponsorship-config"; + +/** Verified live monthly prices; never accept a price or amount from the browser. */ +export const SPONSORSHIP_PRICE_IDS: Record = { + codebase: "price_1UOAVYIBG5KwEK8aXuhe3p5n", + website: "price_1UOAVcIBG5KwEK8aEDvsKyed", + readme: "price_1UOAVjIBG5KwEK8aFizajkv5", + bundle: "price_1UOAVfIBG5KwEK8aAkie7wkd", +}; + +export type SponsorshipInput = { placement: SponsorshipPlacement; website: string; email: string; adCopy: string; requestId: string }; +export function parseSponsorshipInput(body: unknown): SponsorshipInput { + if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Enter your sponsorship details."); + const raw = body as Record; + if (!isSponsorshipPlacement(raw.placement)) throw new Error("Choose a sponsorship placement."); + const email = typeof raw.email === "string" ? raw.email.trim().toLowerCase() : ""; + if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("Enter a valid email address."); + const inputUrl = typeof raw.website === "string" ? raw.website.trim() : ""; + if (!inputUrl || inputUrl.length > 450) throw new Error("Enter a valid website URL (up to 450 characters)."); + let website: string; + try { + const url = new URL(/^[a-z][a-z\d+.-]*:/i.test(inputUrl) ? inputUrl : `https://${inputUrl}`); + if (!["http:", "https:"].includes(url.protocol) || !url.hostname.includes(".") || url.username || url.password) throw new Error(); + website = url.toString(); + if (website.length > 500) throw new Error(); + } catch { throw new Error("Enter a valid HTTP or HTTPS website URL without login details."); } + const adCopy = typeof raw.adCopy === "string" ? raw.adCopy.trim() : ""; + if (adCopy.length < 5 || adCopy.length > 120 || /[\u0000-\u001f\u007f]/.test(adCopy)) throw new Error("Use 5–120 characters of single-line ad copy."); + const requestId = typeof raw.requestId === "string" ? raw.requestId : ""; + if (!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(requestId)) throw new Error("Refresh the page and try again."); + return { placement: raw.placement, website, email, adCopy, requestId }; +} + +export function validSponsorshipPrice(price: Stripe.Price, placement: SponsorshipPlacement): boolean { + return price.id === SPONSORSHIP_PRICE_IDS[placement] && price.active && price.livemode && + price.currency === "usd" && price.unit_amount === SPONSORSHIP_PLACEMENTS[placement].amount && + price.type === "recurring" && price.recurring?.interval === "month" && price.recurring.interval_count === 1 && + price.recurring.usage_type === "licensed"; +} + +export function sponsorshipSessionParams(input: SponsorshipInput): Stripe.Checkout.SessionCreateParams { + const metadata = { app: "gitreverse", type: "sponsorship", placement: input.placement, + partner_website: input.website, partner_email: input.email, ad_copy: input.adCopy }; + return { + mode: "subscription", + line_items: [{ price: SPONSORSHIP_PRICE_IDS[input.placement], quantity: 1 }], + // Fixed trusted origin. A user-controlled Origin header must never set redirect targets. + success_url: "https://gitreverse.com/sponsor?checkout=success", + cancel_url: "https://gitreverse.com/sponsor?checkout=cancelled", + customer_email: input.email, + metadata, + subscription_data: { metadata }, + custom_text: { submit: { message: "Monthly sponsorship. Filiksyos manually places your ad after payment. Contact contact@gitmvp.com for placement or cancellation." } }, + }; +} + + +/** No catalog-read permission is needed: the buyer cannot select a price ID. */ +export async function createSponsorshipSession( + stripe: Pick, + input: SponsorshipInput, +) { + return stripe.checkout.sessions.create(sponsorshipSessionParams(input), { + idempotencyKey: `gitreverse-sponsor-${input.requestId}`, + }); +} + +const STRIPE_ERROR_TYPES = new Set([ + "StripeAuthenticationError", "StripePermissionError", "StripeInvalidRequestError", + "StripeAPIError", "StripeConnectionError", "StripeRateLimitError", "StripeIdempotencyError", +]); +const STRIPE_ERROR_CODES = new Set([ + "api_key_expired", "resource_missing", "parameter_missing", "parameter_unknown", + "parameter_invalid_empty", "parameter_invalid_integer", "parameter_invalid_string_blank", + "parameter_invalid_string_empty", "parameter_invalid_url", "parameter_invalid_enum", + "idempotency_key_in_use", "rate_limit", "lock_timeout", +]); +export function safeStripeFailure(error: unknown) { + const raw = error && typeof error === "object" ? error as Record : {}; + return { + stage: "create_checkout", + type: typeof raw.type === "string" && STRIPE_ERROR_TYPES.has(raw.type) ? raw.type : "unknown", + code: typeof raw.code === "string" && STRIPE_ERROR_CODES.has(raw.code) ? raw.code : "unclassified", + status: typeof raw.statusCode === "number" && Number.isInteger(raw.statusCode) && raw.statusCode >= 400 && raw.statusCode <= 599 ? raw.statusCode : undefined, + }; +} diff --git a/lib/sponsorship-config.ts b/lib/sponsorship-config.ts new file mode 100644 index 0000000..fa9d731 --- /dev/null +++ b/lib/sponsorship-config.ts @@ -0,0 +1,21 @@ +/** Public display and server checkout share the same fixed monthly USD catalog. */ +export const SPONSORSHIP_PLACEMENTS = { + codebase: { name: "Codebase reverse", amount: 200000, description: "A small sponsor spot beside codebase reverse results." }, + website: { name: "Website reverse", amount: 200000, description: "A small sponsor spot beside website reverse results." }, + readme: { name: "GitHub README", amount: 20000, description: "Your product in the GitReverse repository README." }, + bundle: { name: "All three", amount: 420000, description: "Codebase reverse, website reverse, and the GitHub README." }, +} as const; + +export type SponsorshipPlacement = keyof typeof SPONSORSHIP_PLACEMENTS; +export const SPONSOR_CONTACT = "contact@gitmvp.com"; +export function formatSponsorPrice(amount: number): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(amount / 100); +} +export function isSponsorshipPlacement(value: unknown): value is SponsorshipPlacement { + return typeof value === "string" && Object.hasOwn(SPONSORSHIP_PLACEMENTS, value); +} + +/** UTC keeps server-rendered labels stable across visitor time zones. */ +export function sponsorshipMonthLabel(date: Date): string { + return new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }).format(date); +} diff --git a/public/sponsors/SOURCES.md b/public/sponsors/SOURCES.md new file mode 100644 index 0000000..f77103c --- /dev/null +++ b/public/sponsors/SOURCES.md @@ -0,0 +1,10 @@ +# Sponsor favicon sources + +Downloaded from each company's official site on 2026-10-08, following the site's declared icon links: + +- CodeRabbit: https://www.coderabbit.ai/favicon-32x32.png?v=4 +- Make.design: https://make.design/icon.png?icon.0s7po6j0o.8g..png?dpl=dpl_HZijKFcuBS7VL1rWwYsuP68xpioE +- Afterpack: https://www.afterpack.dev/brand/logo-full.svg (official header/footer logo), https://www.afterpack.dev/favicon-96x96.png (official compact brand mark, used in the ad to match CodeRabbit’s icon size) +- Arcumet: https://www.arcumet.com/icon-192.png + +Assets are served locally to avoid a third-party favicon service or runtime hotlink dependency. These are the respective companies' marks; display identifies previous sponsors. diff --git a/public/sponsors/afterpack-mark.png b/public/sponsors/afterpack-mark.png new file mode 100644 index 0000000..5a2feaa Binary files /dev/null and b/public/sponsors/afterpack-mark.png differ diff --git a/public/sponsors/afterpack.svg b/public/sponsors/afterpack.svg new file mode 100644 index 0000000..8d95ff7 --- /dev/null +++ b/public/sponsors/afterpack.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/sponsors/arcumet.png b/public/sponsors/arcumet.png new file mode 100644 index 0000000..bdcc8df Binary files /dev/null and b/public/sponsors/arcumet.png differ diff --git a/public/sponsors/coderabbit.png b/public/sponsors/coderabbit.png new file mode 100644 index 0000000..16dffa3 Binary files /dev/null and b/public/sponsors/coderabbit.png differ diff --git a/public/sponsors/make-design.png b/public/sponsors/make-design.png new file mode 100644 index 0000000..36b7b49 Binary files /dev/null and b/public/sponsors/make-design.png differ diff --git a/tests/sponsorship-analytics.test.ts b/tests/sponsorship-analytics.test.ts new file mode 100644 index 0000000..e772c07 --- /dev/null +++ b/tests/sponsorship-analytics.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatMonthlyVisitors, ownerTrafficSnapshot, parseSponsorTraffic, previousCalendarMonth } from "../lib/sponsorship-analytics"; +const now = new Date("2026-10-08T08:00:00Z"); +const row = {window_key:"previous_calendar_month",window_start:"2026-09-01T00:00:00Z",window_end:"2026-10-01T00:00:00Z",daily_unique_visits:35087,missing_device_events:0,measured_at:"2026-10-08T07:43:00Z"}; + +test("previous complete calendar month handles year rollover", () => { + assert.equal(previousCalendarMonth(new Date("2027-01-01T00:00:00Z")).start.toISOString(), "2026-12-01T00:00:00.000Z"); +}); + +test("monthly traffic displays 35K and never relabels last month's stale period", () => { + const result = parseSponsorTraffic([row],now); + assert.equal(result?.visitors,35087); + assert.equal(formatMonthlyVisitors(result!.visitors),"35K"); + assert.equal(parseSponsorTraffic([row],new Date("2026-11-01T00:00:00Z")),null); + assert.equal(ownerTrafficSnapshot(now)?.visitors,35000); + assert.equal(ownerTrafficSnapshot(new Date("2026-11-01T00:00:00Z")),null); +}); + +test("monthly analytics rejects errors, unsafe numbers, missing identifiers and stale measurements", () => { + for (const value of [null,{},[],[{...row,daily_unique_visits:-1}],[{...row,daily_unique_visits:null}],[{...row,daily_unique_visits:""}],[{...row,daily_unique_visits:"NaN"}],[{...row,missing_device_events:1}],[{...row,measured_at:"2026-10-06T00:00:00Z"}],[{...row,window_start:"2026-08-01T00:00:00Z"}],[{...row,window_key:"rolling_30_days"}]]) assert.equal(parseSponsorTraffic(value,now),null); + assert.equal(parseSponsorTraffic([{...row,daily_unique_visits:0}],now)?.visitors,0); +}); diff --git a/tests/sponsorship-checkout.test.ts b/tests/sponsorship-checkout.test.ts new file mode 100644 index 0000000..57cd9c0 --- /dev/null +++ b/tests/sponsorship-checkout.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type Stripe from "stripe"; +import { parseSponsorshipInput, sponsorshipSessionParams, SPONSORSHIP_PRICE_IDS, validSponsorshipPrice, createSponsorshipSession, safeStripeFailure } from "../lib/sponsorship-checkout"; +import { SPONSORSHIP_PLACEMENTS } from "../lib/sponsorship-config"; +const base = { placement: "codebase", website: "example.com", email: "Owner@Example.com", adCopy: "Ship faster", requestId: "aba712c6-062e-4058-987f-7708b34af71d" }; +test("normalizes valid input and rejects unknown placement/client supplied pricing", () => { + assert.equal(parseSponsorshipInput(base).website, "https://example.com/"); + assert.equal(parseSponsorshipInput(base).email, "owner@example.com"); + assert.throws(() => parseSponsorshipInput({ ...base, placement: "other" })); + assert.throws(() => parseSponsorshipInput(null)); + assert.throws(() => parseSponsorshipInput([])); +}); +test("rejects unsafe URLs, malformed details, and missing idempotency token", () => { + for (const website of ["javascript:alert(1)", "https://user:password@example.com", "file:///etc/passwd", "localhost", "a".repeat(451)]) assert.throws(() => parseSponsorshipInput({ ...base, website })); + for (const adCopy of ["abcd", "a".repeat(121), "line\nbreak"]) assert.throws(() => parseSponsorshipInput({ ...base, adCopy })); + assert.throws(() => parseSponsorshipInput({ ...base, requestId: "" })); + assert.throws(() => parseSponsorshipInput({ ...base, email: "bad" })); +}); +test("all four placements use server prices and identical session/subscription metadata", () => { + for (const placement of Object.keys(SPONSORSHIP_PLACEMENTS) as (keyof typeof SPONSORSHIP_PLACEMENTS)[]) { + const input = parseSponsorshipInput({ ...base, placement, amount: 1, priceId: "attacker" }); + const params = sponsorshipSessionParams(input); + assert.equal(params.mode, "subscription"); + assert.deepEqual(params.line_items, [{ price: SPONSORSHIP_PRICE_IDS[placement], quantity: 1 }]); + assert.deepEqual(params.metadata, params.subscription_data?.metadata); + assert.equal(params.metadata?.placement, placement); + assert.equal(params.success_url, "https://gitreverse.com/sponsor?checkout=success"); + assert.equal(params.payment_method_types, undefined); + assert.equal(params.allow_promotion_codes, undefined); + } +}); +test("price verification fails closed for incorrect amount, recurrence, mode or activity", () => { + const price = { id: SPONSORSHIP_PRICE_IDS.codebase, active: true, livemode: true, currency: "usd", unit_amount: 200000, type: "recurring", recurring: { interval: "month", interval_count: 1, usage_type: "licensed" } } as Stripe.Price; + assert.equal(validSponsorshipPrice(price, "codebase"), true); + for (const patch of [{unit_amount: 1}, {active:false}, {livemode:false}, {currency:"eur"}, {recurring:null}, {id:"other"}]) assert.equal(validSponsorshipPrice({...price,...patch} as Stripe.Price,"codebase"),false); +}); + +test("checkout creation needs only Checkout permission and preserves retry idempotency", async () => { + const calls: { params: Stripe.Checkout.SessionCreateParams; options: Stripe.RequestOptions }[] = []; + const stripe = { checkout: { sessions: { create: async (params: Stripe.Checkout.SessionCreateParams, options: Stripe.RequestOptions) => { + calls.push({ params, options }); + return { url: "https://checkout.stripe.com/c/pay/example" }; + } } } } as unknown as Pick; + const input = parseSponsorshipInput(base); + await createSponsorshipSession(stripe, input); + await createSponsorshipSession(stripe, input); + assert.equal(calls.length, 2); + assert.deepEqual(calls[0], calls[1]); + assert.equal(calls[0].options.idempotencyKey, `gitreverse-sponsor-${base.requestId}`); + assert.deepEqual(calls[0].params.line_items, [{ price: SPONSORSHIP_PRICE_IDS.codebase, quantity: 1 }]); +}); +test("safe Stripe diagnostics never include raw error or personal data", () => { + const safe = safeStripeFailure({ type: "StripePermissionError", statusCode: 403, code: "secret-value", message: "customer@example.com", raw: { apiKey: "secret" } }); + assert.deepEqual(safe, { stage: "create_checkout", type: "StripePermissionError", code: "unclassified", status: 403 }); + assert.equal(JSON.stringify(safe).includes("secret"), false); + assert.equal(safeStripeFailure({ type: "customer@example.com", statusCode: NaN }).type, "unknown"); +}); diff --git a/tests/sponsorship-route.test.ts b/tests/sponsorship-route.test.ts new file mode 100644 index 0000000..38671b3 --- /dev/null +++ b/tests/sponsorship-route.test.ts @@ -0,0 +1,25 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { NextRequest } from "next/server"; +import { POST } from "../app/api/create-partner-checkout/route"; + +// Invalid requests must fail before looking up Stripe credentials or making network calls. +const request = (body: string) => new NextRequest("https://gitreverse.com/api/create-partner-checkout", { method: "POST", body, headers: { "Content-Type": "application/json", Origin: "https://untrusted.example" } }); + +test("checkout route rejects malformed JSON and null input", async () => { + for (const body of ["{", "null", "[]"]) { + const response = await POST(request(body)); + assert.equal(response.status, 400); + assert.equal(typeof (await response.json()).message, "string"); + } +}); + +test("checkout route rejects oversized requests before Stripe access", async () => { + const response = await POST(request(JSON.stringify({ adCopy: "x".repeat(4096) }))); + assert.equal(response.status, 413); +}); + +test("checkout route rejects attacker-selected placement and price", async () => { + const response = await POST(request(JSON.stringify({ placement: "__proto__", price: "price_attacker", amount: 1 }))); + assert.equal(response.status, 400); +}); diff --git a/tests/sponsorship-ui.test.ts b/tests/sponsorship-ui.test.ts new file mode 100644 index 0000000..e032d39 --- /dev/null +++ b/tests/sponsorship-ui.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { formatSponsorPrice, isSponsorshipPlacement, SPONSORSHIP_PLACEMENTS, sponsorshipMonthLabel } from "../lib/sponsorship-config"; +import { isPremiumOnlySubscription } from "../lib/premium-subscription"; +import { STRIPE_PRICE_IDS } from "../lib/billing-config"; + +test("sponsorship catalog uses the approved monthly USD amounts", () => { + assert.deepEqual(Object.values(SPONSORSHIP_PLACEMENTS).map((p) => p.amount), [200000, 200000, 20000, 420000]); + assert.equal(formatSponsorPrice(SPONSORSHIP_PLACEMENTS.bundle.amount), "$4,200"); + assert.equal(isSponsorshipPlacement("bundle"), true); + for (const value of ["__proto__", "constructor", "invalid", null, {}, 200]) assert.equal(isSponsorshipPlacement(value), false); +}); + +test("Premium cancellation excludes sponsorships, unknown prices and mixed subscriptions", () => { + const subscription = (ids: string[], metadata = {}, has_more = false) => ({ metadata, items: { data: ids.map((id) => ({ price: { id } })), has_more } }); + assert.equal(isPremiumOnlySubscription(subscription([STRIPE_PRICE_IDS.starter])), true); + assert.equal(isPremiumOnlySubscription(subscription([STRIPE_PRICE_IDS.partner])), false); + assert.equal(isPremiumOnlySubscription(subscription([STRIPE_PRICE_IDS.readmeSponsor])), false); + assert.equal(isPremiumOnlySubscription(subscription([STRIPE_PRICE_IDS.starter, "sponsor_price"])), false); + assert.equal(isPremiumOnlySubscription(subscription([STRIPE_PRICE_IDS.starter], { type: "sponsorship" })), false); + assert.equal(isPremiumOnlySubscription(subscription([STRIPE_PRICE_IDS.starter], {}, true)), false); + assert.equal(isPremiumOnlySubscription(subscription([])), false); +}); + +test("website reverse replaces its sponsor and keeps codebase sponsorship unchanged", () => { + const website = readFileSync("components/website-reverse-page.tsx", "utf8"); + const codebase = readFileSync("components/reverse-prompt-home.tsx", "utf8"); + const banner = readFileSync("components/afterpack-banner.tsx", "utf8"); + assert.match(website, /AfterpackBanner/); + assert.doesNotMatch(website, /CodeRabbitBanner/); + assert.match(codebase, /CodeRabbitBanner/); + assert.match(banner, /https:\/\/afterpack.dev/); + assert.match(banner, /Wanna make your site irreversible\?/); +}); + +test("sponsor page uses owner snapshot and doesn't claim payment from a URL", () => { + const page = readFileSync("components/partner-page.tsx", "utf8"); + assert.doesNotMatch(page, /35,000|1,000\+|3,000\+|Payment received|within 24 hours/); + assert.match(page, /Once verified/); +}); + + +test("sponsorship month label rolls forward in UTC", () => { + assert.equal(sponsorshipMonthLabel(new Date("2026-10-08T07:00:00Z")), "October"); + assert.equal(sponsorshipMonthLabel(new Date("2026-10-31T23:59:59Z")), "October"); + assert.equal(sponsorshipMonthLabel(new Date("2026-11-01T00:00:00Z")), "November"); +}); + +test("sponsor page has requested audience snapshot and logos, with contact only below checkout", () => { + const page = readFileSync("components/partner-page.tsx", "utf8"); + assert.match(page, /Reach a massive/); + assert.match(page, /250K\+/); + assert.match(page, /visitors last month/); + assert.doesNotMatch(page.split("")[0], /mailto:/); + for (const asset of ["coderabbit.png", "make-design.png", "arcumet.png"]) { + assert.match(page, new RegExp(asset.replace(".", "\\."))); + assert.ok(readFileSync(`public/sponsors/${asset}`).length > 100); + } + const route = readFileSync("app/partner/page.tsx", "utf8"); + assert.match(route, /permanentRedirect/); + assert.match(route, /URLSearchParams/); + assert.match(route, /\/sponsor/); + assert.match(readFileSync("app/sponsor/page.tsx", "utf8"), /canonical: "\/sponsor"/); +}); + + +test("home footer preserves Discord while claim card sits below the example repos", () => { + const home = readFileSync("components/reverse-prompt-home.tsx", "utf8"); + const footer = home.slice(home.indexOf("")); + assert.match(footer, /href="https:\/\/discord\.gg\/eHN86K7rBj"/); + assert.match(footer, /Discord/); + assert.doesNotMatch(footer, /Advertise|Claim spot/); + assert.match(footer, /href="https:\/\/filiksyos.com"/); + const examplesEnd = home.indexOf("Advertise your website"); + assert.ok(examplesEnd > home.indexOf("Try example repos:")); + assert.ok(examplesEnd < home.indexOf("")); + const card = home.slice(examplesEnd, home.indexOf("")); + assert.match(card, /href="\/sponsor"/); + assert.match(card, /Claim spot/); + assert.doesNotMatch(home, /Sponsor this spot/); +}); + + +test("website sponsor includes the official local Afterpack logo", () => { + const banner = readFileSync("components/afterpack-banner.tsx", "utf8"); + assert.match(banner, /src="\/sponsors\/afterpack-mark\.png"/); + assert.ok(readFileSync("public/sponsors/afterpack-mark.png").length > 100); + assert.match(banner, /alt="Afterpack"/); + const logo = readFileSync("public/sponsors/afterpack.svg", "utf8"); + assert.match(logo, /viewBox="0 0 152 34"/); + assert.doesNotMatch(logo, / { + const afterpack = readFileSync("components/afterpack-banner.tsx", "utf8"); + const coderabbit = readFileSync("components/coderabbit-banner.tsx", "utf8"); + for (const style of ["border-[2px] border-zinc-900/15 bg-white/70", "bg-[#FF570A]", "px-3 py-2.5", "Try free"]) { + assert.ok(afterpack.includes(style)); + assert.ok(coderabbit.includes(style)); + } + assert.match(afterpack, /Wanna make your site irreversible\?/); + assert.match(afterpack, /Make sure you use Afterpack/); + assert.doesNotMatch(afterpack, /Sponsor this spot|\/sponsor"/); +});