From 81775b80c7b3d9c62a8e7489dd7588dc0fe4adfa Mon Sep 17 00:00:00 2001 From: Eugene Mutembei <103780583+CodeWithEugene@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:11:41 +0300 Subject: [PATCH 1/2] fix(ui): make the mobile menu open again --- components/patterns/mobile-nav.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/patterns/mobile-nav.tsx b/components/patterns/mobile-nav.tsx index 4749443..e6d5951 100644 --- a/components/patterns/mobile-nav.tsx +++ b/components/patterns/mobile-nav.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { Menu, X } from "lucide-react"; import { NavLink } from "@/components/patterns/nav-link"; +import { cn } from "@/lib/utils"; interface NavLinkItem { href: string; @@ -71,7 +72,7 @@ export function MobileNav({ navLinks, organizerLinks, trailingLinks }: MobileNav ? createPortal( <> - {isDraft ? ( - - - - ) : null} +
+ {isDraft ? ( + + + + ) : null} + {event.status === "DRAFT" || event.status === "PENDING_DEPOSIT" ? ( + + ) : null} +
diff --git a/app/(app)/organizer/hackathons/[slug]/winners/page.tsx b/app/(app)/organizer/hackathons/[slug]/winners/page.tsx index 03383d1..648e24b 100644 --- a/app/(app)/organizer/hackathons/[slug]/winners/page.tsx +++ b/app/(app)/organizer/hackathons/[slug]/winners/page.tsx @@ -55,6 +55,33 @@ export default async function EventWinnersPage({ const results = event.status === "JUDGING" ? await computeEventResults(event.id) : null; const scoreFor = (teamId: string) => results?.results.find((r) => r.teamId === teamId) ?? null; + /** The final-50% due for a winner: the milestone tranche when it exists, else pool minus instant. */ + const milestoneDueKes = (winner: (typeof event.winners)[number]): number => { + const instant = winner.payouts.find((p) => p.tranche === "INSTANT"); + const milestonePayout = winner.payouts.find((p) => p.tranche === "MILESTONE"); + return ( + milestonePayout?.amountKes ?? + (winner.milestoneRequired ? winner.amountKes - (instant?.amountKes ?? 0) : 0) + ); + }; + + const milestoneDues = event.winners + .filter((winner) => winner.milestoneRequired) + .map((winner) => { + const milestonePayout = winner.payouts.find((p) => p.tranche === "MILESTONE"); + return { + id: winner.id, + place: winner.place, + teamName: winner.team.name, + amountKes: milestoneDueKes(winner), + dueAt: winner.milestone?.dueAt ?? null, + confirmedAt: winner.milestone?.confirmedAt ?? null, + payoutStatus: milestonePayout?.status ?? null, + }; + }) + .sort((a, b) => a.place - b.place); + const outstandingDues = milestoneDues.filter((due) => due.payoutStatus !== "SUCCEEDED"); + return (
@@ -181,20 +208,68 @@ export default async function EventWinnersPage({
{winner.milestone && !winner.milestone.confirmedAt ? ( - + ) : null} ); })} - - Milestone Dues - - The final 50% releases when you confirm each winner's handover. Confirming - triggers the milestone payout immediately. - - + {milestoneDues.length > 0 ? ( + + Milestone Dues + + {outstandingDues.length === 0 + ? "Every milestone tranche is settled." + : `${formatKes(outstandingDues.reduce((sum, due) => sum + due.amountKes, 0))} outstanding across ${outstandingDues.length} handover${outstandingDues.length === 1 ? "" : "s"}. Confirming a handover releases the tranche immediately.`} + +
    + {milestoneDues.map((due) => ( +
  • + + {due.place} + {["st", "nd", "rd"][due.place - 1] ?? "th"} · {due.teamName} + + + + {formatKes(due.amountKes)} + + + {due.payoutStatus + ? due.payoutStatus.toLowerCase().replace("_", " ") + : due.confirmedAt + ? "releasing" + : "awaiting handover"} + + {due.dueAt ? ( + + due{" "} + {new Date(due.dueAt).toLocaleDateString("en-KE", { + day: "numeric", + month: "short", + })} + + ) : null} + +
  • + ))} +
+
+ ) : null} ) : null} diff --git a/app/(app)/organizer/loading.tsx b/app/(app)/organizer/loading.tsx new file mode 100644 index 0000000..18ad7aa --- /dev/null +++ b/app/(app)/organizer/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+
+ + +
+
+ + + +
+ ); +} diff --git a/app/(app)/organizer/organization/loading.tsx b/app/(app)/organizer/organization/loading.tsx new file mode 100644 index 0000000..baabf3d --- /dev/null +++ b/app/(app)/organizer/organization/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ + + + +
+ ); +} diff --git a/app/(app)/organizer/organization/page.tsx b/app/(app)/organizer/organization/page.tsx new file mode 100644 index 0000000..4bdae0b --- /dev/null +++ b/app/(app)/organizer/organization/page.tsx @@ -0,0 +1,197 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ShieldCheck, Users } from "lucide-react"; +import type { TrustEventType } from "@prisma/client"; + +import { InviteCodeManager } from "@/components/organizer/invite-code-manager"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { requireSurface } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; + +export const metadata: Metadata = { title: "Organization Settings" }; + +const KYB_BADGE = { + NONE: { label: "KYB: Not Started", tone: "warning" }, + PENDING: { label: "KYB: In Review", tone: "warning" }, + VERIFIED: { label: "Verified Organization", tone: "success" }, + FAILED: { label: "KYB: Needs Attention", tone: "danger" }, +} as const; + +const TRUST_EVENT_LABELS: Record = { + MEDIA_PENALTY: "Media delivered late", + PAYOUT_EXCELLENCE: "Payout excellence", + MANUAL_ADJUST: "Trust adjustment", + APPEAL_GRANTED: "Appeal granted", +}; + +const ROLE_ORDER = { OWNER: 0, ADMIN: 1, MEMBER: 2 } as const; + +const dateFormat = new Intl.DateTimeFormat("en-KE", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "Africa/Nairobi", +}); + +export default async function OrganizationSettingsPage() { + const user = await requireSurface("organizer"); + + const membership = await prisma.orgMember.findFirst({ + where: { userId: user.id, status: "ACTIVE" }, + include: { org: true }, + }); + + if (!membership) { + return ( + + + + } + /> + ); + } + + const org = membership.org; + const [members, activeInvites, trustEvents] = await Promise.all([ + prisma.orgMember.findMany({ + where: { orgId: org.id, status: "ACTIVE" }, + include: { user: { select: { name: true, email: true, handle: true } } }, + }), + prisma.orgInvitation.findMany({ + where: { orgId: org.id, acceptedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: "desc" }, + take: 5, + }), + prisma.trustEvent.findMany({ + where: { orgId: org.id }, + orderBy: { createdAt: "desc" }, + take: 20, + }), + ]); + const sortedMembers = [...members].sort( + (a, b) => ROLE_ORDER[a.role] - ROLE_ORDER[b.role], + ); + const kyb = KYB_BADGE[org.kycStatus]; + + return ( +
+
+
+

{org.name}

+

+ Organization settings · public page at{" "} + + /organizers/{org.slug} + +

+
+ Trust score {org.trustScore} +
+ + +
+
+ + Business Verification + + + {org.kycStatus === "VERIFIED" + ? "Your organization is verified. Your hackathons carry the Verified Organization badge." + : "Verification is required before your first prize-pool deposit. Review takes up to 48 hours."} + +
+
+ {kyb.label} + + + +
+
+
+ + + + Members + + + Everyone with organizer access to {org.name}. Owners and admins can manage hackathons, + invites and this page. + +
    + {sortedMembers.map((member) => ( +
  • +
    +

    + {member.user.name ?? member.user.email} + {member.userId === user.id ? ( + (you) + ) : null} +

    +

    {member.user.email}

    +
    + + {member.role.toLowerCase()} + +
  • + ))} +
+
+ + ({ + token: invite.token, + expiresAt: invite.expiresAt.toISOString(), + }))} + /> + + + Trust History + + Why the score is where it is. Penalties and awards land here the moment they are + recorded; the same history (minus internal notes) is public on your organizer page. + + {trustEvents.length === 0 ? ( +

+ No trust events yet. The score moves with payout speed, media delivery and milestone + honesty. +

+ ) : ( +
    + {trustEvents.map((event) => ( +
  • +
    +

    + {TRUST_EVENT_LABELS[event.type]} +

    +

    + {dateFormat.format(event.createdAt)} + {event.reason ? ` · ${event.reason}` : ""} +

    +
    + + {event.delta > 0 ? `+${event.delta}` : event.delta} points + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/app/(app)/organizer/page.tsx b/app/(app)/organizer/page.tsx index 1837925..95582d5 100644 --- a/app/(app)/organizer/page.tsx +++ b/app/(app)/organizer/page.tsx @@ -87,7 +87,10 @@ export default async function OrganizerPage() {

{org.name}

- {membership.role.toLowerCase()} · hackvillage.xyz/organizers/{org.slug} + {membership.role.toLowerCase()} ·{" "} + + /organizers/{org.slug} +

@@ -123,8 +126,8 @@ export default async function OrganizerPage() { Run A Hackathon - Five steps to a draft; publishing declares the prize pool. The vault deposit flow - (Phase 3) flips it live with the Prize Verified badge. + Five steps to a draft; publishing declares the prize pool. Locking the pool in the + Prize Vault flips it live with the Prize Verified badge.
diff --git a/app/(auth)/onboarding/choose/page.tsx b/app/(auth)/onboarding/choose/page.tsx index a55e806..45c8758 100644 --- a/app/(auth)/onboarding/choose/page.tsx +++ b/app/(auth)/onboarding/choose/page.tsx @@ -1,7 +1,10 @@ import type { Metadata } from "next"; -import { Building2, Code2 } from "lucide-react"; +import Link from "next/link"; +import { Building2, Code2, LogIn } from "lucide-react"; import { RoleChoiceCards } from "@/components/onboarding/role-choice-cards"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { currentUser } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Choose Your Path" }; @@ -9,10 +12,27 @@ export const metadata: Metadata = { title: "Choose Your Path" }; export default async function OnboardingChoosePage() { const user = await currentUser(); if (!user) { + // Signed-out visitors get a real way forward, not a dead end. After + // sign-in the auth flow returns unonboarded users to this page itself + // (lib/auth/actions.ts), so no ?next= round-trip is needed. return ( -

- Sign in first, then we'll set you up. -

+ + + Sign In To Choose Your Path + + + Pick organizer or developer right after you sign in — we'll bring you straight back + here. + +
+ + + + + + +
+
); } diff --git a/app/(marketing)/developers/[handle]/loading.tsx b/app/(marketing)/developers/[handle]/loading.tsx new file mode 100644 index 0000000..9a2c388 --- /dev/null +++ b/app/(marketing)/developers/[handle]/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ + + + + + +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ ); +} diff --git a/app/(marketing)/hackathons/[slug]/loading.tsx b/app/(marketing)/hackathons/[slug]/loading.tsx new file mode 100644 index 0000000..a97c994 --- /dev/null +++ b/app/(marketing)/hackathons/[slug]/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + + +
+
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/app/(marketing)/hackathons/[slug]/page.tsx b/app/(marketing)/hackathons/[slug]/page.tsx index 3c9f94f..b58dca6 100644 --- a/app/(marketing)/hackathons/[slug]/page.tsx +++ b/app/(marketing)/hackathons/[slug]/page.tsx @@ -5,6 +5,7 @@ import { CalendarDays, Clock, MapPin, Users } from "lucide-react"; import { KeyDatesCard } from "@/components/patterns/key-dates-card"; import { OrganizerCard } from "@/components/patterns/organizer-card"; +import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge"; import { StatusTimeline } from "@/components/patterns/status-timeline"; import { JsonLd } from "@/components/seo/json-ld"; import { Badge } from "@/components/ui/badge"; @@ -175,6 +176,7 @@ export default async function EventDetailPage({ params, searchParams }: PageProp {event._count.teams} team {event._count.teams === 1 ? "" : "s"} + {event.prizeVerifiedAt ? : null} {event.summary ? (

{event.summary}

diff --git a/app/(marketing)/hackathons/loading.tsx b/app/(marketing)/hackathons/loading.tsx new file mode 100644 index 0000000..4f94f85 --- /dev/null +++ b/app/(marketing)/hackathons/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+
+ ); +} diff --git a/app/(marketing)/hackathons/page.tsx b/app/(marketing)/hackathons/page.tsx index 57bdf5b..d508c2c 100644 --- a/app/(marketing)/hackathons/page.tsx +++ b/app/(marketing)/hackathons/page.tsx @@ -73,6 +73,7 @@ const getStoredCards = unstable_cache( orgTrustScore: event.org.trustScore, categories: event.categories, coverUrl: event.coverUrl, + prizeVerifiedAt: event.prizeVerifiedAt?.toISOString() ?? null, })); }, ["public-hackathon-listing"], diff --git a/app/(marketing)/organizers/[slug]/loading.tsx b/app/(marketing)/organizers/[slug]/loading.tsx new file mode 100644 index 0000000..fe08171 --- /dev/null +++ b/app/(marketing)/organizers/[slug]/loading.tsx @@ -0,0 +1,29 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ + +
+ + +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+
+ ); +} diff --git a/app/(marketing)/organizers/[slug]/page.tsx b/app/(marketing)/organizers/[slug]/page.tsx new file mode 100644 index 0000000..f724ba2 --- /dev/null +++ b/app/(marketing)/organizers/[slug]/page.tsx @@ -0,0 +1,418 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { CalendarDays, ExternalLink, MapPin, ShieldCheck, Timer } from "lucide-react"; +import type { TrustEventType } from "@prisma/client"; + +import { JsonLd } from "@/components/seo/json-ld"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; +import { prisma } from "@/lib/db"; +import { formatEventDates } from "@/lib/events/format"; +import { isPublicHackathon } from "@/lib/events/visibility"; +import { formatOrgLocation, isOrgKind, ORG_KIND_LABELS } from "@/lib/orgs/details"; +import { pageOpenGraph } from "@/lib/seo/metadata"; +import { appUrl } from "@/lib/url"; +import { formatKes } from "@/lib/utils"; + +import { summarizeMediaTimeliness, summarizePayoutPerformance } from "./performance"; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +/** + * Public-safe organization select. contactPhone is deliberately absent — it + * is for the HackVillage team only (schema comment) and must never reach a + * public page. + */ +const PUBLIC_ORG_SELECT = { + id: true, + name: true, + slug: true, + about: true, + kind: true, + city: true, + country: true, + website: true, + socialUrl: true, + kycStatus: true, + trustScore: true, + createdAt: true, +} as const; + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + const org = await prisma.organization.findUnique({ + where: { slug }, + select: { name: true, about: true, kycStatus: true }, + }); + if (!org) return { title: "Organizer Not Found", robots: { index: false } }; + const verified = org.kycStatus === "VERIFIED"; + return { + title: `${org.name}, ${verified ? "Verified " : ""}Hackathon Organizer`, + description: + org.about ?? + `${org.name} hosts hackathons on HackVillage with 100% escrowed prize pools and payouts recorded on a public ledger.`, + alternates: { canonical: `/organizers/${slug}` }, + openGraph: pageOpenGraph(`/organizers/${slug}`), + }; +} + +const joinedFormat = new Intl.DateTimeFormat("en-KE", { + month: "short", + year: "numeric", + timeZone: "Africa/Nairobi", +}); + +const dateFormat = new Intl.DateTimeFormat("en-KE", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "Africa/Nairobi", +}); + +const TRUST_EVENT_LABELS: Record = { + MEDIA_PENALTY: "Media delivered late", + PAYOUT_EXCELLENCE: "Payout excellence", + MANUAL_ADJUST: "Trust adjustment", + APPEAL_GRANTED: "Appeal granted", +}; + +/** "technetium.co.ke" from "https://www.technetium.co.ke/" (same rule as the Organizer card). */ +function linkLabel(url: string): string { + try { + const { hostname, pathname } = new URL(url); + const host = hostname.replace(/^www\./, ""); + return pathname.length > 1 ? `${host}${pathname.replace(/\/$/, "")}` : host; + } catch { + return url; + } +} + +function formatMedianMinutes(minutes: number): string { + if (minutes < 60) return `${Math.round(minutes)} min`; + const hours = minutes / 60; + return `${hours < 10 ? hours.toFixed(1) : Math.round(hours)} h`; +} + +export default async function OrganizerTrustPage({ params }: PageProps) { + const { slug } = await params; + const org = await prisma.organization.findUnique({ + where: { slug }, + select: PUBLIC_ORG_SELECT, + }); + if (!org) notFound(); + + const [events, trustEvents, payouts, media, escrowed] = await Promise.all([ + prisma.event.findMany({ + where: { orgId: org.id, publishedAt: { not: null } }, + select: { + slug: true, + title: true, + status: true, + startsAt: true, + endsAt: true, + publishedAt: true, + prizeVerifiedAt: true, + prizes: { select: { amountKes: true } }, + }, + orderBy: { startsAt: "desc" }, + take: 12, + }), + prisma.trustEvent.findMany({ + where: { orgId: org.id }, + orderBy: { createdAt: "desc" }, + take: 10, + }), + prisma.payout.findMany({ + where: { winner: { event: { orgId: org.id } } }, + select: { + tranche: true, + status: true, + paidAt: true, + winner: { select: { announcedAt: true } }, + }, + }), + prisma.mediaAsset.findMany({ + where: { event: { orgId: org.id }, status: { not: "HIDDEN" } }, + select: { + eventId: true, + uploadedAt: true, + event: { select: { mediaDeadlineAt: true } }, + }, + }), + prisma.prizeBreakdown.aggregate({ + _sum: { amountKes: true }, + where: { event: { orgId: org.id, prizeVerifiedAt: { not: null } } }, + }), + ]); + + const performance = summarizePayoutPerformance( + payouts.map((payout) => ({ + tranche: payout.tranche, + status: payout.status, + paidAt: payout.paidAt, + announcedAt: payout.winner.announcedAt, + })), + ); + const timeliness = summarizeMediaTimeliness( + media.map((asset) => ({ + eventId: asset.eventId, + uploadedAt: asset.uploadedAt, + mediaDeadlineAt: asset.event.mediaDeadlineAt, + })), + ); + + const location = formatOrgLocation(org); + const facts = [isOrgKind(org.kind) ? ORG_KIND_LABELS[org.kind] : null, location].filter(Boolean); + const links = [org.website, org.socialUrl].filter((link): link is string => Boolean(link)); + const verified = org.kycStatus === "VERIFIED"; + + const stats = [ + { label: "Hackathons hosted", value: String(events.length) }, + { label: "Prizes escrowed", value: formatKes(escrowed._sum.amountKes ?? 0) }, + { label: "On HackVillage since", value: joinedFormat.format(org.createdAt) }, + ]; + + return ( + <> + 0 ? { sameAs: links } : {}), + ...(location + ? { + address: { + "@type": "PostalAddress", + ...(org.city ? { addressLocality: org.city } : {}), + addressCountry: org.country ?? "KE", + }, + } + : {}), + }} + /> +
+
+
+

{org.name}

+ {facts.length > 0 ? ( +

+ {location ? : null} + {facts.join(" · ")} +

+ ) : null} +
+ {verified ? ( + + Verified Organization + + ) : null} + Trust score {org.trustScore} +
+ {org.about ? ( +

{org.about}

+ ) : null} + {links.length > 0 ? ( + + ) : null} +
+ {stats.map((stat) => ( +
+
{stat.label}
+
+ {stat.value} +
+
+ ))} +
+
+ +
+

+ Payout Performance +

+
+ + Instant Payouts + {performance.instantTotal === 0 ? ( + + No payouts yet. The first winners paid will start this record. + + ) : ( + <> +

+ {performance.instantSucceeded} of {performance.instantTotal} +

+ + instant-tranche payouts confirmed. Winners are promised 50% of their prize + within an hour of results. + + + )} +
+ + + Announce→Paid + + {performance.medianInstantMinutes === null ? ( + + No confirmed instant payouts yet, so there is no median time to report. + + ) : ( + <> +

+ {formatMedianMinutes(performance.medianInstantMinutes)} +

+ + median time from winners announced to the instant payout landing. + + + )} +
+ + + Media Delivery + + {timeliness.eventsWithMedia === 0 ? ( + + No event media yet. Event photos delivered within 48 hours start this record. + + ) : ( + <> +

+ {timeliness.onTime} of {timeliness.eventsWithMedia} +

+ + events delivered media inside the 48-hour standard. + + + )} +
+
+
+ +
+

+ Hackathons +

+ {events.length === 0 ? ( + + + {org.name} has not published a hackathon yet. When they do, it appears here with + its escrow status. + + + ) : ( +
    + {events.map((event) => { + const isPublic = isPublicHackathon(event); + const poolKes = event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0); + return ( +
  • + +
    +
    + {isPublic ? ( + + {event.title} + + ) : ( +

    + {event.title} +

    + )} +

    + {formatEventDates(event.startsAt, event.endsAt)} ·{" "} + {formatKes(poolKes)} prize pool +

    +
    +
    + {event.prizeVerifiedAt ? ( + + Prize Verified + + ) : ( + {event.status.toLowerCase()} + )} +
    +
    +
    +
  • + ); + })} +
+ )} +
+ +
+

+ Trust History +

+ + {trustEvents.length === 0 ? ( + + No trust events yet. The score moves with payout speed, media delivery and + milestone honesty; a quiet history is a good history. + + ) : ( +
    + {trustEvents.map((event) => ( +
  • +
    +

    + {TRUST_EVENT_LABELS[event.type]} +

    +

    + {dateFormat.format(event.createdAt)} + {event.type !== "MANUAL_ADJUST" && event.reason + ? ` · ${event.reason}` + : ""} +

    +
    + + {event.delta > 0 ? `+${event.delta}` : event.delta} points + +
  • + ))} +
+ )} +
+

+ Trust scores start at 100 and move with payout speed, media delivery and milestone + honesty. Every lock and payout by this organizer is on the{" "} + + public ledger + + . +

+
+
+
+ + ); +} diff --git a/app/(marketing)/organizers/[slug]/performance.ts b/app/(marketing)/organizers/[slug]/performance.ts new file mode 100644 index 0000000..11095f2 --- /dev/null +++ b/app/(marketing)/organizers/[slug]/performance.ts @@ -0,0 +1,88 @@ +import type { PayoutStatus, PayoutTranche } from "@prisma/client"; + +/** + * Pure math for the /organizers/[slug] payout-performance section, colocated + * with the route so it stays unit-testable (Next.js page modules may only + * export route entrypoints). Every metric degrades to an explicit empty + * state rather than a misleading zero — an organizer with no payouts yet has + * a clean record, not a failed one. + */ + +export interface PayoutRecord { + tranche: PayoutTranche; + status: PayoutStatus; + paidAt: Date | null; + /** The parent winner's announcedAt — the start of the payout clock. */ + announcedAt: Date; +} + +export interface PayoutPerformance { + /** All instant-tranche payouts ever attempted for the organizer's events. */ + instantTotal: number; + instantSucceeded: number; + /** Median minutes from winners announced to instant payout confirmed. */ + medianInstantMinutes: number | null; +} + +/** Median of a numeric sample; null when empty (never a fake zero). */ +export function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +export function summarizePayoutPerformance(records: PayoutRecord[]): PayoutPerformance { + const instant = records.filter((record) => record.tranche === "INSTANT"); + const succeeded = instant.filter((record) => record.status === "SUCCEEDED"); + // Negative durations mean bad clock data upstream; exclude them rather + // than silently dragging the median down. + const paid = succeeded.filter( + (record): record is PayoutRecord & { paidAt: Date } => record.paidAt !== null, + ); + const minutes = paid + .map((record) => (record.paidAt.getTime() - record.announcedAt.getTime()) / 60_000) + .filter((value) => value >= 0); + return { + instantTotal: instant.length, + instantSucceeded: succeeded.length, + medianInstantMinutes: median(minutes), + }; +} + +export interface MediaRecord { + eventId: string; + uploadedAt: Date; + /** The event's mediaDeadlineAt (endsAt + 48h); null means no deadline was set. */ + mediaDeadlineAt: Date | null; +} + +export interface MediaTimeliness { + /** Events with a media deadline and at least one visible upload. */ + eventsWithMedia: number; + /** Of those, events whose first upload beat the 48-hour deadline. */ + onTime: number; +} + +export function summarizeMediaTimeliness(records: MediaRecord[]): MediaTimeliness { + const firstUploadByEvent = new Map(); + for (const record of records) { + const current = firstUploadByEvent.get(record.eventId); + if (!current || record.uploadedAt.getTime() < current.uploadedAt.getTime()) { + firstUploadByEvent.set(record.eventId, { + uploadedAt: record.uploadedAt, + mediaDeadlineAt: record.mediaDeadlineAt, + }); + } + } + let eventsWithMedia = 0; + let onTime = 0; + for (const first of firstUploadByEvent.values()) { + if (!first.mediaDeadlineAt) continue; + eventsWithMedia += 1; + if (first.uploadedAt.getTime() <= first.mediaDeadlineAt.getTime()) onTime += 1; + } + return { eventsWithMedia, onTime }; +} diff --git a/app/(marketing)/trust/ledger.ts b/app/(marketing)/trust/ledger.ts new file mode 100644 index 0000000..067391f --- /dev/null +++ b/app/(marketing)/trust/ledger.ts @@ -0,0 +1,45 @@ +import type { LedgerEntryType, Prisma } from "@prisma/client"; + +/** + * Pure display logic for the /trust public ledger page, colocated with the + * route (Next.js page modules may only export route entrypoints, so testable + * logic lives here — see tests/unit/public-trust-pages.test.ts). + */ + +/** Human labels for the on-chain attestation types (plan §11.3). */ +export const LEDGER_TYPE_LABELS: Record = { + VAULT_CREATED: "Vault created", + DEPOSIT_LOCKED: "Prize pool locked", + INSTANT_PAYOUT: "Instant payout", + MILESTONE_PAYOUT: "Milestone payout", + VAULT_REFUNDED: "Vault refunded", +}; + +/** + * Ledger payloads are JSON written by the escrow/payout services: amountKes + * is present on every type today, but the page must survive older rows and + * future shapes — so extraction is defensive, never a cast. + */ +export function payloadAmountKes(payload: Prisma.JsonValue): number | null { + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + const value = (payload as Record).amountKes; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return null; +} + +/** The most human-readable reference in a payload: transfer ref, payment ref, refund ref or contract. */ +export function payloadReference(payload: Prisma.JsonValue): string | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const record = payload as Record; + for (const key of ["txRef", "paystackRef", "refundRef", "contractAddress"] as const) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return null; +} + +/** "0x1234…abcd" — enough to recognise a hash, short enough for a table row. */ +export function shortenHash(hash: string): string { + return hash.length > 12 ? `${hash.slice(0, 6)}…${hash.slice(-4)}` : hash; +} diff --git a/app/(marketing)/trust/loading.tsx b/app/(marketing)/trust/loading.tsx new file mode 100644 index 0000000..a586993 --- /dev/null +++ b/app/(marketing)/trust/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + + +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ + +
+ ); +} diff --git a/app/(marketing)/trust/page.tsx b/app/(marketing)/trust/page.tsx new file mode 100644 index 0000000..5426bdb --- /dev/null +++ b/app/(marketing)/trust/page.tsx @@ -0,0 +1,281 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ExternalLink, ScrollText, ShieldCheck } from "lucide-react"; +import type { Prisma, VaultChainState } from "@prisma/client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { prisma } from "@/lib/db"; +import { getEnv } from "@/lib/env"; +import { getChainPort } from "@/lib/ports/chain"; +import { pageOpenGraph } from "@/lib/seo/metadata"; +import { formatKes } from "@/lib/utils"; + +import { + LEDGER_TYPE_LABELS, + payloadAmountKes, + payloadReference, + shortenHash, +} from "./ledger"; + +export const metadata: Metadata = { + title: "The Public Ledger: Every Prize Lock And Payout, Verifiable", + description: + "Every hackathon prize pool locked, paid out or refunded on HackVillage is recorded on a public ledger. Verify each entry yourself — radical transparency is the product.", + alternates: { canonical: "/trust" }, + openGraph: pageOpenGraph("/trust"), +}; + +const VAULT_STATE_LABELS: Record = { + AWAITING: "Awaiting deposit", + LOCKED: "Locked in escrow", + HALF_RELEASED: "50% paid out", + SETTLED: "Fully settled", + REFUNDED: "Refunded", +}; + +const timestampFormat = new Intl.DateTimeFormat("en-KE", { + day: "numeric", + month: "short", + year: "numeric", + hour: "numeric", + minute: "2-digit", + timeZone: "Africa/Nairobi", +}); + +interface PageProps { + searchParams: Promise<{ event?: string }>; +} + +export default async function TrustPage({ searchParams }: PageProps) { + const { event: eventFilter } = await searchParams; + const chainMode = getChainPort().mode; + const polygonscanUrl = getEnv().POLYGONSCAN_URL; + const onChain = chainMode !== "simulation"; + + const eventWhere: Prisma.EventWhereInput | undefined = eventFilter + ? { slug: eventFilter } + : undefined; + + const [entries, vaults, filterEvents] = await Promise.all([ + prisma.ledgerEntry.findMany({ + where: eventWhere ? { event: eventWhere } : undefined, + include: { event: { select: { title: true, slug: true } } }, + orderBy: { mirroredAt: "desc" }, + take: 100, + }), + prisma.vaultState.findMany({ + where: eventWhere ? { event: eventWhere } : undefined, + include: { event: { select: { title: true, slug: true } } }, + orderBy: { updatedAt: "desc" }, + }), + // Filter chips always list every event that has a ledger, so switching + // away from the current filter is one click. + prisma.event.findMany({ + where: { ledgerEntries: { some: {} } }, + select: { slug: true, title: true }, + orderBy: { startsAt: "desc" }, + }), + ]); + + return ( +
+
+

+ The Public Ledger +

+

+ Every prize pool locked, payout sent and vault refunded is recorded here, entry by + entry. Trust on HackVillage is not a promise; it is a record you can check. +

+
+ + {!onChain ? ( + + + Simulation Mode + + + The ledger is running in simulation mode while we complete go-live. Entries are + application-verified and recorded in order; on-chain anchoring is pending, so + transaction references are not yet verifiable on a block explorer. + + + ) : null} + + {vaults.length > 0 ? ( +
+

+ Prize Vaults +

+
    + {vaults.map((vault) => ( +
  • + +

    + + {vault.event.title} + +

    +

    + {formatKes(vault.amountKes)} +

    +
    + + {VAULT_STATE_LABELS[vault.chainState]} + +
    + {vault.contractAddress ? ( +

    + {onChain ? ( + + {shortenHash(vault.contractAddress)} + + + ) : ( + shortenHash(vault.contractAddress) + )} +

    + ) : null} +
    +
  • + ))} +
+
+ ) : null} + + {filterEvents.length > 1 ? ( + + ) : null} + + {entries.length === 0 ? ( + + + + } + /> + ) : ( +
+

+ Ledger Entries +

+
+ + + + + + + + + + + + {entries.map((entry) => { + const amount = payloadAmountKes(entry.payload); + const reference = payloadReference(entry.payload); + return ( + + + + + + + + ); + })} + +
+ Time + + Hackathon + + Entry + + Amount + + {onChain ? "Transaction" : "Reference"} +
+ {timestampFormat.format(entry.mirroredAt)} + + + {entry.event.title} + + {LEDGER_TYPE_LABELS[entry.type]} + {amount === null ? "—" : formatKes(amount)} + + {onChain ? ( + + {shortenHash(entry.txHash)} + + (opens on Polygonscan in a new tab) + + ) : ( + + {shortenHash(reference ?? entry.txHash)} + + )} +
+
+

+ Showing the {entries.length} most recent entries + {entries.length === 100 ? " (use the hackathon filter to narrow down)" : ""}.{" "} + {onChain + ? "Each transaction hash resolves on Polygonscan, the independent block explorer for the Polygon network." + : "References are synthetic while the ledger runs in simulation mode."} +

+
+ )} +
+ ); +} diff --git a/app/admin/hackathons/page.tsx b/app/admin/hackathons/page.tsx index f447a8b..1903e92 100644 --- a/app/admin/hackathons/page.tsx +++ b/app/admin/hackathons/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { AdminEventCancelForm } from "@/components/admin/event-cancel-form"; import { Badge } from "@/components/ui/badge"; import { Card, CardTitle } from "@/components/ui/card"; import { prisma } from "@/lib/db"; @@ -51,6 +52,11 @@ export default async function AdminEventsPage() { > {STATUS_LABELS[event.status]} + {["DRAFT", "PENDING_DEPOSIT", "LIVE", "IN_PROGRESS", "JUDGING"].includes( + event.status + ) ? ( + + ) : null} ))} diff --git a/app/admin/loading.tsx b/app/admin/loading.tsx new file mode 100644 index 0000000..ae8e096 --- /dev/null +++ b/app/admin/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ ); +} diff --git a/app/admin/payments/page.tsx b/app/admin/payments/page.tsx index 6b757b5..5fa6979 100644 --- a/app/admin/payments/page.tsx +++ b/app/admin/payments/page.tsx @@ -83,7 +83,7 @@ export default async function AdminPaymentsPage() { - + ))} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 60f483a..57d2ef3 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -59,26 +59,39 @@ export default async function AdminUsersPage({ Results ({users.length}) -
    - {users.map((user) => ( -
  • - - {user.name ?? `@${user.handle}`}{" "} - @{user.handle} - {user.email} - - - {user.deletedAt ? deleted : null} - - {user.emailVerified ? "verified" : "unverified"} - - {user.roleGrants.map((grant) => ( - {grant.role.toLowerCase()} - ))} - -
  • - ))} -
+ {users.length === 0 ? ( +

+ {q ? ( + <> + No users match “{q}”. Try a + different name, handle, or email. + + ) : ( + "No users yet." + )} +

+ ) : ( +
    + {users.map((user) => ( +
  • + + {user.name ?? `@${user.handle}`}{" "} + @{user.handle} + {user.email} + + + {user.deletedAt ? deleted : null} + + {user.emailVerified ? "verified" : "unverified"} + + {user.roleGrants.map((grant) => ( + {grant.role.toLowerCase()} + ))} + +
  • + ))} +
+ )}
); diff --git a/app/api/dev/covers/upload/route.ts b/app/api/dev/covers/upload/route.ts index 4f4e1e6..29599a1 100644 --- a/app/api/dev/covers/upload/route.ts +++ b/app/api/dev/covers/upload/route.ts @@ -1,4 +1,4 @@ -import { requireUser } from "@/lib/auth/guards"; +import { currentUser } from "@/lib/auth/guards"; import { authorizeCoverEdit, CoverError } from "@/lib/events/cover-service"; import { isCoverKeyFor, validateCoverUpload } from "@/lib/events/cover-upload"; @@ -6,16 +6,18 @@ export const dynamic = "force-dynamic"; /** * DEV MODE cover upload (storage port in local mode): receives the processed - * cover and saves it under public/uploads. 404 whenever R2 is configured, so - * there is no dev path in production. + * cover and saves it under public/uploads. 404 in production and whenever R2 + * is configured, so there is no dev path in production. */ export async function POST(request: Request) { + if (process.env.NODE_ENV === "production") { + return new Response("Not Found", { status: 404 }); + } const storage = (await import("@/lib/ports/storage")).getStoragePort(); if (storage.mode !== "local") { return new Response("Not Found", { status: 404 }); } - const user = await requireUser(); const key = new URL(request.url).searchParams.get("key") ?? ""; const eventId = key.split("/")[1] ?? ""; if (!isCoverKeyFor(eventId, key)) { @@ -27,8 +29,13 @@ export async function POST(request: Request) { const problem = validateCoverUpload(contentType, body.length); if (problem) return Response.json({ error: problem }, { status: 400 }); + const user = await currentUser(); + if (!user) return Response.json({ error: "Sign in first." }, { status: 401 }); + try { + // Only organization owners/admins of the target event may write its cover. await authorizeCoverEdit(user.id, eventId); + // saveLocalAt joins under public/uploads and refuses escaping keys. const url = await storage.saveLocalAt(key, body); return Response.json({ ok: true, url }); } catch (error) { diff --git a/app/api/dev/media/upload/[key]/route.ts b/app/api/dev/media/upload/[key]/route.ts index 420dc2f..4e4942d 100644 --- a/app/api/dev/media/upload/[key]/route.ts +++ b/app/api/dev/media/upload/[key]/route.ts @@ -1,47 +1,57 @@ -import { registerMediaAsset } from "@/services/media/service"; -import { requireUser } from "@/lib/auth/guards"; +import { currentUser } from "@/lib/auth/guards"; +import { validateMediaUpload } from "@/lib/ports/storage"; +import { MediaError, requireOrgAdmin } from "@/services/media/service"; export const dynamic = "force-dynamic"; +/** keys are issued as events//- — anything else is rejected. */ +const MEDIA_KEY_PATTERN = /^events\/([a-z0-9]+)\/[a-zA-Z0-9._-]+$/; + /** - * DEV MODE media upload (storage port in local mode): receives the file - * directly and registers the asset in one step. 404 whenever R2 is - * configured (P4 — no dev paths in production). + * DEV MODE media upload (storage port in local mode): receives the file and + * saves it under public/uploads. The MediaAsset row is NOT created here — + * the client confirms via confirmMediaUploadAction, identical to the R2 + * flow. 404 in production and whenever R2 is configured (P4 — no dev paths + * in production). */ export async function POST( request: Request, { params }: { params: Promise<{ key: string }> } ) { + if (process.env.NODE_ENV === "production") { + return new Response("Not Found", { status: 404 }); + } const storage = (await import("@/lib/ports/storage")).getStoragePort(); if (storage.mode !== "local") { return new Response("Not Found", { status: 404 }); } + // Key shape first — traversal attempts get a cheap 400 before any auth or IO. const { key } = await params; - const user = await requireUser(); + const match = MEDIA_KEY_PATTERN.exec(key); + if (!match || key.includes("..")) { + return Response.json({ error: "Malformed key." }, { status: 400 }); + } + const eventId = match[1]; const contentType = request.headers.get("content-type") ?? "application/octet-stream"; const body = Buffer.from(await request.arrayBuffer()); - if (body.length === 0) { - return Response.json({ error: "Empty upload." }, { status: 400 }); - } + const problem = validateMediaUpload(contentType, body.length); + if (problem) return Response.json({ error: problem }, { status: 400 }); - // The key embeds the event: events// - const eventId = key.split("/")[1]; - if (!eventId) { - return Response.json({ error: "Malformed key." }, { status: 400 }); - } + const user = await currentUser(); + if (!user) return Response.json({ error: "Sign in first." }, { status: 401 }); try { - await storage.saveLocal(key, body); - await registerMediaAsset({ - eventId, - userId: user.id, - key, - kind: contentType.startsWith("video/") ? "VIDEO" : "PHOTO", - }); - return Response.json({ ok: true, url: storage.publicUrlFor(key) }); + // Only organization owners/admins of the target event may write its files. + await requireOrgAdmin(eventId, user.id); + // saveLocalAt joins under public/uploads and refuses escaping keys. + const url = await storage.saveLocalAt(key, body); + return Response.json({ ok: true, url }); } catch (error) { + if (error instanceof MediaError) { + return Response.json({ error: error.message }, { status: 403 }); + } console.error("[media:dev] upload failed", error); return Response.json({ error: "Upload failed." }, { status: 500 }); } diff --git a/app/api/events/[slug]/cancel/route.ts b/app/api/events/[slug]/cancel/route.ts index 7db27b4..c23ea4e 100644 --- a/app/api/events/[slug]/cancel/route.ts +++ b/app/api/events/[slug]/cancel/route.ts @@ -1,12 +1,29 @@ import { NextResponse } from "next/server"; +import { currentUser } from "@/lib/auth/guards"; import { cancelRegistrationAction } from "@/lib/events/registration-actions"; +import { rateLimit } from "@/lib/rate-limit"; +/** + * Authenticated callers are throttled (30 mutations/hour shared across the + * mutation routes); unauthenticated requests fall through to the action, + * which keeps its existing redirect-to-sign-in behavior. + */ export async function POST( _request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; + const user = await currentUser(); + if (user) { + const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + if (!limit.ok) { + return NextResponse.json( + { error: "Too many attempts. Try again later." }, + { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } } + ); + } + } await cancelRegistrationAction(slug); return NextResponse.redirect(new URL("/dashboard/hackathons", _request.url), 303); } diff --git a/app/api/events/[slug]/register/route.ts b/app/api/events/[slug]/register/route.ts index 4d5ccb7..0557a6b 100644 --- a/app/api/events/[slug]/register/route.ts +++ b/app/api/events/[slug]/register/route.ts @@ -1,17 +1,33 @@ import { NextResponse } from "next/server"; +import { currentUser } from "@/lib/auth/guards"; import { registerForEventAction } from "@/lib/events/registration-actions"; +import { rateLimit } from "@/lib/rate-limit"; /** * Plain-form-friendly registration endpoint (P6: no client JS required). * The server action owns the logic; this handler only exists because HTML * forms cannot invoke server actions with dynamic arguments. + * + * Authenticated callers are throttled (30 mutations/hour shared across the + * mutation routes); unauthenticated requests fall through to the action, + * which keeps its existing redirect-to-sign-in behavior. */ export async function POST( _request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; + const user = await currentUser(); + if (user) { + const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + if (!limit.ok) { + return NextResponse.json( + { error: "Too many attempts. Try again later." }, + { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } } + ); + } + } await registerForEventAction(slug); return NextResponse.redirect(new URL(`/hackathons/${slug}/workspace`, _request.url), 303); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..9dd983a --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/db"; +import { getChainPort } from "@/lib/ports/chain"; +import { getPaystackPort } from "@/lib/ports/paystack"; + +/** + * Liveness/readiness probe (no auth — uptime monitors and load balancers + * poll it). Reports component status only: no versions, hosts, or secret + * presence beyond the already-public port modes. 200 when the app can serve + * real traffic (database reachable, queue schema present), 503 otherwise. + */ +export const dynamic = "force-dynamic"; + +interface ComponentHealth { + ok: boolean; + db: "ok" | "down"; + queue: "ok" | "down"; + paystackMode: string; + chainMode: string; + uptime: number; +} + +async function databaseOk(): Promise { + try { + await prisma.$queryRaw`SELECT 1`; + return true; + } catch { + return false; + } +} + +/** pg-boss creates its schema on first start; without it no job will run. */ +async function queueOk(): Promise { + try { + // ::text — Prisma cannot deserialize the regclass type from to_regclass. + const rows = await prisma.$queryRaw>` + SELECT to_regclass('pgboss.job')::text AS name + `; + return rows[0]?.name != null; + } catch { + return false; + } +} + +export async function GET(): Promise> { + const [db, queue] = await Promise.all([databaseOk(), queueOk()]); + + // Port getters never reach the network (simulation fallbacks are in-process); + // if env validation itself throws, the whole app is down — report honestly. + let paystackMode = "unknown"; + let chainMode = "unknown"; + try { + paystackMode = getPaystackPort().mode; + chainMode = getChainPort().mode; + } catch { + // Modes are informational; db/queue decide the status code. + } + + const ok = db && queue; + const body: ComponentHealth = { + ok, + db: db ? "ok" : "down", + queue: queue ? "ok" : "down", + paystackMode, + chainMode, + uptime: Math.floor(process.uptime()), + }; + return NextResponse.json(body, { status: ok ? 200 : 503 }); +} diff --git a/app/api/judge/invites/[assignmentId]/route.ts b/app/api/judge/invites/[assignmentId]/route.ts index ca8020c..5b79973 100644 --- a/app/api/judge/invites/[assignmentId]/route.ts +++ b/app/api/judge/invites/[assignmentId]/route.ts @@ -1,8 +1,15 @@ import { NextResponse } from "next/server"; +import { currentUser } from "@/lib/auth/guards"; +import { rateLimit } from "@/lib/rate-limit"; import { respondToJudgeInviteAction } from "@/services/judging/actions"; -/** Plain-form judge invite responses (no client JS required — P6). */ +/** + * Plain-form judge invite responses (no client JS required — P6). + * Authenticated callers are throttled (30 mutations/hour shared across the + * mutation routes); unauthenticated requests fall through to the action, + * which keeps its existing redirect-to-sign-in behavior. + */ export async function POST( request: Request, { params }: { params: Promise<{ assignmentId: string }> } @@ -13,6 +20,16 @@ export async function POST( if (decision !== "accept" && decision !== "decline") { return NextResponse.redirect(new URL("/judge", request.url), 303); } + const user = await currentUser(); + if (user) { + const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + if (!limit.ok) { + return NextResponse.json( + { error: "Too many attempts. Try again later." }, + { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } } + ); + } + } await respondToJudgeInviteAction(assignmentId, decision === "accept"); return NextResponse.redirect(new URL("/judge", request.url), 303); } diff --git a/app/error.tsx b/app/error.tsx index 567a311..1d2a21f 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -13,7 +13,13 @@ export default function Error({ reset: () => void; }) { useEffect(() => { - // Sentry wiring lands in Phase 9 (plan §16); console for now. + // Report to Sentry only when a public DSN is configured; the SDK is + // loaded lazily so error reporting never blocks the error UI itself. + if (process.env.NEXT_PUBLIC_SENTRY_DSN) { + void import("@sentry/nextjs") + .then((Sentry) => Sentry.captureException(error)) + .catch(() => undefined); + } console.error("[app-error]", error); }, [error]); diff --git a/app/global-error.tsx b/app/global-error.tsx index 212c3f9..972a471 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -15,6 +15,13 @@ export default function GlobalError({ reset: () => void; }) { useEffect(() => { + // Report to Sentry only when a public DSN is configured; the SDK is + // loaded lazily so error reporting never blocks the error UI itself. + if (process.env.NEXT_PUBLIC_SENTRY_DSN) { + void import("@sentry/nextjs") + .then((Sentry) => Sentry.captureException(error)) + .catch(() => undefined); + } console.error("[global-error]", error); }, [error]); diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index 737a081..0d74261 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -75,9 +75,11 @@ HackVillage ("Hack Village") is open-source infrastructure for high-impact hacka - [How it works](${appUrl("/how-it-works")}): the full lifecycle: escrowed prize, team formation, structured judging, instant payouts. - [How it works for organizers](${appUrl("/for-organizers")}): hosting a hackathon step by step: setup, verification, the 5% fee, funding the prize vault, judging, payouts, milestones and the trust score. - [How escrow works](${appUrl("/how-escrow-works")}): exactly how prize money is deposited, locked, held and paid, step by step. +- [Public ledger](${appUrl("/trust")}): every vault lock, payout and refund recorded entry by entry, with the per-hackathon vault state. - [Blog](${appUrl("/blog")}): guides and stories on escrow, payouts, judging and building hackathons builders trust. - [Contribute](${appUrl("/contribute")}): how to contribute to the open-source platform. - Developer profiles: every builder who takes part has a public Proof-of-Work profile at ${appUrl("/developers/")}. +- Organizer trust pages: every organizer has a public record at ${appUrl("/organizers/")}: verification status, trust score, payout performance and hosted hackathons. ## Current hackathons diff --git a/app/sitemap.ts b/app/sitemap.ts index 0424adc..1b2ab57 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -34,6 +34,8 @@ export default async function sitemap(): Promise { images: imagesIn("hero/kenya", "journey", "trust"), }, { url: appUrl("/hackathons"), changeFrequency: "daily", priority: 0.9 }, + // The public ledger is the trust pillar — crawlers should find it easily. + { url: appUrl("/trust"), changeFrequency: "hourly", priority: 0.8 }, { url: appUrl("/how-it-works"), changeFrequency: "weekly", @@ -72,7 +74,7 @@ export default async function sitemap(): Promise { // unreachable during a build, static and blog entries still ship and the // rest return at the next regeneration. try { - const [events, developers] = await Promise.all([ + const [events, developers, organizers] = await Promise.all([ prisma.event.findMany({ // Demo hackathons are noindex, so they don't belong in the sitemap. where: { ...PUBLIC_HACKATHON_WHERE, isDemo: false }, @@ -83,6 +85,12 @@ export default async function sitemap(): Promise { where: INDEXABLE_DEVELOPER_WHERE, select: { handle: true, email: true, updatedAt: true }, }), + // Public organizer trust pages: only organizations with at least one + // published hackathon earn a sitemap entry, matching page prominence. + prisma.organization.findMany({ + where: { events: { some: { publishedAt: { not: null } } } }, + select: { slug: true, updatedAt: true }, + }), ]); for (const event of events) { entries.push({ @@ -106,6 +114,14 @@ export default async function sitemap(): Promise { priority: 0.5, }); } + for (const organizer of organizers) { + entries.push({ + url: appUrl(`/organizers/${organizer.slug}`), + lastModified: organizer.updatedAt, + changeFrequency: "weekly", + priority: 0.6, + }); + } } catch (error) { // Keep the sitemap useful with what we have, but say why it's short. console.error("[sitemap] database sections skipped", error); diff --git a/components/admin/event-cancel-form.tsx b/components/admin/event-cancel-form.tsx new file mode 100644 index 0000000..7d1ea89 --- /dev/null +++ b/components/admin/event-cancel-form.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Ban } from "lucide-react"; + +import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action"; +import { Input, Label } from "@/components/ui/input"; +import { adminCancelEventAction } from "@/lib/admin/event-actions"; + +/** + * Admin cancels an event with a reason (the organizer sees it). Calls Agent + * C's contract `adminCancelEventAction(eventId, reason)` from + * lib/admin/event-actions.ts. + */ +export function AdminEventCancelForm({ + eventId, + eventTitle, +}: { + eventId: string; + eventTitle: string; +}) { + return ( + + Cancel + + } + triggerVariant="ghost" + triggerSize="sm" + onConfirm={async (formData) => { + const reason = String(formData.get("reason") ?? "").trim(); + if (reason.length < 4) { + return { error: "Give a reason (at least 4 characters) — the organizer sees it." }; + } + return adminCancelEventAction(eventId, reason); + }} + > +
+ + +
+
+ ); +} diff --git a/components/admin/payout-ops-actions.tsx b/components/admin/payout-ops-actions.tsx index 289425e..734108d 100644 --- a/components/admin/payout-ops-actions.tsx +++ b/components/admin/payout-ops-actions.tsx @@ -1,23 +1,22 @@ "use client"; -import { useActionState, useState, useTransition } from "react"; +import { useState, useTransition } from "react"; import { RotateCcw } from "lucide-react"; +import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action"; import { Button } from "@/components/ui/button"; -import { FormError, FormSuccess, Input } from "@/components/ui/input"; -import { - adminMarkPaidAction, - adminRetryPayoutAction, - type PayoutActionState, -} from "@/services/payout/actions"; +import { Input, Label } from "@/components/ui/input"; +import { adminMarkPaidAction, adminRetryPayoutAction } from "@/services/payout/actions"; -export function PayoutOpsActions({ payoutId }: { payoutId: string }) { +export function PayoutOpsActions({ + payoutId, + amountKes, +}: { + payoutId: string; + amountKes: number; +}) { const [retry, startRetry] = useTransition(); const [retryError, setRetryError] = useState(null); - const [markState, markPaid, marking] = useActionState( - adminMarkPaidAction, - {} - ); return (
@@ -43,24 +42,36 @@ export function PayoutOpsActions({ payoutId }: { payoutId: string }) { ) : null}
-
+ { + const receipt = String(formData.get("receipt") ?? "").trim(); + if (receipt.length < 6) { + return { error: "Paste the payment receipt reference (at least 6 characters)." }; + } + return adminMarkPaidAction({}, formData); + }} + > -
+
+
- - - - +
); } diff --git a/components/events/event-wizard.tsx b/components/events/event-wizard.tsx index dc7c34f..2a1ecfd 100644 --- a/components/events/event-wizard.tsx +++ b/components/events/event-wizard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState, useState } from "react"; +import { useActionState, useRef, useState } from "react"; import { ArrowLeft, ArrowRight, Plus, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -9,6 +9,14 @@ import { Card } from "@/components/ui/card"; import { FormError, Input, Label, Textarea } from "@/components/ui/input"; import { HACKATHON_CATEGORIES, MAX_CATEGORIES } from "@/lib/events/categories"; import { COVER_HEIGHT, COVER_WIDTH } from "@/lib/events/cover-upload"; +import { + eventBasicsSchema, + eventProblemSchema, + eventPrizesSchema, + eventSettingsSchema, + eventWizardSchema, + placesAreUnique, +} from "@/lib/events/validation"; import { formatKes } from "@/lib/utils"; import { saveEventAction, type EventActionState } from "@/lib/events/actions"; @@ -38,6 +46,23 @@ interface PrizeRow { const STEPS = ["Basics", "Problem", "Prizes", "Teams", "Review"] as const; +/** Which step a schema field lives on — validation failures jump back to it. */ +const STEP_FOR_FIELD: Record = { + title: 0, + summary: 0, + venueType: 0, + location: 0, + startsAt: 0, + endsAt: 0, + registrationDeadline: 0, + categories: 0, + problemStatement: 1, + rules: 1, + rolesWanted: 1, + prizes: 2, + maxTeams: 3, +}; + function toLocalInput(date?: Date): string { if (!date) return ""; return new Date(date.getTime() - date.getTimezoneOffset() * 60_000) @@ -48,6 +73,8 @@ function toLocalInput(date?: Date): string { export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefaults; minPoolKes: number }) { const [state, action, pending] = useActionState(saveEventAction, {}); const [step, setStep] = useState(0); + const [stepError, setStepError] = useState(null); + const formRef = useRef(null); const [venueType, setVenueType] = useState<"PHYSICAL" | "ONLINE" | "HYBRID">( defaults?.venueType ?? "PHYSICAL" ); @@ -82,6 +109,115 @@ export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefault const visible = (index: number) => (step === index ? "block" : "hidden"); + /** + * The steps hide instead of unmounting, so the answers persist across + * steps — and the form is noValidate, because a required input inside a + * display:none step can silently block the final submit. Validation runs + * per step (and again on submit) through the same zod schemas the server + * uses. + */ + const fieldValue = (name: string): string => { + const element = formRef.current?.elements.namedItem(name); + return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement + ? element.value + : ""; + }; + + const completePrizes = () => + prizes + .filter((p) => p.label.trim() && Number(p.amount) > 0) + .map((p) => ({ + place: p.place, + label: p.label.trim(), + amountKes: Number(p.amount), + milestoneRequired: p.milestoneRequired, + })); + + /** First problem on a step, or null when the step passes the server's schema. */ + const validateStep = (index: number): string | null => { + switch (index) { + case 0: { + if (!fieldValue("registrationDeadline") || !fieldValue("startsAt") || !fieldValue("endsAt")) { + return "Set the registration deadline, start, and end dates."; + } + const parsed = eventBasicsSchema.safeParse({ + title: fieldValue("title"), + summary: fieldValue("summary"), + venueType, + location: fieldValue("location"), + startsAt: fieldValue("startsAt"), + endsAt: fieldValue("endsAt"), + registrationDeadline: fieldValue("registrationDeadline"), + categories: categories.join(","), + }); + if (!parsed.success) return parsed.error.issues[0]?.message ?? "Check the basics."; + const { startsAt, endsAt, registrationDeadline } = parsed.data; + if (!(registrationDeadline < startsAt)) { + return "Registration must close before the hackathon starts."; + } + if (!(startsAt < endsAt)) return "The hackathon must end after it starts."; + return null; + } + case 1: { + const parsed = eventProblemSchema.safeParse({ + problemStatement: fieldValue("problemStatement"), + rules: fieldValue("rules") || undefined, + rolesWanted: fieldValue("rolesWanted") || undefined, + }); + return parsed.success ? null : (parsed.error.issues[0]?.message ?? "Check the problem."); + } + case 2: { + const halfFilled = prizes.some( + (p) => Boolean(p.label.trim()) !== Boolean(Number(p.amount) > 0) + ); + if (halfFilled) return "Finish or remove the half-filled prize row: label and KES amount."; + const parsed = eventPrizesSchema.safeParse({ prizes: completePrizes() }); + if (!parsed.success) return parsed.error.issues[0]?.message ?? "Check the prizes."; + if (!placesAreUnique(parsed.data.prizes)) { + return "Prize places must be unique: 1st, 2nd, 3rd…"; + } + return null; + } + case 3: { + const parsed = eventSettingsSchema.safeParse({ maxTeams: fieldValue("maxTeams") }); + return parsed.success ? null : (parsed.error.issues[0]?.message ?? "Check the team settings."); + } + default: + return null; + } + }; + + /** Whole-form check before the real submit; jumps to the failing step. */ + const validateAll = (): { error: string; step: number } | null => { + const parsed = eventWizardSchema.safeParse({ + title: fieldValue("title"), + summary: fieldValue("summary"), + venueType, + location: fieldValue("location"), + startsAt: fieldValue("startsAt"), + endsAt: fieldValue("endsAt"), + registrationDeadline: fieldValue("registrationDeadline"), + categories: categories.join(","), + problemStatement: fieldValue("problemStatement"), + rules: fieldValue("rules") || undefined, + rolesWanted: fieldValue("rolesWanted") || undefined, + prizes: completePrizes(), + maxTeams: fieldValue("maxTeams"), + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const field = String(issue?.path[0] ?? ""); + return { + error: issue?.message ?? "Check the hackathon details and try again.", + step: STEP_FOR_FIELD[field] ?? 0, + }; + } + if (!placesAreUnique(parsed.data.prizes)) { + return { error: "Prize places must be unique: 1st, 2nd, 3rd…", step: 2 }; + } + return null; + }; + return (
{/* Stepper */} @@ -102,24 +238,26 @@ export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefault ))} -
+ { + const problem = validateAll(); + if (problem) { + event.preventDefault(); + setStep(problem.step); + setStepError(problem.error); + } else { + setStepError(null); + } + }} + className="space-y-6" + > - p.label.trim() && Number(p.amount) > 0) - .map((p) => ({ - place: p.place, - label: p.label.trim(), - amountKes: Number(p.amount), - milestoneRequired: p.milestoneRequired, - })) - )} - /> + {/* Step 1 — Basics */}
@@ -259,7 +397,7 @@ export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefault type="checkbox" checked={prize.milestoneRequired} onChange={(e) => setPrize(index, { milestoneRequired: e.target.checked })} - className="size-3.5 accent-[#222]" + className="size-3.5 accent-ink" /> Milestone required before the final 50% releases @@ -315,19 +453,34 @@ export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefault
+
{step < STEPS.length - 1 ? ( - ) : ( diff --git a/components/judging/scoring-screen.tsx b/components/judging/scoring-screen.tsx index 8f3b97a..83af6c3 100644 --- a/components/judging/scoring-screen.tsx +++ b/components/judging/scoring-screen.tsx @@ -49,6 +49,14 @@ export function ScoringScreen({ ); const [finalizeState, setFinalizeState] = useState(null); const [pending, startTransition] = useTransition(); + // Scores the judge explicitly moved this session. Sliders never pre-fill a + // fabricated value: a criterion counts as scored only when it already has a + // saved score or the judge touched its slider. + const [touchedScores, setTouchedScores] = useState>({}); + const valueFor = (criterionId: string): number | undefined => + touchedScores[criterionId] ?? existingScores[criterionId]; + const unscoredCount = criteria.filter((criterion) => valueFor(criterion.id) === undefined).length; + const allScored = unscoredCount === 0; const byKind = (kind: FeedbackKind) => feedback.filter((f) => f.kind === kind); const locked = finalized || !judgingOpen; @@ -60,46 +68,67 @@ export function ScoringScreen({ Rubric Scoring: {teamName} - Whole numbers 0-10 per criterion. Save as often as you like; finalization locks - everything. + Whole numbers 0-10 per criterion. Move every slider to score it, even if you keep the + middle value. Save as often as you like; finalization locks everything.
- {criteria.map((criterion) => ( -
- -
- - - {existingScores[criterion.id] ?? 5} - + {criteria.map((criterion) => { + const value = valueFor(criterion.id); + return ( +
+ +
+ + setTouchedScores((current) => ({ + ...current, + [criterion.id]: Number(event.target.value), + })) + } + disabled={locked} + className="h-2 flex-1 accent-ink" + /> + + {value ?? "–"} + +
-
- ))} + ); + })}
{!locked ? ( - +
+ + {!allScored ? ( +

+ {unscoredCount} {unscoredCount === 1 ? "criterion" : "criteria"} still unscored — + move every slider to enable saving. +

+ ) : null} +
) : null} diff --git a/components/media/media-uploader.tsx b/components/media/media-uploader.tsx index 711525f..5c0d6d8 100644 --- a/components/media/media-uploader.tsx +++ b/components/media/media-uploader.tsx @@ -6,12 +6,17 @@ import { Image as ImageIcon, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { FormError } from "@/components/ui/input"; -import { requestUploadUrlAction, type MediaActionState } from "@/services/media/actions"; +import { + confirmMediaUploadAction, + requestUploadUrlAction, + type MediaActionState, +} from "@/services/media/actions"; /** * The media vault uploader: requests the upload target, PUTs the file (or - * POSTs to the dev route locally), then refreshes. Simple, no chunking — - * files are capped at 15MB by the port. + * POSTs to the dev route locally), then CONFIRMS the upload — the MediaAsset + * row only exists once the file is verifiably in storage. Simple, no + * chunking — files are capped at 15MB by the port. */ export function MediaUploader({ eventId }: { eventId: string }) { const [state, action, pending] = useActionState( @@ -19,39 +24,52 @@ export function MediaUploader({ eventId }: { eventId: string }) { {} ); const [uploading, setUploading] = useState(null); + const [error, setError] = useState(null); const formRef = useRef(null); async function handleFile(formData: FormData, file: File) { + setError(null); formData.set("filename", file.name); formData.set("contentType", file.type); formData.set("sizeBytes", String(file.size)); const targetState = await requestUploadUrlAction({}, formData); - if (targetState.error) return; - if (!targetState.uploadUrl) return; + if (targetState.error || !targetState.uploadUrl || !targetState.key) { + // Was previously swallowed — the uploader failed silently. + setError(targetState.error ?? "We couldn't prepare the upload. Try again."); + return; + } setUploading(file.name); try { - if (targetState.message === "dev-upload") { - // Dev mode: POST the raw file to our route. - await fetch(targetState.uploadUrl, { - method: "POST", - headers: { "Content-Type": file.type }, - body: file, - }); - } else { - // R2 mode: PUT directly with the presigned headers. - await fetch(targetState.uploadUrl, { - method: "PUT", - headers: targetState.uploadHeaders ?? { "Content-Type": file.type }, - body: file, - }); + const response = await fetch(targetState.uploadUrl, { + method: targetState.uploadUrl.includes("/api/dev/media/upload/") ? "POST" : "PUT", + headers: targetState.uploadHeaders ?? { "Content-Type": file.type }, + body: file, + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + setError(body?.error ?? "The upload didn't complete. Try again."); + return; + } + + // The row is created only now, after the file verifiably landed. + const confirmed = await confirmMediaUploadAction({ + eventId, + key: targetState.key, + kind: file.type.startsWith("video/") ? "VIDEO" : "PHOTO", + caption: String(formData.get("caption") ?? "") || undefined, + }); + if (confirmed.error) { + setError(confirmed.error); + return; } - // Registration for R2 mode happened in the action; dev mode registers - // in the route. Either way, reload to show the gallery. + window.location.reload(); - } catch (error) { - console.error("[media] upload failed", error); + } catch (uploadError) { + // Was previously a console.error only — the UI gave no signal. + console.error("[media] upload failed", uploadError); + setError("The upload didn't complete. Check your connection and try again."); } finally { setUploading(null); } @@ -99,7 +117,7 @@ export function MediaUploader({ eventId }: { eventId: string }) { required /> - + diff --git a/components/organizer/event-cancel-button.tsx b/components/organizer/event-cancel-button.tsx new file mode 100644 index 0000000..2e2fe4c --- /dev/null +++ b/components/organizer/event-cancel-button.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Ban } from "lucide-react"; + +import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action"; +import { cancelEventAction } from "@/lib/events/actions"; + +export function EventCancelButton({ + eventId, + eventTitle, +}: { + eventId: string; + eventTitle: string; +}) { + return ( + + Cancel Event + + } + triggerVariant="ghost" + triggerSize="sm" + onConfirm={() => cancelEventAction(eventId)} + /> + ); +} diff --git a/components/organizer/publish-gate.tsx b/components/organizer/publish-gate.tsx index 34c781b..5d783e0 100644 --- a/components/organizer/publish-gate.tsx +++ b/components/organizer/publish-gate.tsx @@ -23,7 +23,7 @@ export function PublishGate({ eventId }: { eventId: string }) { Publishing makes the event publicly visible as pending deposit. The - event goes live when the prize pool is locked in the Prize Vault (Phase 3). + event goes live when the prize pool is locked in the Prize Vault. {error ? (

diff --git a/components/patterns/app-nav-link.tsx b/components/patterns/app-nav-link.tsx new file mode 100644 index 0000000..eb2e16b --- /dev/null +++ b/components/patterns/app-nav-link.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +import { isActivePath } from "@/lib/nav"; +import { cn } from "@/lib/utils"; + +/** + * App chrome nav link: marks itself aria-current="page" when it points at the + * page being viewed, and the active styling keys off that state so what you + * see and what screen readers announce can never disagree. /dashboard matches + * exactly (its siblings live under it); other links also match sub-pages. + */ +export function AppNavLink({ href, children }: { href: string; children: ReactNode }) { + const pathname = usePathname(); + const active = href === "/dashboard" ? pathname === href : isActivePath(pathname, href); + return ( + + {children} + + ); +} diff --git a/components/patterns/app-nav.tsx b/components/patterns/app-nav.tsx index d39f213..2afc2bd 100644 --- a/components/patterns/app-nav.tsx +++ b/components/patterns/app-nav.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; +import { AppNavLink } from "@/components/patterns/app-nav-link"; import { UserMenu } from "@/components/patterns/user-menu"; import { ThemeSwitcher } from "@/components/patterns/theme-switcher"; import { cn } from "@/lib/utils"; @@ -54,13 +55,9 @@ export default function AppTopNav({ className="flex min-w-0 items-center gap-1 overflow-x-auto py-2 sm:gap-4" > {links.map((link) => ( - + {link.label} - + ))} diff --git a/components/patterns/confirm-money-action.tsx b/components/patterns/confirm-money-action.tsx new file mode 100644 index 0000000..89104ac --- /dev/null +++ b/components/patterns/confirm-money-action.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { + useEffect, + useRef, + useState, + useTransition, + type FormEvent, + type ReactNode, +} from "react"; + +import { Button } from "@/components/ui/button"; +import { FormError, FormSuccess, Input, Label } from "@/components/ui/input"; +import { formatKes } from "@/lib/utils"; + +/** Money-movement typed confirmation threshold (plan §8.2). */ +export const MONEY_CONFIRM_THRESHOLD_KES = 250_000; + +export interface ConfirmMoneyResult { + error?: string; + message?: string; +} + +/** Above the threshold the user must type the confirm word; at or below, a plain confirm suffices. */ +export function typedConfirmRequired( + amountKes: number, + thresholdKes: number = MONEY_CONFIRM_THRESHOLD_KES +): boolean { + return amountKes > thresholdKes; +} + +/** + * The money-action confirm gate (plan §8.2): every action that moves money + * passes through a confirmation dialog, and above the KES 250k threshold the + * user types a confirm word. The server still re-verifies everything — this + * is the human tripwire, not the enforcement. + * + * Renders the trigger button; the dialog carries any extra fields (children) + * and submits them as FormData to onConfirm. + */ +export function ConfirmMoneyAction({ + amountKes, + thresholdKes = MONEY_CONFIRM_THRESHOLD_KES, + confirmWord = "CONFIRM", + title, + description, + confirmLabel = "Confirm", + triggerLabel, + triggerVariant = "primary", + triggerSize = "md", + triggerDisabled = false, + className, + children, + onConfirm, +}: { + /** Money at stake in KES; 0 keeps the dialog but never asks for typed confirmation. */ + amountKes: number; + thresholdKes?: number; + confirmWord?: string; + title: string; + description?: ReactNode; + confirmLabel?: string; + triggerLabel: ReactNode; + triggerVariant?: "primary" | "secondary" | "ghost" | "danger"; + triggerSize?: "sm" | "md" | "lg"; + triggerDisabled?: boolean; + className?: string; + children?: ReactNode; + onConfirm: (formData: FormData) => Promise; +}) { + const [open, setOpen] = useState(false); + const [typed, setTyped] = useState(""); + const [result, setResult] = useState({}); + const [pending, startTransition] = useTransition(); + const formRef = useRef(null); + + const needsTyping = typedConfirmRequired(amountKes, thresholdKes); + const typedOk = !needsTyping || typed === confirmWord; + + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open]); + + const openDialog = () => { + setTyped(""); + setResult({}); + setOpen(true); + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!typedOk || !formRef.current) return; + const formData = new FormData(formRef.current); + startTransition(async () => { + const outcome = await onConfirm(formData); + setResult(outcome); + if (!outcome.error) setOpen(false); + }); + }; + + return ( +

+ + {!open && result.message ? : null} + + {open ? ( +
{ + if (event.target === event.currentTarget) setOpen(false); + }} + > +
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} + {amountKes > 0 ? ( +

{formatKes(amountKes)}

+ ) : null} + +
+ {children} + {needsTyping ? ( +
+ + setTyped(event.target.value.toUpperCase())} + placeholder={confirmWord} + className="font-mono" + autoFocus + autoComplete="off" + /> +
+ ) : null} + + + +
+ + +
+ +
+
+ ) : null} +
+ ); +} diff --git a/components/patterns/event-card.tsx b/components/patterns/event-card.tsx index 691f552..302d075 100644 --- a/components/patterns/event-card.tsx +++ b/components/patterns/event-card.tsx @@ -2,11 +2,12 @@ import Image from "next/image"; import Link from "next/link"; import { ArrowUpRight, CalendarDays, MapPin, Users } from "lucide-react"; +import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge"; import { buttonVariants } from "@/components/ui/button"; import { categoryLabel, isCategory } from "@/lib/events/categories"; import { coverFor } from "@/lib/events/covers"; import { formatEventDates, formatShortDate } from "@/lib/events/format"; -import { registrationOpen, type EventStatus } from "@/lib/events/lifecycle"; +import { isPrizeVerified, registrationOpen, type EventStatus } from "@/lib/events/lifecycle"; import { cn, formatKes } from "@/lib/utils"; export interface EventCardData { @@ -19,6 +20,7 @@ export interface EventCardData { endsAt: Date; registrationDeadline: Date; publishedAt?: Date | null; + prizeVerifiedAt?: Date | string | null; status: EventStatus; poolKes: number; teamCount: number; @@ -48,6 +50,10 @@ export function EventCard({ priority?: boolean; }) { const open = registrationOpen(event); + const verified = isPrizeVerified( + event.status, + event.prizeVerifiedAt ? new Date(event.prizeVerifiedAt) : null + ); const categories = event.categories.filter(isCategory); const cover = coverFor(event); @@ -82,6 +88,7 @@ export function EventCard({ ))} ) : null} + {verified ? : null}
diff --git a/components/payout/milestone-confirmer.tsx b/components/payout/milestone-confirmer.tsx index 93becef..1d479b3 100644 --- a/components/payout/milestone-confirmer.tsx +++ b/components/payout/milestone-confirmer.tsx @@ -1,48 +1,39 @@ "use client"; -import { useState, useTransition } from "react"; import { Handshake } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action"; import { confirmMilestoneAction } from "@/services/payout/actions"; -export function MilestoneConfirmer({ winnerId }: { winnerId: string }) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(null); - const [message, setMessage] = useState(null); - +/** + * Releases the final 50% for a winner. The handover used to be a single + * unconfirmed click; now it passes through the money-action confirm gate + * (typed confirmation above the KES 250k threshold, keyed on the milestone + * amount). + */ +export function MilestoneConfirmer({ + winnerId, + amountKes, +}: { + winnerId: string; + amountKes: number; +}) { return ( -
- {error ? ( -

- {error} -

- ) : null} - {message ? ( -

- {message} -

- ) : null} - -
+ + Confirm handover, release final 50% + + } + triggerVariant="secondary" + triggerSize="sm" + onConfirm={() => confirmMilestoneAction(winnerId)} + /> ); } diff --git a/components/payout/winners-announcer.tsx b/components/payout/winners-announcer.tsx index e1e146a..35eb35e 100644 --- a/components/payout/winners-announcer.tsx +++ b/components/payout/winners-announcer.tsx @@ -1,12 +1,12 @@ "use client"; -import { useActionState, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Trophy } from "lucide-react"; +import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardTitle } from "@/components/ui/card"; -import { FormError, FormSuccess, Input, Label } from "@/components/ui/input"; +import { FormError, FormSuccess, Label } from "@/components/ui/input"; import { announceWinnersAction, type PayoutActionState } from "@/services/payout/actions"; import { formatKes } from "@/lib/utils"; @@ -29,7 +29,7 @@ interface TeamOption { /** * The announcement console (plan §8.2): map judged teams onto prize places, * review the money one last time, confirm. Typed confirmation above the - * KES 250k threshold; the server re-verifies everything. + * KES 250k threshold (ConfirmMoneyAction); the server re-verifies everything. */ export function WinnersAnnouncer({ eventId, @@ -42,13 +42,8 @@ export function WinnersAnnouncer({ teams: TeamOption[]; poolKes: number; }) { - const [state, action, pending] = useActionState( - announceWinnersAction, - {} - ); + const [state, setState] = useState({}); const [selection, setSelection] = useState>({}); - const typedConfirmNeeded = poolKes > 250_000; - const [typed, setTyped] = useState(""); const rankedTeams = useMemo( () => [...teams].sort((a, b) => (b.score ?? -1) - (a.score ?? -1)), @@ -63,16 +58,19 @@ export function WinnersAnnouncer({ .map((p) => teams.find((t) => t.teamId === p.teamId)) .filter((t) => t && !t.leaderHasRecipient); - const ready = - placements.length === prizes.length && - missingRecipients.length === 0 && - (!typedConfirmNeeded || typed === "ANNOUNCE"); + const ready = placements.length === prizes.length && missingRecipients.length === 0; - return ( -
- - + const announce = async (): Promise => { + const formData = new FormData(); + formData.set("eventId", eventId); + formData.set("placements", JSON.stringify(placements)); + const outcome = await announceWinnersAction({}, formData); + setState(outcome); + return outcome; + }; + return ( +
Announce Winners & Pay 50% @@ -127,28 +125,21 @@ export function WinnersAnnouncer({

) : null} - {typedConfirmNeeded ? ( -
- - setTyped(event.target.value.toUpperCase())} - placeholder="ANNOUNCE" - className="font-mono" - /> -
- ) : null} - - +
- +
); } diff --git a/components/profile/edit-profile-form.tsx b/components/profile/edit-profile-form.tsx index 7bae884..01dfec3 100644 --- a/components/profile/edit-profile-form.tsx +++ b/components/profile/edit-profile-form.tsx @@ -108,7 +108,7 @@ export function EditProfileForm({ handle, defaults }: { handle: string; defaults name="visible" checked={visible} onChange={(event) => setVisible(event.target.checked)} - className="size-4 accent-[#222]" + className="size-4 accent-ink" /> Public profile. Uncheck to hide yourself from developer browse. diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 0000000..6c98ec5 --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,28 @@ +import type { HTMLAttributes } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * The loading-state primitive (plan §8.4 four-state model): a pulsing block + * that mirrors the shape of the content it stands in for. Always aria-hidden — + * the route's loading.tsx provides the accessible "loading" context. + */ +export function Skeleton({ className, ...props }: HTMLAttributes) { + return ( +
+ ); +} + +/** A card-shaped skeleton matching components/ui/card.tsx. */ +export function SkeletonCard({ className, ...props }: HTMLAttributes) { + return ( +
+ ); +} diff --git a/contracts/PrizeVault.sol b/contracts/PrizeVault.sol index ee972e7..95585c3 100644 --- a/contracts/PrizeVault.sol +++ b/contracts/PrizeVault.sol @@ -12,6 +12,11 @@ import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol" * instantly, and that milestones settled. Every deposit and payout * recorded here carries the off-chain payment reference for * cross-verification on /trust. + * + * @dev Roles: the hot attester key holds ONLY ATTESTER_ROLE — never admin. + * DEFAULT_ADMIN_ROLE belongs to the factory (the vault's deployer), which + * uses it for key ceremonies: a compromised hot key is rotated on-chain + * via `PrizeVaultFactory.rotateAttester` — no redeploy, no ledger fork. */ contract PrizeVault is AccessControl { // ── Roles ──────────────────────────────────────────────────────────── @@ -59,11 +64,11 @@ contract PrizeVault is AccessControl { amountKes = _amountKes; state = State.AWAITING; - // The attester rotates by re-deploying (immutable contract — plan §11.4): - // the factory holds DEFAULT_ADMIN on child vaults for key ceremonies. + // The factory (msg.sender) holds DEFAULT_ADMIN_ROLE so it can run key + // ceremonies (rotateAttester) on this vault. The hot attester key gets + // ATTESTER_ROLE and nothing more — least privilege for an env-held key. _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); _grantRole(ATTESTER_ROLE, attester); - _grantRole(DEFAULT_ADMIN_ROLE, attester); } /// @notice Record the locked deposit once the pool webhook is confirmed. diff --git a/contracts/PrizeVaultFactory.sol b/contracts/PrizeVaultFactory.sol index 7e1134a..3562284 100644 --- a/contracts/PrizeVaultFactory.sol +++ b/contracts/PrizeVaultFactory.sol @@ -9,7 +9,9 @@ import { PrizeVault } from "./PrizeVault.sol"; * @notice The factory address is the one pinned in SMART_CONTRACT_ADDRESS. * Only holders of ATTESTER_ROLE may create vaults; the child vault * receives its own ATTESTER grant for the runtime signer (the - * platform key — env-only, per SECURITY.md). + * platform key — env-only, per SECURITY.md). The factory holds + * DEFAULT_ADMIN_ROLE on every vault it creates so a compromised hot + * key can be rotated on-chain (see rotateAttester). */ contract PrizeVaultFactory is AccessControl { bytes32 public constant ATTESTER_ROLE = keccak256("ATTESTER_ROLE"); @@ -17,6 +19,7 @@ contract PrizeVaultFactory is AccessControl { mapping(string => address) public eventToVault; event VaultCreated(string eventId, address indexed vault, uint256 amountKes, address indexed attester); + event AttesterRotated(address indexed vault, address indexed oldAttester, address indexed newAttester); constructor() { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); @@ -42,4 +45,34 @@ contract PrizeVaultFactory is AccessControl { function vaultFor(string calldata eventId) external view returns (address) { return eventToVault[eventId]; } + + /** + * @notice Rotate the attester key on a vault this factory created. + * @dev Runbook — hot-key compromise: + * 1. A factory admin (cold key) calls rotateAttester(vault, old, new). + * 2. The vault grants ATTESTER_ROLE to the new key, then revokes it + * from the old one — the old key is dead on-chain immediately, + * with no redeploy and no ledger fork. Grant happens before + * revoke so the vault is never left without an attester. + * The factory can do this because every PrizeVault grants it + * DEFAULT_ADMIN_ROLE at construction; the hot attester key itself + * never holds admin and cannot perform this rotation. + */ + function rotateAttester( + address vault, + address oldAttester, + address newAttester + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(vault != address(0), "Factory: zero vault"); + require(newAttester != address(0), "Factory: zero new attester"); + require(oldAttester != newAttester, "Factory: same attester"); + + // The vault's ATTESTER_ROLE is the same constant (keccak256 of the + // same string); the external grantRole/revokeRole calls are + // authorized by the factory's DEFAULT_ADMIN_ROLE on the vault. + PrizeVault(vault).grantRole(ATTESTER_ROLE, newAttester); + PrizeVault(vault).revokeRole(ATTESTER_ROLE, oldAttester); + + emit AttesterRotated(vault, oldAttester, newAttester); + } } diff --git a/contracts/artifacts/contracts/PrizeVault.sol/PrizeVault.dbg.json b/contracts/artifacts/contracts/PrizeVault.sol/PrizeVault.dbg.json index 6f7ba47..a6f51fc 100644 --- a/contracts/artifacts/contracts/PrizeVault.sol/PrizeVault.dbg.json +++ b/contracts/artifacts/contracts/PrizeVault.sol/PrizeVault.dbg.json @@ -1,4 +1,4 @@ { "_format": "hh-sol-dbg-1", - "buildInfo": "../../build-info/df02a330a00364d2c848195a185ae999.json" + "buildInfo": "../../build-info/d4e4d3c7fd895d870b900a329498af84.json" } diff --git a/contracts/artifacts/contracts/PrizeVault.sol/PrizeVault.json b/contracts/artifacts/contracts/PrizeVault.sol/PrizeVault.json index 96a96f5..e9218b8 100644 --- a/contracts/artifacts/contracts/PrizeVault.sol/PrizeVault.json +++ b/contracts/artifacts/contracts/PrizeVault.sol/PrizeVault.json @@ -576,8 +576,8 @@ "type": "function" } ], - "bytecode": "0x60806040523480156200001157600080fd5b50604051620013533803806200135383398101604081905262000034916200027a565b60008351116200008b5760405162461bcd60e51b815260206004820152601460248201527f5661756c743a20656d707479206576656e74496400000000000000000000000060448201526064015b60405180910390fd5b60008211620000d05760405162461bcd60e51b815260206004820152601060248201526f15985d5b1d0e881e995c9bc81c1bdbdb60821b604482015260640162000082565b6001600160a01b038116620001285760405162461bcd60e51b815260206004820152601460248201527f5661756c743a207a65726f206174746573746572000000000000000000000000604482015260640162000082565b6002620001368482620003fc565b5060038290556001805460ff191690556200015360003362000198565b50620001807fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa98262000198565b506200018e60008262000198565b50505050620004c8565b6000828152602081815260408083206001600160a01b038516845290915281205460ff166200023d576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055620001f43390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a450600162000241565b5060005b92915050565b634e487b7160e01b600052604160045260246000fd5b80516001600160a01b03811681146200027557600080fd5b919050565b6000806000606084860312156200029057600080fd5b83516001600160401b0380821115620002a857600080fd5b818601915086601f830112620002bd57600080fd5b815181811115620002d257620002d262000247565b604051601f8201601f19908116603f01168101908382118183101715620002fd57620002fd62000247565b816040528281526020935089848487010111156200031a57600080fd5b600091505b828210156200033e57848201840151818301850152908301906200031f565b6000848483010152809750505050808601519350505062000362604085016200025d565b90509250925092565b600181811c908216806200038057607f821691505b602082108103620003a157634e487b7160e01b600052602260045260246000fd5b50919050565b601f821115620003f7576000816000526020600020601f850160051c81016020861015620003d25750805b601f850160051c820191505b81811015620003f357828155600101620003de565b5050505b505050565b81516001600160401b0381111562000418576200041862000247565b62000430816200042984546200036b565b84620003a7565b602080601f8311600181146200046857600084156200044f5750858301515b600019600386901b1c1916600185901b178555620003f3565b600085815260208120601f198616915b82811015620004995788860151825594840194600190910190840162000478565b5085821015620004b85787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b610e7b80620004d86000396000f3fe608060405234801561001057600080fd5b50600436106101375760003560e01c80636b1024ee116100b8578063b21634821161007c578063b21634821461025e578063c19d93fb14610267578063d547741f14610281578063edd6b5c314610294578063f709b5c31461029d578063fa8f99de146102b057600080fd5b80636b1024ee1461021e5780637249fbb61461022757806391d148541461023a578063a217fddf1461024d578063a99a67e61461025557600080fd5b8063248a9ca3116100ff578063248a9ca3146101b85780632728f333146101db5780632f2ff15d146101e35780632f81bb58146101f657806336568abe1461020b57600080fd5b806301670ba91461013c57806301ffc9a7146101515780630422f17c146101795780630827d05e1461018c578063089cdd0c146101a3575b600080fd5b61014f61014a366004610b2c565b6102b9565b005b61016461015f366004610b45565b6103b0565b60405190151581526020015b60405180910390f35b61014f610187366004610b76565b6103e7565b61019560065481565b604051908152602001610170565b6101ab610528565b6040516101709190610ba2565b6101956101c6366004610b2c565b60009081526020819052604090206001015490565b6101ab6105b6565b61014f6101f1366004610bf1565b6106e0565b610195600080516020610e2683398151915281565b61014f610219366004610bf1565b61070b565b61019560045481565b61014f610235366004610b2c565b610743565b610164610248366004610bf1565b61083b565b610195600081565b61019560075481565b61019560055481565b6001546102749060ff1681565b6040516101709190610c43565b61014f61028f366004610bf1565b610864565b61019560085481565b61014f6102ab366004610b76565b610889565b61019560035481565b6102d1600080516020610e268339815191523361083b565b6102f65760405162461bcd60e51b81526004016102ed90610c6b565b60405180910390fd5b600060015460ff16600481111561030f5761030f610c2d565b1461035c5760405162461bcd60e51b815260206004820152601b60248201527f5661756c743a206e6f74206177616974696e67206465706f736974000000000060448201526064016102ed565b6001805460ff1916811790554260058190556003546040517fdce4f63a820a70db387211b8a0b55c3370c291792cdbc8036cf973441d575fc4926103a592600292869290610d77565b60405180910390a150565b60006001600160e01b03198216637965db0b60e01b14806103e157506301ffc9a760e01b6001600160e01b03198316145b92915050565b6103ff600080516020610e268339815191523361083b565b61041b5760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561043357610433610c2d565b146104745760405162461bcd60e51b815260206004820152601160248201527015985d5b1d0e881b9bdd081b1bd8dad959607a1b60448201526064016102ed565b600082116104b95760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b6001805460ff1916600217905542600655600480548391906000906104df908490610da6565b90915550506040517f511f53603374f23d68700b47cdb96506ca244987097c55aed250d30c4b01ed119061051b90600290869086908690610d77565b60405180910390a1505050565b6002805461053590610c99565b80601f016020809104026020016040519081016040528092919081815260200182805461056190610c99565b80156105ae5780601f10610583576101008083540402835291602001916105ae565b820191906000526020600020905b81548152906001019060200180831161059157829003601f168201915b505050505081565b6060600060015460ff1660048111156105d1576105d1610c2d565b036105f957506040805180820190915260088152674157414954494e4760c01b602082015290565b6001805460ff16600481111561061157610611610c2d565b0361063757506040805180820190915260068152651313d0d2d15160d21b602082015290565b600260015460ff16600481111561065057610650610c2d565b0361067d575060408051808201909152600d81526c1210531197d49153115054d151609a1b602082015290565b600360015460ff16600481111561069657610696610c2d565b036106bd575060408051808201909152600781526614d1551513115160ca1b602082015290565b50604080518082019091526008815267149151955391115160c21b602082015290565b6000828152602081905260409020600101546106fb816109e5565b61070583836109f2565b50505050565b6001600160a01b03811633146107345760405163334bd91960e11b815260040160405180910390fd5b61073e8282610a84565b505050565b61075b600080516020610e268339815191523361083b565b6107775760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561078f5761078f610c2d565b14806107b15750600260015460ff1660048111156107af576107af610c2d565b145b6107f55760405162461bcd60e51b81526020600482015260156024820152745661756c743a206e6f7420726566756e6461626c6560581b60448201526064016102ed565b6001805460ff191660041790554260088190556040517f145dc9770278153bf55661b2995f99690edf947eb7b1ceda6dc584b6dc5c7001916103a5916002918591610dc7565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015461087f816109e5565b6107058383610a84565b6108a1600080516020610e268339815191523361083b565b6108bd5760405162461bcd60e51b81526004016102ed90610c6b565b600260015460ff1660048111156108d6576108d6610c2d565b146109235760405162461bcd60e51b815260206004820152601860248201527f5661756c743a206e6f742068616c662072656c6561736564000000000000000060448201526064016102ed565b600082116109685760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b816004600082825461097a9190610da6565b90915550506003546004541080159061099f576001805460ff19166003179055426007555b7fb4c3d1fece72a35bac91fbf712c1e9af695c44dd643a1fe1bc86f606d075c2236002858585856040516109d7959493929190610dec565b60405180910390a150505050565b6109ef8133610aef565b50565b60006109fe838361083b565b610a7c576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055610a343390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016103e1565b5060006103e1565b6000610a90838361083b565b15610a7c576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016103e1565b610af9828261083b565b610b285760405163e2517d3f60e01b81526001600160a01b0382166004820152602481018390526044016102ed565b5050565b600060208284031215610b3e57600080fd5b5035919050565b600060208284031215610b5757600080fd5b81356001600160e01b031981168114610b6f57600080fd5b9392505050565b600080600060608486031215610b8b57600080fd5b505081359360208301359350604090920135919050565b60006020808352835180602085015260005b81811015610bd057858101830151858201604001528201610bb4565b506000604082860101526040601f19601f8301168501019250505092915050565b60008060408385031215610c0457600080fd5b8235915060208301356001600160a01b0381168114610c2257600080fd5b809150509250929050565b634e487b7160e01b600052602160045260246000fd5b6020810160058310610c6557634e487b7160e01b600052602160045260246000fd5b91905290565b6020808252601490820152735661756c743a206174746573746572206f6e6c7960601b604082015260600190565b600181811c90821680610cad57607f821691505b602082108103610ccd57634e487b7160e01b600052602260045260246000fd5b50919050565b8054600090600181811c9080831680610ced57607f831692505b60208084108203610d0e57634e487b7160e01b600052602260045260246000fd5b83885260208801828015610d295760018114610d3f57610d6a565b60ff198716825285151560051b82019750610d6a565b60008981526020902060005b87811015610d6457815484820152908601908401610d4b565b83019850505b5050505050505092915050565b608081526000610d8a6080830187610cd3565b6020830195909552506040810192909252606090910152919050565b808201808211156103e157634e487b7160e01b600052601160045260246000fd5b606081526000610dda6060830186610cd3565b60208301949094525060400152919050565b60a081526000610dff60a0830188610cd3565b90508560208301528460408301528360608301528215156080830152969550505050505056feca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a26469706673582212200d6d4745e1ac302c0d8f4c00da47d440d18c417b9bbcec33fcf0c53b9f10bd5c64736f6c63430008180033", - "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106101375760003560e01c80636b1024ee116100b8578063b21634821161007c578063b21634821461025e578063c19d93fb14610267578063d547741f14610281578063edd6b5c314610294578063f709b5c31461029d578063fa8f99de146102b057600080fd5b80636b1024ee1461021e5780637249fbb61461022757806391d148541461023a578063a217fddf1461024d578063a99a67e61461025557600080fd5b8063248a9ca3116100ff578063248a9ca3146101b85780632728f333146101db5780632f2ff15d146101e35780632f81bb58146101f657806336568abe1461020b57600080fd5b806301670ba91461013c57806301ffc9a7146101515780630422f17c146101795780630827d05e1461018c578063089cdd0c146101a3575b600080fd5b61014f61014a366004610b2c565b6102b9565b005b61016461015f366004610b45565b6103b0565b60405190151581526020015b60405180910390f35b61014f610187366004610b76565b6103e7565b61019560065481565b604051908152602001610170565b6101ab610528565b6040516101709190610ba2565b6101956101c6366004610b2c565b60009081526020819052604090206001015490565b6101ab6105b6565b61014f6101f1366004610bf1565b6106e0565b610195600080516020610e2683398151915281565b61014f610219366004610bf1565b61070b565b61019560045481565b61014f610235366004610b2c565b610743565b610164610248366004610bf1565b61083b565b610195600081565b61019560075481565b61019560055481565b6001546102749060ff1681565b6040516101709190610c43565b61014f61028f366004610bf1565b610864565b61019560085481565b61014f6102ab366004610b76565b610889565b61019560035481565b6102d1600080516020610e268339815191523361083b565b6102f65760405162461bcd60e51b81526004016102ed90610c6b565b60405180910390fd5b600060015460ff16600481111561030f5761030f610c2d565b1461035c5760405162461bcd60e51b815260206004820152601b60248201527f5661756c743a206e6f74206177616974696e67206465706f736974000000000060448201526064016102ed565b6001805460ff1916811790554260058190556003546040517fdce4f63a820a70db387211b8a0b55c3370c291792cdbc8036cf973441d575fc4926103a592600292869290610d77565b60405180910390a150565b60006001600160e01b03198216637965db0b60e01b14806103e157506301ffc9a760e01b6001600160e01b03198316145b92915050565b6103ff600080516020610e268339815191523361083b565b61041b5760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561043357610433610c2d565b146104745760405162461bcd60e51b815260206004820152601160248201527015985d5b1d0e881b9bdd081b1bd8dad959607a1b60448201526064016102ed565b600082116104b95760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b6001805460ff1916600217905542600655600480548391906000906104df908490610da6565b90915550506040517f511f53603374f23d68700b47cdb96506ca244987097c55aed250d30c4b01ed119061051b90600290869086908690610d77565b60405180910390a1505050565b6002805461053590610c99565b80601f016020809104026020016040519081016040528092919081815260200182805461056190610c99565b80156105ae5780601f10610583576101008083540402835291602001916105ae565b820191906000526020600020905b81548152906001019060200180831161059157829003601f168201915b505050505081565b6060600060015460ff1660048111156105d1576105d1610c2d565b036105f957506040805180820190915260088152674157414954494e4760c01b602082015290565b6001805460ff16600481111561061157610611610c2d565b0361063757506040805180820190915260068152651313d0d2d15160d21b602082015290565b600260015460ff16600481111561065057610650610c2d565b0361067d575060408051808201909152600d81526c1210531197d49153115054d151609a1b602082015290565b600360015460ff16600481111561069657610696610c2d565b036106bd575060408051808201909152600781526614d1551513115160ca1b602082015290565b50604080518082019091526008815267149151955391115160c21b602082015290565b6000828152602081905260409020600101546106fb816109e5565b61070583836109f2565b50505050565b6001600160a01b03811633146107345760405163334bd91960e11b815260040160405180910390fd5b61073e8282610a84565b505050565b61075b600080516020610e268339815191523361083b565b6107775760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561078f5761078f610c2d565b14806107b15750600260015460ff1660048111156107af576107af610c2d565b145b6107f55760405162461bcd60e51b81526020600482015260156024820152745661756c743a206e6f7420726566756e6461626c6560581b60448201526064016102ed565b6001805460ff191660041790554260088190556040517f145dc9770278153bf55661b2995f99690edf947eb7b1ceda6dc584b6dc5c7001916103a5916002918591610dc7565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015461087f816109e5565b6107058383610a84565b6108a1600080516020610e268339815191523361083b565b6108bd5760405162461bcd60e51b81526004016102ed90610c6b565b600260015460ff1660048111156108d6576108d6610c2d565b146109235760405162461bcd60e51b815260206004820152601860248201527f5661756c743a206e6f742068616c662072656c6561736564000000000000000060448201526064016102ed565b600082116109685760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b816004600082825461097a9190610da6565b90915550506003546004541080159061099f576001805460ff19166003179055426007555b7fb4c3d1fece72a35bac91fbf712c1e9af695c44dd643a1fe1bc86f606d075c2236002858585856040516109d7959493929190610dec565b60405180910390a150505050565b6109ef8133610aef565b50565b60006109fe838361083b565b610a7c576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055610a343390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016103e1565b5060006103e1565b6000610a90838361083b565b15610a7c576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016103e1565b610af9828261083b565b610b285760405163e2517d3f60e01b81526001600160a01b0382166004820152602481018390526044016102ed565b5050565b600060208284031215610b3e57600080fd5b5035919050565b600060208284031215610b5757600080fd5b81356001600160e01b031981168114610b6f57600080fd5b9392505050565b600080600060608486031215610b8b57600080fd5b505081359360208301359350604090920135919050565b60006020808352835180602085015260005b81811015610bd057858101830151858201604001528201610bb4565b506000604082860101526040601f19601f8301168501019250505092915050565b60008060408385031215610c0457600080fd5b8235915060208301356001600160a01b0381168114610c2257600080fd5b809150509250929050565b634e487b7160e01b600052602160045260246000fd5b6020810160058310610c6557634e487b7160e01b600052602160045260246000fd5b91905290565b6020808252601490820152735661756c743a206174746573746572206f6e6c7960601b604082015260600190565b600181811c90821680610cad57607f821691505b602082108103610ccd57634e487b7160e01b600052602260045260246000fd5b50919050565b8054600090600181811c9080831680610ced57607f831692505b60208084108203610d0e57634e487b7160e01b600052602260045260246000fd5b83885260208801828015610d295760018114610d3f57610d6a565b60ff198716825285151560051b82019750610d6a565b60008981526020902060005b87811015610d6457815484820152908601908401610d4b565b83019850505b5050505050505092915050565b608081526000610d8a6080830187610cd3565b6020830195909552506040810192909252606090910152919050565b808201808211156103e157634e487b7160e01b600052601160045260246000fd5b606081526000610dda6060830186610cd3565b60208301949094525060400152919050565b60a081526000610dff60a0830188610cd3565b90508560208301528460408301528360608301528215156080830152969550505050505056feca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a26469706673582212200d6d4745e1ac302c0d8f4c00da47d440d18c417b9bbcec33fcf0c53b9f10bd5c64736f6c63430008180033", + "bytecode": "0x60806040523480156200001157600080fd5b50604051620013453803806200134583398101604081905262000034916200026c565b60008351116200008b5760405162461bcd60e51b815260206004820152601460248201527f5661756c743a20656d707479206576656e74496400000000000000000000000060448201526064015b60405180910390fd5b60008211620000d05760405162461bcd60e51b815260206004820152601060248201526f15985d5b1d0e881e995c9bc81c1bdbdb60821b604482015260640162000082565b6001600160a01b038116620001285760405162461bcd60e51b815260206004820152601460248201527f5661756c743a207a65726f206174746573746572000000000000000000000000604482015260640162000082565b6002620001368482620003ee565b5060038290556001805460ff19169055620001536000336200018a565b50620001807fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9826200018a565b50505050620004ba565b6000828152602081815260408083206001600160a01b038516845290915281205460ff166200022f576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055620001e63390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a450600162000233565b5060005b92915050565b634e487b7160e01b600052604160045260246000fd5b80516001600160a01b03811681146200026757600080fd5b919050565b6000806000606084860312156200028257600080fd5b83516001600160401b03808211156200029a57600080fd5b818601915086601f830112620002af57600080fd5b815181811115620002c457620002c462000239565b604051601f8201601f19908116603f01168101908382118183101715620002ef57620002ef62000239565b816040528281526020935089848487010111156200030c57600080fd5b600091505b8282101562000330578482018401518183018501529083019062000311565b6000848483010152809750505050808601519350505062000354604085016200024f565b90509250925092565b600181811c908216806200037257607f821691505b6020821081036200039357634e487b7160e01b600052602260045260246000fd5b50919050565b601f821115620003e9576000816000526020600020601f850160051c81016020861015620003c45750805b601f850160051c820191505b81811015620003e557828155600101620003d0565b5050505b505050565b81516001600160401b038111156200040a576200040a62000239565b62000422816200041b84546200035d565b8462000399565b602080601f8311600181146200045a5760008415620004415750858301515b600019600386901b1c1916600185901b178555620003e5565b600085815260208120601f198616915b828110156200048b578886015182559484019460019091019084016200046a565b5085821015620004aa5787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b610e7b80620004ca6000396000f3fe608060405234801561001057600080fd5b50600436106101375760003560e01c80636b1024ee116100b8578063b21634821161007c578063b21634821461025e578063c19d93fb14610267578063d547741f14610281578063edd6b5c314610294578063f709b5c31461029d578063fa8f99de146102b057600080fd5b80636b1024ee1461021e5780637249fbb61461022757806391d148541461023a578063a217fddf1461024d578063a99a67e61461025557600080fd5b8063248a9ca3116100ff578063248a9ca3146101b85780632728f333146101db5780632f2ff15d146101e35780632f81bb58146101f657806336568abe1461020b57600080fd5b806301670ba91461013c57806301ffc9a7146101515780630422f17c146101795780630827d05e1461018c578063089cdd0c146101a3575b600080fd5b61014f61014a366004610b2c565b6102b9565b005b61016461015f366004610b45565b6103b0565b60405190151581526020015b60405180910390f35b61014f610187366004610b76565b6103e7565b61019560065481565b604051908152602001610170565b6101ab610528565b6040516101709190610ba2565b6101956101c6366004610b2c565b60009081526020819052604090206001015490565b6101ab6105b6565b61014f6101f1366004610bf1565b6106e0565b610195600080516020610e2683398151915281565b61014f610219366004610bf1565b61070b565b61019560045481565b61014f610235366004610b2c565b610743565b610164610248366004610bf1565b61083b565b610195600081565b61019560075481565b61019560055481565b6001546102749060ff1681565b6040516101709190610c43565b61014f61028f366004610bf1565b610864565b61019560085481565b61014f6102ab366004610b76565b610889565b61019560035481565b6102d1600080516020610e268339815191523361083b565b6102f65760405162461bcd60e51b81526004016102ed90610c6b565b60405180910390fd5b600060015460ff16600481111561030f5761030f610c2d565b1461035c5760405162461bcd60e51b815260206004820152601b60248201527f5661756c743a206e6f74206177616974696e67206465706f736974000000000060448201526064016102ed565b6001805460ff1916811790554260058190556003546040517fdce4f63a820a70db387211b8a0b55c3370c291792cdbc8036cf973441d575fc4926103a592600292869290610d77565b60405180910390a150565b60006001600160e01b03198216637965db0b60e01b14806103e157506301ffc9a760e01b6001600160e01b03198316145b92915050565b6103ff600080516020610e268339815191523361083b565b61041b5760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561043357610433610c2d565b146104745760405162461bcd60e51b815260206004820152601160248201527015985d5b1d0e881b9bdd081b1bd8dad959607a1b60448201526064016102ed565b600082116104b95760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b6001805460ff1916600217905542600655600480548391906000906104df908490610da6565b90915550506040517f511f53603374f23d68700b47cdb96506ca244987097c55aed250d30c4b01ed119061051b90600290869086908690610d77565b60405180910390a1505050565b6002805461053590610c99565b80601f016020809104026020016040519081016040528092919081815260200182805461056190610c99565b80156105ae5780601f10610583576101008083540402835291602001916105ae565b820191906000526020600020905b81548152906001019060200180831161059157829003601f168201915b505050505081565b6060600060015460ff1660048111156105d1576105d1610c2d565b036105f957506040805180820190915260088152674157414954494e4760c01b602082015290565b6001805460ff16600481111561061157610611610c2d565b0361063757506040805180820190915260068152651313d0d2d15160d21b602082015290565b600260015460ff16600481111561065057610650610c2d565b0361067d575060408051808201909152600d81526c1210531197d49153115054d151609a1b602082015290565b600360015460ff16600481111561069657610696610c2d565b036106bd575060408051808201909152600781526614d1551513115160ca1b602082015290565b50604080518082019091526008815267149151955391115160c21b602082015290565b6000828152602081905260409020600101546106fb816109e5565b61070583836109f2565b50505050565b6001600160a01b03811633146107345760405163334bd91960e11b815260040160405180910390fd5b61073e8282610a84565b505050565b61075b600080516020610e268339815191523361083b565b6107775760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561078f5761078f610c2d565b14806107b15750600260015460ff1660048111156107af576107af610c2d565b145b6107f55760405162461bcd60e51b81526020600482015260156024820152745661756c743a206e6f7420726566756e6461626c6560581b60448201526064016102ed565b6001805460ff191660041790554260088190556040517f145dc9770278153bf55661b2995f99690edf947eb7b1ceda6dc584b6dc5c7001916103a5916002918591610dc7565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015461087f816109e5565b6107058383610a84565b6108a1600080516020610e268339815191523361083b565b6108bd5760405162461bcd60e51b81526004016102ed90610c6b565b600260015460ff1660048111156108d6576108d6610c2d565b146109235760405162461bcd60e51b815260206004820152601860248201527f5661756c743a206e6f742068616c662072656c6561736564000000000000000060448201526064016102ed565b600082116109685760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b816004600082825461097a9190610da6565b90915550506003546004541080159061099f576001805460ff19166003179055426007555b7fb4c3d1fece72a35bac91fbf712c1e9af695c44dd643a1fe1bc86f606d075c2236002858585856040516109d7959493929190610dec565b60405180910390a150505050565b6109ef8133610aef565b50565b60006109fe838361083b565b610a7c576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055610a343390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016103e1565b5060006103e1565b6000610a90838361083b565b15610a7c576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016103e1565b610af9828261083b565b610b285760405163e2517d3f60e01b81526001600160a01b0382166004820152602481018390526044016102ed565b5050565b600060208284031215610b3e57600080fd5b5035919050565b600060208284031215610b5757600080fd5b81356001600160e01b031981168114610b6f57600080fd5b9392505050565b600080600060608486031215610b8b57600080fd5b505081359360208301359350604090920135919050565b60006020808352835180602085015260005b81811015610bd057858101830151858201604001528201610bb4565b506000604082860101526040601f19601f8301168501019250505092915050565b60008060408385031215610c0457600080fd5b8235915060208301356001600160a01b0381168114610c2257600080fd5b809150509250929050565b634e487b7160e01b600052602160045260246000fd5b6020810160058310610c6557634e487b7160e01b600052602160045260246000fd5b91905290565b6020808252601490820152735661756c743a206174746573746572206f6e6c7960601b604082015260600190565b600181811c90821680610cad57607f821691505b602082108103610ccd57634e487b7160e01b600052602260045260246000fd5b50919050565b8054600090600181811c9080831680610ced57607f831692505b60208084108203610d0e57634e487b7160e01b600052602260045260246000fd5b83885260208801828015610d295760018114610d3f57610d6a565b60ff198716825285151560051b82019750610d6a565b60008981526020902060005b87811015610d6457815484820152908601908401610d4b565b83019850505b5050505050505092915050565b608081526000610d8a6080830187610cd3565b6020830195909552506040810192909252606090910152919050565b808201808211156103e157634e487b7160e01b600052601160045260246000fd5b606081526000610dda6060830186610cd3565b60208301949094525060400152919050565b60a081526000610dff60a0830188610cd3565b90508560208301528460408301528360608301528215156080830152969550505050505056feca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a2646970667358221220b618b48e4fa68af0296a275855262d6b0d71e9b37cedc5c8780860cf4e3d450364736f6c63430008180033", + "deployedBytecode": "0x608060405234801561001057600080fd5b50600436106101375760003560e01c80636b1024ee116100b8578063b21634821161007c578063b21634821461025e578063c19d93fb14610267578063d547741f14610281578063edd6b5c314610294578063f709b5c31461029d578063fa8f99de146102b057600080fd5b80636b1024ee1461021e5780637249fbb61461022757806391d148541461023a578063a217fddf1461024d578063a99a67e61461025557600080fd5b8063248a9ca3116100ff578063248a9ca3146101b85780632728f333146101db5780632f2ff15d146101e35780632f81bb58146101f657806336568abe1461020b57600080fd5b806301670ba91461013c57806301ffc9a7146101515780630422f17c146101795780630827d05e1461018c578063089cdd0c146101a3575b600080fd5b61014f61014a366004610b2c565b6102b9565b005b61016461015f366004610b45565b6103b0565b60405190151581526020015b60405180910390f35b61014f610187366004610b76565b6103e7565b61019560065481565b604051908152602001610170565b6101ab610528565b6040516101709190610ba2565b6101956101c6366004610b2c565b60009081526020819052604090206001015490565b6101ab6105b6565b61014f6101f1366004610bf1565b6106e0565b610195600080516020610e2683398151915281565b61014f610219366004610bf1565b61070b565b61019560045481565b61014f610235366004610b2c565b610743565b610164610248366004610bf1565b61083b565b610195600081565b61019560075481565b61019560055481565b6001546102749060ff1681565b6040516101709190610c43565b61014f61028f366004610bf1565b610864565b61019560085481565b61014f6102ab366004610b76565b610889565b61019560035481565b6102d1600080516020610e268339815191523361083b565b6102f65760405162461bcd60e51b81526004016102ed90610c6b565b60405180910390fd5b600060015460ff16600481111561030f5761030f610c2d565b1461035c5760405162461bcd60e51b815260206004820152601b60248201527f5661756c743a206e6f74206177616974696e67206465706f736974000000000060448201526064016102ed565b6001805460ff1916811790554260058190556003546040517fdce4f63a820a70db387211b8a0b55c3370c291792cdbc8036cf973441d575fc4926103a592600292869290610d77565b60405180910390a150565b60006001600160e01b03198216637965db0b60e01b14806103e157506301ffc9a760e01b6001600160e01b03198316145b92915050565b6103ff600080516020610e268339815191523361083b565b61041b5760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561043357610433610c2d565b146104745760405162461bcd60e51b815260206004820152601160248201527015985d5b1d0e881b9bdd081b1bd8dad959607a1b60448201526064016102ed565b600082116104b95760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b6001805460ff1916600217905542600655600480548391906000906104df908490610da6565b90915550506040517f511f53603374f23d68700b47cdb96506ca244987097c55aed250d30c4b01ed119061051b90600290869086908690610d77565b60405180910390a1505050565b6002805461053590610c99565b80601f016020809104026020016040519081016040528092919081815260200182805461056190610c99565b80156105ae5780601f10610583576101008083540402835291602001916105ae565b820191906000526020600020905b81548152906001019060200180831161059157829003601f168201915b505050505081565b6060600060015460ff1660048111156105d1576105d1610c2d565b036105f957506040805180820190915260088152674157414954494e4760c01b602082015290565b6001805460ff16600481111561061157610611610c2d565b0361063757506040805180820190915260068152651313d0d2d15160d21b602082015290565b600260015460ff16600481111561065057610650610c2d565b0361067d575060408051808201909152600d81526c1210531197d49153115054d151609a1b602082015290565b600360015460ff16600481111561069657610696610c2d565b036106bd575060408051808201909152600781526614d1551513115160ca1b602082015290565b50604080518082019091526008815267149151955391115160c21b602082015290565b6000828152602081905260409020600101546106fb816109e5565b61070583836109f2565b50505050565b6001600160a01b03811633146107345760405163334bd91960e11b815260040160405180910390fd5b61073e8282610a84565b505050565b61075b600080516020610e268339815191523361083b565b6107775760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561078f5761078f610c2d565b14806107b15750600260015460ff1660048111156107af576107af610c2d565b145b6107f55760405162461bcd60e51b81526020600482015260156024820152745661756c743a206e6f7420726566756e6461626c6560581b60448201526064016102ed565b6001805460ff191660041790554260088190556040517f145dc9770278153bf55661b2995f99690edf947eb7b1ceda6dc584b6dc5c7001916103a5916002918591610dc7565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015461087f816109e5565b6107058383610a84565b6108a1600080516020610e268339815191523361083b565b6108bd5760405162461bcd60e51b81526004016102ed90610c6b565b600260015460ff1660048111156108d6576108d6610c2d565b146109235760405162461bcd60e51b815260206004820152601860248201527f5661756c743a206e6f742068616c662072656c6561736564000000000000000060448201526064016102ed565b600082116109685760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b816004600082825461097a9190610da6565b90915550506003546004541080159061099f576001805460ff19166003179055426007555b7fb4c3d1fece72a35bac91fbf712c1e9af695c44dd643a1fe1bc86f606d075c2236002858585856040516109d7959493929190610dec565b60405180910390a150505050565b6109ef8133610aef565b50565b60006109fe838361083b565b610a7c576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055610a343390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016103e1565b5060006103e1565b6000610a90838361083b565b15610a7c576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016103e1565b610af9828261083b565b610b285760405163e2517d3f60e01b81526001600160a01b0382166004820152602481018390526044016102ed565b5050565b600060208284031215610b3e57600080fd5b5035919050565b600060208284031215610b5757600080fd5b81356001600160e01b031981168114610b6f57600080fd5b9392505050565b600080600060608486031215610b8b57600080fd5b505081359360208301359350604090920135919050565b60006020808352835180602085015260005b81811015610bd057858101830151858201604001528201610bb4565b506000604082860101526040601f19601f8301168501019250505092915050565b60008060408385031215610c0457600080fd5b8235915060208301356001600160a01b0381168114610c2257600080fd5b809150509250929050565b634e487b7160e01b600052602160045260246000fd5b6020810160058310610c6557634e487b7160e01b600052602160045260246000fd5b91905290565b6020808252601490820152735661756c743a206174746573746572206f6e6c7960601b604082015260600190565b600181811c90821680610cad57607f821691505b602082108103610ccd57634e487b7160e01b600052602260045260246000fd5b50919050565b8054600090600181811c9080831680610ced57607f831692505b60208084108203610d0e57634e487b7160e01b600052602260045260246000fd5b83885260208801828015610d295760018114610d3f57610d6a565b60ff198716825285151560051b82019750610d6a565b60008981526020902060005b87811015610d6457815484820152908601908401610d4b565b83019850505b5050505050505092915050565b608081526000610d8a6080830187610cd3565b6020830195909552506040810192909252606090910152919050565b808201808211156103e157634e487b7160e01b600052601160045260246000fd5b606081526000610dda6060830186610cd3565b60208301949094525060400152919050565b60a081526000610dff60a0830188610cd3565b90508560208301528460408301528360608301528215156080830152969550505050505056feca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a2646970667358221220b618b48e4fa68af0296a275855262d6b0d71e9b37cedc5c8780860cf4e3d450364736f6c63430008180033", "linkReferences": {}, "deployedLinkReferences": {} } diff --git a/contracts/artifacts/contracts/PrizeVaultFactory.sol/PrizeVaultFactory.dbg.json b/contracts/artifacts/contracts/PrizeVaultFactory.sol/PrizeVaultFactory.dbg.json index 6f7ba47..a6f51fc 100644 --- a/contracts/artifacts/contracts/PrizeVaultFactory.sol/PrizeVaultFactory.dbg.json +++ b/contracts/artifacts/contracts/PrizeVaultFactory.sol/PrizeVaultFactory.dbg.json @@ -1,4 +1,4 @@ { "_format": "hh-sol-dbg-1", - "buildInfo": "../../build-info/df02a330a00364d2c848195a185ae999.json" + "buildInfo": "../../build-info/d4e4d3c7fd895d870b900a329498af84.json" } diff --git a/contracts/artifacts/contracts/PrizeVaultFactory.sol/PrizeVaultFactory.json b/contracts/artifacts/contracts/PrizeVaultFactory.sol/PrizeVaultFactory.json index 1f43506..894eaef 100644 --- a/contracts/artifacts/contracts/PrizeVaultFactory.sol/PrizeVaultFactory.json +++ b/contracts/artifacts/contracts/PrizeVaultFactory.sol/PrizeVaultFactory.json @@ -29,6 +29,31 @@ "name": "AccessControlUnauthorizedAccount", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "vault", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "oldAttester", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newAttester", + "type": "address" + } + ], + "name": "AttesterRotated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -306,6 +331,29 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "vault", + "type": "address" + }, + { + "internalType": "address", + "name": "oldAttester", + "type": "address" + }, + { + "internalType": "address", + "name": "newAttester", + "type": "address" + } + ], + "name": "rotateAttester", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -345,8 +393,8 @@ "type": "function" } ], - "bytecode": "0x608060405234801561001057600080fd5b5061001c60003361004d565b506100477fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa93361004d565b506100f9565b6000828152602081815260408083206001600160a01b038516845290915281205460ff166100ef576000838152602081815260408083206001600160a01b03861684529091529020805460ff191660011790556100a73390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016100f3565b5060005b92915050565b611cfd806101086000396000f3fe60806040523480156200001157600080fd5b5060043610620000b75760003560e01c806336568abe116200007a57806336568abe146200018e5780635651c5ec14620001a557806391d1485414620001bc578063a217fddf14620001d3578063d547741f14620001dc578063e725688814620001f357600080fd5b806301ffc9a714620000bc578063248a9ca314620000e857806325de49c3146200011d5780632f2ff15d146200014d5780632f81bb581462000166575b600080fd5b620000d3620000cd3660046200067f565b6200022a565b60405190151581526020015b60405180910390f35b6200010e620000f9366004620006b2565b60009081526020819052604090206001015490565b604051908152602001620000df565b620001346200012e36600462000718565b62000262565b6040516001600160a01b039091168152602001620000df565b620001646200015e3660046200077b565b62000299565b005b6200010e7fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa981565b620001646200019f3660046200077b565b620002c8565b62000134620001b6366004620007aa565b62000303565b620000d3620001cd3660046200077b565b620004c8565b6200010e600081565b62000164620001ed3660046200077b565b620004f1565b620001346200020436600462000823565b80516020818301810180516001825292820191909301209152546001600160a01b031681565b60006001600160e01b03198216637965db0b60e01b14806200025c57506301ffc9a760e01b6001600160e01b03198316145b92915050565b60006001838360405162000278929190620008de565b908152604051908190036020019020546001600160a01b0316905092915050565b600082815260208190526040902060010154620002b6816200051a565b620002c2838362000529565b50505050565b6001600160a01b0381163314620002f25760405163334bd91960e11b815260040160405180910390fd5b620002fe8282620005c1565b505050565b60007fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa962000331816200051a565b846200037d5760405162461bcd60e51b8152602060048201526016602482015275119858dd1bdc9e4e88195b5c1d1e48195d995b9d125960521b60448201526064015b60405180910390fd5b60006001600160a01b0316600187876040516200039c929190620008de565b908152604051908190036020019020546001600160a01b031614620003fc5760405162461bcd60e51b8152602060048201526015602482015274466163746f72793a207661756c742065786973747360581b604482015260640162000374565b858585856040516200040e9062000671565b6200041d949392919062000917565b604051809103906000f0801580156200043a573d6000803e3d6000fd5b509150816001878760405162000452929190620008de565b90815260405190819003602001812080546001600160a01b039384166001600160a01b0319909116179055848216918416907f1fdd852a78d545c6024e0d66e640f76a44528ba281c35893d063dcde9fa62a3990620004b7908a908a908a906200094e565b60405180910390a350949350505050565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b6000828152602081905260409020600101546200050e816200051a565b620002c28383620005c1565b62000526813362000630565b50565b6000620005378383620004c8565b620005b8576000838152602081815260408083206001600160a01b03861684529091529020805460ff191660011790556200056f3390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016200025c565b5060006200025c565b6000620005cf8383620004c8565b15620005b8576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016200025c565b6200063c8282620004c8565b6200066d5760405163e2517d3f60e01b81526001600160a01b03821660048201526024810183905260440162000374565b5050565b611353806200097583390190565b6000602082840312156200069257600080fd5b81356001600160e01b031981168114620006ab57600080fd5b9392505050565b600060208284031215620006c557600080fd5b5035919050565b60008083601f840112620006df57600080fd5b50813567ffffffffffffffff811115620006f857600080fd5b6020830191508360208285010111156200071157600080fd5b9250929050565b600080602083850312156200072c57600080fd5b823567ffffffffffffffff8111156200074457600080fd5b6200075285828601620006cc565b90969095509350505050565b80356001600160a01b03811681146200077657600080fd5b919050565b600080604083850312156200078f57600080fd5b82359150620007a1602084016200075e565b90509250929050565b60008060008060608587031215620007c157600080fd5b843567ffffffffffffffff811115620007d957600080fd5b620007e787828801620006cc565b9095509350506020850135915062000802604086016200075e565b905092959194509250565b634e487b7160e01b600052604160045260246000fd5b6000602082840312156200083657600080fd5b813567ffffffffffffffff808211156200084f57600080fd5b818401915084601f8301126200086457600080fd5b8135818111156200087957620008796200080d565b604051601f8201601f19908116603f01168101908382118183101715620008a457620008a46200080d565b81604052828152876020848701011115620008be57600080fd5b826020860160208301376000928101602001929092525095945050505050565b8183823760009101908152919050565b81835281816020850137506000828201602090810191909152601f909101601f19169091010190565b6060815260006200092d606083018688620008ee565b6020830194909452506001600160a01b039190911660409091015292915050565b60408152600062000964604083018587620008ee565b905082602083015294935050505056fe60806040523480156200001157600080fd5b50604051620013533803806200135383398101604081905262000034916200027a565b60008351116200008b5760405162461bcd60e51b815260206004820152601460248201527f5661756c743a20656d707479206576656e74496400000000000000000000000060448201526064015b60405180910390fd5b60008211620000d05760405162461bcd60e51b815260206004820152601060248201526f15985d5b1d0e881e995c9bc81c1bdbdb60821b604482015260640162000082565b6001600160a01b038116620001285760405162461bcd60e51b815260206004820152601460248201527f5661756c743a207a65726f206174746573746572000000000000000000000000604482015260640162000082565b6002620001368482620003fc565b5060038290556001805460ff191690556200015360003362000198565b50620001807fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa98262000198565b506200018e60008262000198565b50505050620004c8565b6000828152602081815260408083206001600160a01b038516845290915281205460ff166200023d576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055620001f43390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a450600162000241565b5060005b92915050565b634e487b7160e01b600052604160045260246000fd5b80516001600160a01b03811681146200027557600080fd5b919050565b6000806000606084860312156200029057600080fd5b83516001600160401b0380821115620002a857600080fd5b818601915086601f830112620002bd57600080fd5b815181811115620002d257620002d262000247565b604051601f8201601f19908116603f01168101908382118183101715620002fd57620002fd62000247565b816040528281526020935089848487010111156200031a57600080fd5b600091505b828210156200033e57848201840151818301850152908301906200031f565b6000848483010152809750505050808601519350505062000362604085016200025d565b90509250925092565b600181811c908216806200038057607f821691505b602082108103620003a157634e487b7160e01b600052602260045260246000fd5b50919050565b601f821115620003f7576000816000526020600020601f850160051c81016020861015620003d25750805b601f850160051c820191505b81811015620003f357828155600101620003de565b5050505b505050565b81516001600160401b0381111562000418576200041862000247565b62000430816200042984546200036b565b84620003a7565b602080601f8311600181146200046857600084156200044f5750858301515b600019600386901b1c1916600185901b178555620003f3565b600085815260208120601f198616915b82811015620004995788860151825594840194600190910190840162000478565b5085821015620004b85787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b610e7b80620004d86000396000f3fe608060405234801561001057600080fd5b50600436106101375760003560e01c80636b1024ee116100b8578063b21634821161007c578063b21634821461025e578063c19d93fb14610267578063d547741f14610281578063edd6b5c314610294578063f709b5c31461029d578063fa8f99de146102b057600080fd5b80636b1024ee1461021e5780637249fbb61461022757806391d148541461023a578063a217fddf1461024d578063a99a67e61461025557600080fd5b8063248a9ca3116100ff578063248a9ca3146101b85780632728f333146101db5780632f2ff15d146101e35780632f81bb58146101f657806336568abe1461020b57600080fd5b806301670ba91461013c57806301ffc9a7146101515780630422f17c146101795780630827d05e1461018c578063089cdd0c146101a3575b600080fd5b61014f61014a366004610b2c565b6102b9565b005b61016461015f366004610b45565b6103b0565b60405190151581526020015b60405180910390f35b61014f610187366004610b76565b6103e7565b61019560065481565b604051908152602001610170565b6101ab610528565b6040516101709190610ba2565b6101956101c6366004610b2c565b60009081526020819052604090206001015490565b6101ab6105b6565b61014f6101f1366004610bf1565b6106e0565b610195600080516020610e2683398151915281565b61014f610219366004610bf1565b61070b565b61019560045481565b61014f610235366004610b2c565b610743565b610164610248366004610bf1565b61083b565b610195600081565b61019560075481565b61019560055481565b6001546102749060ff1681565b6040516101709190610c43565b61014f61028f366004610bf1565b610864565b61019560085481565b61014f6102ab366004610b76565b610889565b61019560035481565b6102d1600080516020610e268339815191523361083b565b6102f65760405162461bcd60e51b81526004016102ed90610c6b565b60405180910390fd5b600060015460ff16600481111561030f5761030f610c2d565b1461035c5760405162461bcd60e51b815260206004820152601b60248201527f5661756c743a206e6f74206177616974696e67206465706f736974000000000060448201526064016102ed565b6001805460ff1916811790554260058190556003546040517fdce4f63a820a70db387211b8a0b55c3370c291792cdbc8036cf973441d575fc4926103a592600292869290610d77565b60405180910390a150565b60006001600160e01b03198216637965db0b60e01b14806103e157506301ffc9a760e01b6001600160e01b03198316145b92915050565b6103ff600080516020610e268339815191523361083b565b61041b5760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561043357610433610c2d565b146104745760405162461bcd60e51b815260206004820152601160248201527015985d5b1d0e881b9bdd081b1bd8dad959607a1b60448201526064016102ed565b600082116104b95760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b6001805460ff1916600217905542600655600480548391906000906104df908490610da6565b90915550506040517f511f53603374f23d68700b47cdb96506ca244987097c55aed250d30c4b01ed119061051b90600290869086908690610d77565b60405180910390a1505050565b6002805461053590610c99565b80601f016020809104026020016040519081016040528092919081815260200182805461056190610c99565b80156105ae5780601f10610583576101008083540402835291602001916105ae565b820191906000526020600020905b81548152906001019060200180831161059157829003601f168201915b505050505081565b6060600060015460ff1660048111156105d1576105d1610c2d565b036105f957506040805180820190915260088152674157414954494e4760c01b602082015290565b6001805460ff16600481111561061157610611610c2d565b0361063757506040805180820190915260068152651313d0d2d15160d21b602082015290565b600260015460ff16600481111561065057610650610c2d565b0361067d575060408051808201909152600d81526c1210531197d49153115054d151609a1b602082015290565b600360015460ff16600481111561069657610696610c2d565b036106bd575060408051808201909152600781526614d1551513115160ca1b602082015290565b50604080518082019091526008815267149151955391115160c21b602082015290565b6000828152602081905260409020600101546106fb816109e5565b61070583836109f2565b50505050565b6001600160a01b03811633146107345760405163334bd91960e11b815260040160405180910390fd5b61073e8282610a84565b505050565b61075b600080516020610e268339815191523361083b565b6107775760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561078f5761078f610c2d565b14806107b15750600260015460ff1660048111156107af576107af610c2d565b145b6107f55760405162461bcd60e51b81526020600482015260156024820152745661756c743a206e6f7420726566756e6461626c6560581b60448201526064016102ed565b6001805460ff191660041790554260088190556040517f145dc9770278153bf55661b2995f99690edf947eb7b1ceda6dc584b6dc5c7001916103a5916002918591610dc7565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015461087f816109e5565b6107058383610a84565b6108a1600080516020610e268339815191523361083b565b6108bd5760405162461bcd60e51b81526004016102ed90610c6b565b600260015460ff1660048111156108d6576108d6610c2d565b146109235760405162461bcd60e51b815260206004820152601860248201527f5661756c743a206e6f742068616c662072656c6561736564000000000000000060448201526064016102ed565b600082116109685760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b816004600082825461097a9190610da6565b90915550506003546004541080159061099f576001805460ff19166003179055426007555b7fb4c3d1fece72a35bac91fbf712c1e9af695c44dd643a1fe1bc86f606d075c2236002858585856040516109d7959493929190610dec565b60405180910390a150505050565b6109ef8133610aef565b50565b60006109fe838361083b565b610a7c576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055610a343390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016103e1565b5060006103e1565b6000610a90838361083b565b15610a7c576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016103e1565b610af9828261083b565b610b285760405163e2517d3f60e01b81526001600160a01b0382166004820152602481018390526044016102ed565b5050565b600060208284031215610b3e57600080fd5b5035919050565b600060208284031215610b5757600080fd5b81356001600160e01b031981168114610b6f57600080fd5b9392505050565b600080600060608486031215610b8b57600080fd5b505081359360208301359350604090920135919050565b60006020808352835180602085015260005b81811015610bd057858101830151858201604001528201610bb4565b506000604082860101526040601f19601f8301168501019250505092915050565b60008060408385031215610c0457600080fd5b8235915060208301356001600160a01b0381168114610c2257600080fd5b809150509250929050565b634e487b7160e01b600052602160045260246000fd5b6020810160058310610c6557634e487b7160e01b600052602160045260246000fd5b91905290565b6020808252601490820152735661756c743a206174746573746572206f6e6c7960601b604082015260600190565b600181811c90821680610cad57607f821691505b602082108103610ccd57634e487b7160e01b600052602260045260246000fd5b50919050565b8054600090600181811c9080831680610ced57607f831692505b60208084108203610d0e57634e487b7160e01b600052602260045260246000fd5b83885260208801828015610d295760018114610d3f57610d6a565b60ff198716825285151560051b82019750610d6a565b60008981526020902060005b87811015610d6457815484820152908601908401610d4b565b83019850505b5050505050505092915050565b608081526000610d8a6080830187610cd3565b6020830195909552506040810192909252606090910152919050565b808201808211156103e157634e487b7160e01b600052601160045260246000fd5b606081526000610dda6060830186610cd3565b60208301949094525060400152919050565b60a081526000610dff60a0830188610cd3565b90508560208301528460408301528360608301528215156080830152969550505050505056feca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a26469706673582212200d6d4745e1ac302c0d8f4c00da47d440d18c417b9bbcec33fcf0c53b9f10bd5c64736f6c63430008180033a2646970667358221220384703a1e1241dc9419f35c08a3e7ddf99f5fe8508eb456da63a330f11e08f2664736f6c63430008180033", - "deployedBytecode": "0x60806040523480156200001157600080fd5b5060043610620000b75760003560e01c806336568abe116200007a57806336568abe146200018e5780635651c5ec14620001a557806391d1485414620001bc578063a217fddf14620001d3578063d547741f14620001dc578063e725688814620001f357600080fd5b806301ffc9a714620000bc578063248a9ca314620000e857806325de49c3146200011d5780632f2ff15d146200014d5780632f81bb581462000166575b600080fd5b620000d3620000cd3660046200067f565b6200022a565b60405190151581526020015b60405180910390f35b6200010e620000f9366004620006b2565b60009081526020819052604090206001015490565b604051908152602001620000df565b620001346200012e36600462000718565b62000262565b6040516001600160a01b039091168152602001620000df565b620001646200015e3660046200077b565b62000299565b005b6200010e7fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa981565b620001646200019f3660046200077b565b620002c8565b62000134620001b6366004620007aa565b62000303565b620000d3620001cd3660046200077b565b620004c8565b6200010e600081565b62000164620001ed3660046200077b565b620004f1565b620001346200020436600462000823565b80516020818301810180516001825292820191909301209152546001600160a01b031681565b60006001600160e01b03198216637965db0b60e01b14806200025c57506301ffc9a760e01b6001600160e01b03198316145b92915050565b60006001838360405162000278929190620008de565b908152604051908190036020019020546001600160a01b0316905092915050565b600082815260208190526040902060010154620002b6816200051a565b620002c2838362000529565b50505050565b6001600160a01b0381163314620002f25760405163334bd91960e11b815260040160405180910390fd5b620002fe8282620005c1565b505050565b60007fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa962000331816200051a565b846200037d5760405162461bcd60e51b8152602060048201526016602482015275119858dd1bdc9e4e88195b5c1d1e48195d995b9d125960521b60448201526064015b60405180910390fd5b60006001600160a01b0316600187876040516200039c929190620008de565b908152604051908190036020019020546001600160a01b031614620003fc5760405162461bcd60e51b8152602060048201526015602482015274466163746f72793a207661756c742065786973747360581b604482015260640162000374565b858585856040516200040e9062000671565b6200041d949392919062000917565b604051809103906000f0801580156200043a573d6000803e3d6000fd5b509150816001878760405162000452929190620008de565b90815260405190819003602001812080546001600160a01b039384166001600160a01b0319909116179055848216918416907f1fdd852a78d545c6024e0d66e640f76a44528ba281c35893d063dcde9fa62a3990620004b7908a908a908a906200094e565b60405180910390a350949350505050565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b6000828152602081905260409020600101546200050e816200051a565b620002c28383620005c1565b62000526813362000630565b50565b6000620005378383620004c8565b620005b8576000838152602081815260408083206001600160a01b03861684529091529020805460ff191660011790556200056f3390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016200025c565b5060006200025c565b6000620005cf8383620004c8565b15620005b8576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016200025c565b6200063c8282620004c8565b6200066d5760405163e2517d3f60e01b81526001600160a01b03821660048201526024810183905260440162000374565b5050565b611353806200097583390190565b6000602082840312156200069257600080fd5b81356001600160e01b031981168114620006ab57600080fd5b9392505050565b600060208284031215620006c557600080fd5b5035919050565b60008083601f840112620006df57600080fd5b50813567ffffffffffffffff811115620006f857600080fd5b6020830191508360208285010111156200071157600080fd5b9250929050565b600080602083850312156200072c57600080fd5b823567ffffffffffffffff8111156200074457600080fd5b6200075285828601620006cc565b90969095509350505050565b80356001600160a01b03811681146200077657600080fd5b919050565b600080604083850312156200078f57600080fd5b82359150620007a1602084016200075e565b90509250929050565b60008060008060608587031215620007c157600080fd5b843567ffffffffffffffff811115620007d957600080fd5b620007e787828801620006cc565b9095509350506020850135915062000802604086016200075e565b905092959194509250565b634e487b7160e01b600052604160045260246000fd5b6000602082840312156200083657600080fd5b813567ffffffffffffffff808211156200084f57600080fd5b818401915084601f8301126200086457600080fd5b8135818111156200087957620008796200080d565b604051601f8201601f19908116603f01168101908382118183101715620008a457620008a46200080d565b81604052828152876020848701011115620008be57600080fd5b826020860160208301376000928101602001929092525095945050505050565b8183823760009101908152919050565b81835281816020850137506000828201602090810191909152601f909101601f19169091010190565b6060815260006200092d606083018688620008ee565b6020830194909452506001600160a01b039190911660409091015292915050565b60408152600062000964604083018587620008ee565b905082602083015294935050505056fe60806040523480156200001157600080fd5b50604051620013533803806200135383398101604081905262000034916200027a565b60008351116200008b5760405162461bcd60e51b815260206004820152601460248201527f5661756c743a20656d707479206576656e74496400000000000000000000000060448201526064015b60405180910390fd5b60008211620000d05760405162461bcd60e51b815260206004820152601060248201526f15985d5b1d0e881e995c9bc81c1bdbdb60821b604482015260640162000082565b6001600160a01b038116620001285760405162461bcd60e51b815260206004820152601460248201527f5661756c743a207a65726f206174746573746572000000000000000000000000604482015260640162000082565b6002620001368482620003fc565b5060038290556001805460ff191690556200015360003362000198565b50620001807fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa98262000198565b506200018e60008262000198565b50505050620004c8565b6000828152602081815260408083206001600160a01b038516845290915281205460ff166200023d576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055620001f43390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a450600162000241565b5060005b92915050565b634e487b7160e01b600052604160045260246000fd5b80516001600160a01b03811681146200027557600080fd5b919050565b6000806000606084860312156200029057600080fd5b83516001600160401b0380821115620002a857600080fd5b818601915086601f830112620002bd57600080fd5b815181811115620002d257620002d262000247565b604051601f8201601f19908116603f01168101908382118183101715620002fd57620002fd62000247565b816040528281526020935089848487010111156200031a57600080fd5b600091505b828210156200033e57848201840151818301850152908301906200031f565b6000848483010152809750505050808601519350505062000362604085016200025d565b90509250925092565b600181811c908216806200038057607f821691505b602082108103620003a157634e487b7160e01b600052602260045260246000fd5b50919050565b601f821115620003f7576000816000526020600020601f850160051c81016020861015620003d25750805b601f850160051c820191505b81811015620003f357828155600101620003de565b5050505b505050565b81516001600160401b0381111562000418576200041862000247565b62000430816200042984546200036b565b84620003a7565b602080601f8311600181146200046857600084156200044f5750858301515b600019600386901b1c1916600185901b178555620003f3565b600085815260208120601f198616915b82811015620004995788860151825594840194600190910190840162000478565b5085821015620004b85787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b610e7b80620004d86000396000f3fe608060405234801561001057600080fd5b50600436106101375760003560e01c80636b1024ee116100b8578063b21634821161007c578063b21634821461025e578063c19d93fb14610267578063d547741f14610281578063edd6b5c314610294578063f709b5c31461029d578063fa8f99de146102b057600080fd5b80636b1024ee1461021e5780637249fbb61461022757806391d148541461023a578063a217fddf1461024d578063a99a67e61461025557600080fd5b8063248a9ca3116100ff578063248a9ca3146101b85780632728f333146101db5780632f2ff15d146101e35780632f81bb58146101f657806336568abe1461020b57600080fd5b806301670ba91461013c57806301ffc9a7146101515780630422f17c146101795780630827d05e1461018c578063089cdd0c146101a3575b600080fd5b61014f61014a366004610b2c565b6102b9565b005b61016461015f366004610b45565b6103b0565b60405190151581526020015b60405180910390f35b61014f610187366004610b76565b6103e7565b61019560065481565b604051908152602001610170565b6101ab610528565b6040516101709190610ba2565b6101956101c6366004610b2c565b60009081526020819052604090206001015490565b6101ab6105b6565b61014f6101f1366004610bf1565b6106e0565b610195600080516020610e2683398151915281565b61014f610219366004610bf1565b61070b565b61019560045481565b61014f610235366004610b2c565b610743565b610164610248366004610bf1565b61083b565b610195600081565b61019560075481565b61019560055481565b6001546102749060ff1681565b6040516101709190610c43565b61014f61028f366004610bf1565b610864565b61019560085481565b61014f6102ab366004610b76565b610889565b61019560035481565b6102d1600080516020610e268339815191523361083b565b6102f65760405162461bcd60e51b81526004016102ed90610c6b565b60405180910390fd5b600060015460ff16600481111561030f5761030f610c2d565b1461035c5760405162461bcd60e51b815260206004820152601b60248201527f5661756c743a206e6f74206177616974696e67206465706f736974000000000060448201526064016102ed565b6001805460ff1916811790554260058190556003546040517fdce4f63a820a70db387211b8a0b55c3370c291792cdbc8036cf973441d575fc4926103a592600292869290610d77565b60405180910390a150565b60006001600160e01b03198216637965db0b60e01b14806103e157506301ffc9a760e01b6001600160e01b03198316145b92915050565b6103ff600080516020610e268339815191523361083b565b61041b5760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561043357610433610c2d565b146104745760405162461bcd60e51b815260206004820152601160248201527015985d5b1d0e881b9bdd081b1bd8dad959607a1b60448201526064016102ed565b600082116104b95760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b6001805460ff1916600217905542600655600480548391906000906104df908490610da6565b90915550506040517f511f53603374f23d68700b47cdb96506ca244987097c55aed250d30c4b01ed119061051b90600290869086908690610d77565b60405180910390a1505050565b6002805461053590610c99565b80601f016020809104026020016040519081016040528092919081815260200182805461056190610c99565b80156105ae5780601f10610583576101008083540402835291602001916105ae565b820191906000526020600020905b81548152906001019060200180831161059157829003601f168201915b505050505081565b6060600060015460ff1660048111156105d1576105d1610c2d565b036105f957506040805180820190915260088152674157414954494e4760c01b602082015290565b6001805460ff16600481111561061157610611610c2d565b0361063757506040805180820190915260068152651313d0d2d15160d21b602082015290565b600260015460ff16600481111561065057610650610c2d565b0361067d575060408051808201909152600d81526c1210531197d49153115054d151609a1b602082015290565b600360015460ff16600481111561069657610696610c2d565b036106bd575060408051808201909152600781526614d1551513115160ca1b602082015290565b50604080518082019091526008815267149151955391115160c21b602082015290565b6000828152602081905260409020600101546106fb816109e5565b61070583836109f2565b50505050565b6001600160a01b03811633146107345760405163334bd91960e11b815260040160405180910390fd5b61073e8282610a84565b505050565b61075b600080516020610e268339815191523361083b565b6107775760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561078f5761078f610c2d565b14806107b15750600260015460ff1660048111156107af576107af610c2d565b145b6107f55760405162461bcd60e51b81526020600482015260156024820152745661756c743a206e6f7420726566756e6461626c6560581b60448201526064016102ed565b6001805460ff191660041790554260088190556040517f145dc9770278153bf55661b2995f99690edf947eb7b1ceda6dc584b6dc5c7001916103a5916002918591610dc7565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015461087f816109e5565b6107058383610a84565b6108a1600080516020610e268339815191523361083b565b6108bd5760405162461bcd60e51b81526004016102ed90610c6b565b600260015460ff1660048111156108d6576108d6610c2d565b146109235760405162461bcd60e51b815260206004820152601860248201527f5661756c743a206e6f742068616c662072656c6561736564000000000000000060448201526064016102ed565b600082116109685760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b816004600082825461097a9190610da6565b90915550506003546004541080159061099f576001805460ff19166003179055426007555b7fb4c3d1fece72a35bac91fbf712c1e9af695c44dd643a1fe1bc86f606d075c2236002858585856040516109d7959493929190610dec565b60405180910390a150505050565b6109ef8133610aef565b50565b60006109fe838361083b565b610a7c576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055610a343390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016103e1565b5060006103e1565b6000610a90838361083b565b15610a7c576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016103e1565b610af9828261083b565b610b285760405163e2517d3f60e01b81526001600160a01b0382166004820152602481018390526044016102ed565b5050565b600060208284031215610b3e57600080fd5b5035919050565b600060208284031215610b5757600080fd5b81356001600160e01b031981168114610b6f57600080fd5b9392505050565b600080600060608486031215610b8b57600080fd5b505081359360208301359350604090920135919050565b60006020808352835180602085015260005b81811015610bd057858101830151858201604001528201610bb4565b506000604082860101526040601f19601f8301168501019250505092915050565b60008060408385031215610c0457600080fd5b8235915060208301356001600160a01b0381168114610c2257600080fd5b809150509250929050565b634e487b7160e01b600052602160045260246000fd5b6020810160058310610c6557634e487b7160e01b600052602160045260246000fd5b91905290565b6020808252601490820152735661756c743a206174746573746572206f6e6c7960601b604082015260600190565b600181811c90821680610cad57607f821691505b602082108103610ccd57634e487b7160e01b600052602260045260246000fd5b50919050565b8054600090600181811c9080831680610ced57607f831692505b60208084108203610d0e57634e487b7160e01b600052602260045260246000fd5b83885260208801828015610d295760018114610d3f57610d6a565b60ff198716825285151560051b82019750610d6a565b60008981526020902060005b87811015610d6457815484820152908601908401610d4b565b83019850505b5050505050505092915050565b608081526000610d8a6080830187610cd3565b6020830195909552506040810192909252606090910152919050565b808201808211156103e157634e487b7160e01b600052601160045260246000fd5b606081526000610dda6060830186610cd3565b60208301949094525060400152919050565b60a081526000610dff60a0830188610cd3565b90508560208301528460408301528360608301528215156080830152969550505050505056feca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a26469706673582212200d6d4745e1ac302c0d8f4c00da47d440d18c417b9bbcec33fcf0c53b9f10bd5c64736f6c63430008180033a2646970667358221220384703a1e1241dc9419f35c08a3e7ddf99f5fe8508eb456da63a330f11e08f2664736f6c63430008180033", + "bytecode": "0x608060405234801561001057600080fd5b5061001c60003361004d565b506100477fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa93361004d565b506100f9565b6000828152602081815260408083206001600160a01b038516845290915281205460ff166100ef576000838152602081815260408083206001600160a01b03861684529091529020805460ff191660011790556100a73390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016100f3565b5060005b92915050565b611f9d806101086000396000f3fe60806040523480156200001157600080fd5b5060043610620000c35760003560e01c806336568abe116200007a57806336568abe14620001a05780635651c5ec14620001b757806391d1485414620001ce578063a217fddf14620001e5578063d547741f14620001ee578063e7256888146200020557600080fd5b806301ffc9a714620000c8578063248a9ca314620000f457806325de49c314620001295780632f2ff15d14620001595780632f81bb5814620001725780633604dd951462000189575b600080fd5b620000df620000d9366004620008c3565b6200023c565b60405190151581526020015b60405180910390f35b6200011a62000105366004620008f6565b60009081526020819052604090206001015490565b604051908152602001620000eb565b620001406200013a3660046200095c565b62000274565b6040516001600160a01b039091168152602001620000eb565b620001706200016a366004620009bf565b620002ab565b005b6200011a60008051602062001f4883398151915281565b620001706200019a366004620009ee565b620002da565b62000170620001b1366004620009bf565b62000521565b62000140620001c836600462000a38565b6200055c565b620000df620001df366004620009bf565b6200070c565b6200011a600081565b62000170620001ff366004620009bf565b62000735565b620001406200021636600462000ab1565b80516020818301810180516001825292820191909301209152546001600160a01b031681565b60006001600160e01b03198216637965db0b60e01b14806200026e57506301ffc9a760e01b6001600160e01b03198316145b92915050565b6000600183836040516200028a92919062000b6c565b908152604051908190036020019020546001600160a01b0316905092915050565b600082815260208190526040902060010154620002c8816200075e565b620002d483836200076d565b50505050565b6000620002e7816200075e565b6001600160a01b038416620003395760405162461bcd60e51b8152602060048201526013602482015272119858dd1bdc9e4e881e995c9bc81d985d5b1d606a1b60448201526064015b60405180910390fd5b6001600160a01b038216620003915760405162461bcd60e51b815260206004820152601a60248201527f466163746f72793a207a65726f206e6577206174746573746572000000000000604482015260640162000330565b816001600160a01b0316836001600160a01b031603620003ed5760405162461bcd60e51b81526020600482015260166024820152752330b1ba37b93c9d1039b0b6b29030ba3a32b9ba32b960511b604482015260640162000330565b604051632f2ff15d60e01b815260008051602062001f4883398151915260048201526001600160a01b038381166024830152851690632f2ff15d90604401600060405180830381600087803b1580156200044657600080fd5b505af11580156200045b573d6000803e3d6000fd5b505060405163d547741f60e01b815260008051602062001f4883398151915260048201526001600160a01b0386811660248301528716925063d547741f9150604401600060405180830381600087803b158015620004b857600080fd5b505af1158015620004cd573d6000803e3d6000fd5b50505050816001600160a01b0316836001600160a01b0316856001600160a01b03167f30164289c44120f1cdb477894225112b9ef25a4159f8cc01014a87d29df18ef160405160405180910390a450505050565b6001600160a01b03811633146200054b5760405163334bd91960e11b815260040160405180910390fd5b62000557828262000805565b505050565b600060008051602062001f4883398151915262000579816200075e565b84620005c15760405162461bcd60e51b8152602060048201526016602482015275119858dd1bdc9e4e88195b5c1d1e48195d995b9d125960521b604482015260640162000330565b60006001600160a01b031660018787604051620005e092919062000b6c565b908152604051908190036020019020546001600160a01b031614620006405760405162461bcd60e51b8152602060048201526015602482015274466163746f72793a207661756c742065786973747360581b604482015260640162000330565b858585856040516200065290620008b5565b62000661949392919062000ba5565b604051809103906000f0801580156200067e573d6000803e3d6000fd5b50915081600187876040516200069692919062000b6c565b90815260405190819003602001812080546001600160a01b039384166001600160a01b0319909116179055848216918416907f1fdd852a78d545c6024e0d66e640f76a44528ba281c35893d063dcde9fa62a3990620006fb908a908a908a9062000bdc565b60405180910390a350949350505050565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015462000752816200075e565b620002d4838362000805565b6200076a813362000874565b50565b60006200077b83836200070c565b620007fc576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055620007b33390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016200026e565b5060006200026e565b60006200081383836200070c565b15620007fc576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016200026e565b6200088082826200070c565b620008b15760405163e2517d3f60e01b81526001600160a01b03821660048201526024810183905260440162000330565b5050565b6113458062000c0383390190565b600060208284031215620008d657600080fd5b81356001600160e01b031981168114620008ef57600080fd5b9392505050565b6000602082840312156200090957600080fd5b5035919050565b60008083601f8401126200092357600080fd5b50813567ffffffffffffffff8111156200093c57600080fd5b6020830191508360208285010111156200095557600080fd5b9250929050565b600080602083850312156200097057600080fd5b823567ffffffffffffffff8111156200098857600080fd5b620009968582860162000910565b90969095509350505050565b80356001600160a01b0381168114620009ba57600080fd5b919050565b60008060408385031215620009d357600080fd5b82359150620009e560208401620009a2565b90509250929050565b60008060006060848603121562000a0457600080fd5b62000a0f84620009a2565b925062000a1f60208501620009a2565b915062000a2f60408501620009a2565b90509250925092565b6000806000806060858703121562000a4f57600080fd5b843567ffffffffffffffff81111562000a6757600080fd5b62000a758782880162000910565b9095509350506020850135915062000a9060408601620009a2565b905092959194509250565b634e487b7160e01b600052604160045260246000fd5b60006020828403121562000ac457600080fd5b813567ffffffffffffffff8082111562000add57600080fd5b818401915084601f83011262000af257600080fd5b81358181111562000b075762000b0762000a9b565b604051601f8201601f19908116603f0116810190838211818310171562000b325762000b3262000a9b565b8160405282815287602084870101111562000b4c57600080fd5b826020860160208301376000928101602001929092525095945050505050565b8183823760009101908152919050565b81835281816020850137506000828201602090810191909152601f909101601f19169091010190565b60608152600062000bbb60608301868862000b7c565b6020830194909452506001600160a01b039190911660409091015292915050565b60408152600062000bf260408301858762000b7c565b905082602083015294935050505056fe60806040523480156200001157600080fd5b50604051620013453803806200134583398101604081905262000034916200026c565b60008351116200008b5760405162461bcd60e51b815260206004820152601460248201527f5661756c743a20656d707479206576656e74496400000000000000000000000060448201526064015b60405180910390fd5b60008211620000d05760405162461bcd60e51b815260206004820152601060248201526f15985d5b1d0e881e995c9bc81c1bdbdb60821b604482015260640162000082565b6001600160a01b038116620001285760405162461bcd60e51b815260206004820152601460248201527f5661756c743a207a65726f206174746573746572000000000000000000000000604482015260640162000082565b6002620001368482620003ee565b5060038290556001805460ff19169055620001536000336200018a565b50620001807fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9826200018a565b50505050620004ba565b6000828152602081815260408083206001600160a01b038516845290915281205460ff166200022f576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055620001e63390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a450600162000233565b5060005b92915050565b634e487b7160e01b600052604160045260246000fd5b80516001600160a01b03811681146200026757600080fd5b919050565b6000806000606084860312156200028257600080fd5b83516001600160401b03808211156200029a57600080fd5b818601915086601f830112620002af57600080fd5b815181811115620002c457620002c462000239565b604051601f8201601f19908116603f01168101908382118183101715620002ef57620002ef62000239565b816040528281526020935089848487010111156200030c57600080fd5b600091505b8282101562000330578482018401518183018501529083019062000311565b6000848483010152809750505050808601519350505062000354604085016200024f565b90509250925092565b600181811c908216806200037257607f821691505b6020821081036200039357634e487b7160e01b600052602260045260246000fd5b50919050565b601f821115620003e9576000816000526020600020601f850160051c81016020861015620003c45750805b601f850160051c820191505b81811015620003e557828155600101620003d0565b5050505b505050565b81516001600160401b038111156200040a576200040a62000239565b62000422816200041b84546200035d565b8462000399565b602080601f8311600181146200045a5760008415620004415750858301515b600019600386901b1c1916600185901b178555620003e5565b600085815260208120601f198616915b828110156200048b578886015182559484019460019091019084016200046a565b5085821015620004aa5787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b610e7b80620004ca6000396000f3fe608060405234801561001057600080fd5b50600436106101375760003560e01c80636b1024ee116100b8578063b21634821161007c578063b21634821461025e578063c19d93fb14610267578063d547741f14610281578063edd6b5c314610294578063f709b5c31461029d578063fa8f99de146102b057600080fd5b80636b1024ee1461021e5780637249fbb61461022757806391d148541461023a578063a217fddf1461024d578063a99a67e61461025557600080fd5b8063248a9ca3116100ff578063248a9ca3146101b85780632728f333146101db5780632f2ff15d146101e35780632f81bb58146101f657806336568abe1461020b57600080fd5b806301670ba91461013c57806301ffc9a7146101515780630422f17c146101795780630827d05e1461018c578063089cdd0c146101a3575b600080fd5b61014f61014a366004610b2c565b6102b9565b005b61016461015f366004610b45565b6103b0565b60405190151581526020015b60405180910390f35b61014f610187366004610b76565b6103e7565b61019560065481565b604051908152602001610170565b6101ab610528565b6040516101709190610ba2565b6101956101c6366004610b2c565b60009081526020819052604090206001015490565b6101ab6105b6565b61014f6101f1366004610bf1565b6106e0565b610195600080516020610e2683398151915281565b61014f610219366004610bf1565b61070b565b61019560045481565b61014f610235366004610b2c565b610743565b610164610248366004610bf1565b61083b565b610195600081565b61019560075481565b61019560055481565b6001546102749060ff1681565b6040516101709190610c43565b61014f61028f366004610bf1565b610864565b61019560085481565b61014f6102ab366004610b76565b610889565b61019560035481565b6102d1600080516020610e268339815191523361083b565b6102f65760405162461bcd60e51b81526004016102ed90610c6b565b60405180910390fd5b600060015460ff16600481111561030f5761030f610c2d565b1461035c5760405162461bcd60e51b815260206004820152601b60248201527f5661756c743a206e6f74206177616974696e67206465706f736974000000000060448201526064016102ed565b6001805460ff1916811790554260058190556003546040517fdce4f63a820a70db387211b8a0b55c3370c291792cdbc8036cf973441d575fc4926103a592600292869290610d77565b60405180910390a150565b60006001600160e01b03198216637965db0b60e01b14806103e157506301ffc9a760e01b6001600160e01b03198316145b92915050565b6103ff600080516020610e268339815191523361083b565b61041b5760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561043357610433610c2d565b146104745760405162461bcd60e51b815260206004820152601160248201527015985d5b1d0e881b9bdd081b1bd8dad959607a1b60448201526064016102ed565b600082116104b95760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b6001805460ff1916600217905542600655600480548391906000906104df908490610da6565b90915550506040517f511f53603374f23d68700b47cdb96506ca244987097c55aed250d30c4b01ed119061051b90600290869086908690610d77565b60405180910390a1505050565b6002805461053590610c99565b80601f016020809104026020016040519081016040528092919081815260200182805461056190610c99565b80156105ae5780601f10610583576101008083540402835291602001916105ae565b820191906000526020600020905b81548152906001019060200180831161059157829003601f168201915b505050505081565b6060600060015460ff1660048111156105d1576105d1610c2d565b036105f957506040805180820190915260088152674157414954494e4760c01b602082015290565b6001805460ff16600481111561061157610611610c2d565b0361063757506040805180820190915260068152651313d0d2d15160d21b602082015290565b600260015460ff16600481111561065057610650610c2d565b0361067d575060408051808201909152600d81526c1210531197d49153115054d151609a1b602082015290565b600360015460ff16600481111561069657610696610c2d565b036106bd575060408051808201909152600781526614d1551513115160ca1b602082015290565b50604080518082019091526008815267149151955391115160c21b602082015290565b6000828152602081905260409020600101546106fb816109e5565b61070583836109f2565b50505050565b6001600160a01b03811633146107345760405163334bd91960e11b815260040160405180910390fd5b61073e8282610a84565b505050565b61075b600080516020610e268339815191523361083b565b6107775760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561078f5761078f610c2d565b14806107b15750600260015460ff1660048111156107af576107af610c2d565b145b6107f55760405162461bcd60e51b81526020600482015260156024820152745661756c743a206e6f7420726566756e6461626c6560581b60448201526064016102ed565b6001805460ff191660041790554260088190556040517f145dc9770278153bf55661b2995f99690edf947eb7b1ceda6dc584b6dc5c7001916103a5916002918591610dc7565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015461087f816109e5565b6107058383610a84565b6108a1600080516020610e268339815191523361083b565b6108bd5760405162461bcd60e51b81526004016102ed90610c6b565b600260015460ff1660048111156108d6576108d6610c2d565b146109235760405162461bcd60e51b815260206004820152601860248201527f5661756c743a206e6f742068616c662072656c6561736564000000000000000060448201526064016102ed565b600082116109685760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b816004600082825461097a9190610da6565b90915550506003546004541080159061099f576001805460ff19166003179055426007555b7fb4c3d1fece72a35bac91fbf712c1e9af695c44dd643a1fe1bc86f606d075c2236002858585856040516109d7959493929190610dec565b60405180910390a150505050565b6109ef8133610aef565b50565b60006109fe838361083b565b610a7c576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055610a343390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016103e1565b5060006103e1565b6000610a90838361083b565b15610a7c576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016103e1565b610af9828261083b565b610b285760405163e2517d3f60e01b81526001600160a01b0382166004820152602481018390526044016102ed565b5050565b600060208284031215610b3e57600080fd5b5035919050565b600060208284031215610b5757600080fd5b81356001600160e01b031981168114610b6f57600080fd5b9392505050565b600080600060608486031215610b8b57600080fd5b505081359360208301359350604090920135919050565b60006020808352835180602085015260005b81811015610bd057858101830151858201604001528201610bb4565b506000604082860101526040601f19601f8301168501019250505092915050565b60008060408385031215610c0457600080fd5b8235915060208301356001600160a01b0381168114610c2257600080fd5b809150509250929050565b634e487b7160e01b600052602160045260246000fd5b6020810160058310610c6557634e487b7160e01b600052602160045260246000fd5b91905290565b6020808252601490820152735661756c743a206174746573746572206f6e6c7960601b604082015260600190565b600181811c90821680610cad57607f821691505b602082108103610ccd57634e487b7160e01b600052602260045260246000fd5b50919050565b8054600090600181811c9080831680610ced57607f831692505b60208084108203610d0e57634e487b7160e01b600052602260045260246000fd5b83885260208801828015610d295760018114610d3f57610d6a565b60ff198716825285151560051b82019750610d6a565b60008981526020902060005b87811015610d6457815484820152908601908401610d4b565b83019850505b5050505050505092915050565b608081526000610d8a6080830187610cd3565b6020830195909552506040810192909252606090910152919050565b808201808211156103e157634e487b7160e01b600052601160045260246000fd5b606081526000610dda6060830186610cd3565b60208301949094525060400152919050565b60a081526000610dff60a0830188610cd3565b90508560208301528460408301528360608301528215156080830152969550505050505056feca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a2646970667358221220b618b48e4fa68af0296a275855262d6b0d71e9b37cedc5c8780860cf4e3d450364736f6c63430008180033ca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a2646970667358221220b4074a5ca6e955960c1980bf5ffb68c779f3709d1582acd8ac2716285db3a04f64736f6c63430008180033", + "deployedBytecode": "0x60806040523480156200001157600080fd5b5060043610620000c35760003560e01c806336568abe116200007a57806336568abe14620001a05780635651c5ec14620001b757806391d1485414620001ce578063a217fddf14620001e5578063d547741f14620001ee578063e7256888146200020557600080fd5b806301ffc9a714620000c8578063248a9ca314620000f457806325de49c314620001295780632f2ff15d14620001595780632f81bb5814620001725780633604dd951462000189575b600080fd5b620000df620000d9366004620008c3565b6200023c565b60405190151581526020015b60405180910390f35b6200011a62000105366004620008f6565b60009081526020819052604090206001015490565b604051908152602001620000eb565b620001406200013a3660046200095c565b62000274565b6040516001600160a01b039091168152602001620000eb565b620001706200016a366004620009bf565b620002ab565b005b6200011a60008051602062001f4883398151915281565b620001706200019a366004620009ee565b620002da565b62000170620001b1366004620009bf565b62000521565b62000140620001c836600462000a38565b6200055c565b620000df620001df366004620009bf565b6200070c565b6200011a600081565b62000170620001ff366004620009bf565b62000735565b620001406200021636600462000ab1565b80516020818301810180516001825292820191909301209152546001600160a01b031681565b60006001600160e01b03198216637965db0b60e01b14806200026e57506301ffc9a760e01b6001600160e01b03198316145b92915050565b6000600183836040516200028a92919062000b6c565b908152604051908190036020019020546001600160a01b0316905092915050565b600082815260208190526040902060010154620002c8816200075e565b620002d483836200076d565b50505050565b6000620002e7816200075e565b6001600160a01b038416620003395760405162461bcd60e51b8152602060048201526013602482015272119858dd1bdc9e4e881e995c9bc81d985d5b1d606a1b60448201526064015b60405180910390fd5b6001600160a01b038216620003915760405162461bcd60e51b815260206004820152601a60248201527f466163746f72793a207a65726f206e6577206174746573746572000000000000604482015260640162000330565b816001600160a01b0316836001600160a01b031603620003ed5760405162461bcd60e51b81526020600482015260166024820152752330b1ba37b93c9d1039b0b6b29030ba3a32b9ba32b960511b604482015260640162000330565b604051632f2ff15d60e01b815260008051602062001f4883398151915260048201526001600160a01b038381166024830152851690632f2ff15d90604401600060405180830381600087803b1580156200044657600080fd5b505af11580156200045b573d6000803e3d6000fd5b505060405163d547741f60e01b815260008051602062001f4883398151915260048201526001600160a01b0386811660248301528716925063d547741f9150604401600060405180830381600087803b158015620004b857600080fd5b505af1158015620004cd573d6000803e3d6000fd5b50505050816001600160a01b0316836001600160a01b0316856001600160a01b03167f30164289c44120f1cdb477894225112b9ef25a4159f8cc01014a87d29df18ef160405160405180910390a450505050565b6001600160a01b03811633146200054b5760405163334bd91960e11b815260040160405180910390fd5b62000557828262000805565b505050565b600060008051602062001f4883398151915262000579816200075e565b84620005c15760405162461bcd60e51b8152602060048201526016602482015275119858dd1bdc9e4e88195b5c1d1e48195d995b9d125960521b604482015260640162000330565b60006001600160a01b031660018787604051620005e092919062000b6c565b908152604051908190036020019020546001600160a01b031614620006405760405162461bcd60e51b8152602060048201526015602482015274466163746f72793a207661756c742065786973747360581b604482015260640162000330565b858585856040516200065290620008b5565b62000661949392919062000ba5565b604051809103906000f0801580156200067e573d6000803e3d6000fd5b50915081600187876040516200069692919062000b6c565b90815260405190819003602001812080546001600160a01b039384166001600160a01b0319909116179055848216918416907f1fdd852a78d545c6024e0d66e640f76a44528ba281c35893d063dcde9fa62a3990620006fb908a908a908a9062000bdc565b60405180910390a350949350505050565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015462000752816200075e565b620002d4838362000805565b6200076a813362000874565b50565b60006200077b83836200070c565b620007fc576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055620007b33390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016200026e565b5060006200026e565b60006200081383836200070c565b15620007fc576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016200026e565b6200088082826200070c565b620008b15760405163e2517d3f60e01b81526001600160a01b03821660048201526024810183905260440162000330565b5050565b6113458062000c0383390190565b600060208284031215620008d657600080fd5b81356001600160e01b031981168114620008ef57600080fd5b9392505050565b6000602082840312156200090957600080fd5b5035919050565b60008083601f8401126200092357600080fd5b50813567ffffffffffffffff8111156200093c57600080fd5b6020830191508360208285010111156200095557600080fd5b9250929050565b600080602083850312156200097057600080fd5b823567ffffffffffffffff8111156200098857600080fd5b620009968582860162000910565b90969095509350505050565b80356001600160a01b0381168114620009ba57600080fd5b919050565b60008060408385031215620009d357600080fd5b82359150620009e560208401620009a2565b90509250929050565b60008060006060848603121562000a0457600080fd5b62000a0f84620009a2565b925062000a1f60208501620009a2565b915062000a2f60408501620009a2565b90509250925092565b6000806000806060858703121562000a4f57600080fd5b843567ffffffffffffffff81111562000a6757600080fd5b62000a758782880162000910565b9095509350506020850135915062000a9060408601620009a2565b905092959194509250565b634e487b7160e01b600052604160045260246000fd5b60006020828403121562000ac457600080fd5b813567ffffffffffffffff8082111562000add57600080fd5b818401915084601f83011262000af257600080fd5b81358181111562000b075762000b0762000a9b565b604051601f8201601f19908116603f0116810190838211818310171562000b325762000b3262000a9b565b8160405282815287602084870101111562000b4c57600080fd5b826020860160208301376000928101602001929092525095945050505050565b8183823760009101908152919050565b81835281816020850137506000828201602090810191909152601f909101601f19169091010190565b60608152600062000bbb60608301868862000b7c565b6020830194909452506001600160a01b039190911660409091015292915050565b60408152600062000bf260408301858762000b7c565b905082602083015294935050505056fe60806040523480156200001157600080fd5b50604051620013453803806200134583398101604081905262000034916200026c565b60008351116200008b5760405162461bcd60e51b815260206004820152601460248201527f5661756c743a20656d707479206576656e74496400000000000000000000000060448201526064015b60405180910390fd5b60008211620000d05760405162461bcd60e51b815260206004820152601060248201526f15985d5b1d0e881e995c9bc81c1bdbdb60821b604482015260640162000082565b6001600160a01b038116620001285760405162461bcd60e51b815260206004820152601460248201527f5661756c743a207a65726f206174746573746572000000000000000000000000604482015260640162000082565b6002620001368482620003ee565b5060038290556001805460ff19169055620001536000336200018a565b50620001807fca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9826200018a565b50505050620004ba565b6000828152602081815260408083206001600160a01b038516845290915281205460ff166200022f576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055620001e63390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a450600162000233565b5060005b92915050565b634e487b7160e01b600052604160045260246000fd5b80516001600160a01b03811681146200026757600080fd5b919050565b6000806000606084860312156200028257600080fd5b83516001600160401b03808211156200029a57600080fd5b818601915086601f830112620002af57600080fd5b815181811115620002c457620002c462000239565b604051601f8201601f19908116603f01168101908382118183101715620002ef57620002ef62000239565b816040528281526020935089848487010111156200030c57600080fd5b600091505b8282101562000330578482018401518183018501529083019062000311565b6000848483010152809750505050808601519350505062000354604085016200024f565b90509250925092565b600181811c908216806200037257607f821691505b6020821081036200039357634e487b7160e01b600052602260045260246000fd5b50919050565b601f821115620003e9576000816000526020600020601f850160051c81016020861015620003c45750805b601f850160051c820191505b81811015620003e557828155600101620003d0565b5050505b505050565b81516001600160401b038111156200040a576200040a62000239565b62000422816200041b84546200035d565b8462000399565b602080601f8311600181146200045a5760008415620004415750858301515b600019600386901b1c1916600185901b178555620003e5565b600085815260208120601f198616915b828110156200048b578886015182559484019460019091019084016200046a565b5085821015620004aa5787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b610e7b80620004ca6000396000f3fe608060405234801561001057600080fd5b50600436106101375760003560e01c80636b1024ee116100b8578063b21634821161007c578063b21634821461025e578063c19d93fb14610267578063d547741f14610281578063edd6b5c314610294578063f709b5c31461029d578063fa8f99de146102b057600080fd5b80636b1024ee1461021e5780637249fbb61461022757806391d148541461023a578063a217fddf1461024d578063a99a67e61461025557600080fd5b8063248a9ca3116100ff578063248a9ca3146101b85780632728f333146101db5780632f2ff15d146101e35780632f81bb58146101f657806336568abe1461020b57600080fd5b806301670ba91461013c57806301ffc9a7146101515780630422f17c146101795780630827d05e1461018c578063089cdd0c146101a3575b600080fd5b61014f61014a366004610b2c565b6102b9565b005b61016461015f366004610b45565b6103b0565b60405190151581526020015b60405180910390f35b61014f610187366004610b76565b6103e7565b61019560065481565b604051908152602001610170565b6101ab610528565b6040516101709190610ba2565b6101956101c6366004610b2c565b60009081526020819052604090206001015490565b6101ab6105b6565b61014f6101f1366004610bf1565b6106e0565b610195600080516020610e2683398151915281565b61014f610219366004610bf1565b61070b565b61019560045481565b61014f610235366004610b2c565b610743565b610164610248366004610bf1565b61083b565b610195600081565b61019560075481565b61019560055481565b6001546102749060ff1681565b6040516101709190610c43565b61014f61028f366004610bf1565b610864565b61019560085481565b61014f6102ab366004610b76565b610889565b61019560035481565b6102d1600080516020610e268339815191523361083b565b6102f65760405162461bcd60e51b81526004016102ed90610c6b565b60405180910390fd5b600060015460ff16600481111561030f5761030f610c2d565b1461035c5760405162461bcd60e51b815260206004820152601b60248201527f5661756c743a206e6f74206177616974696e67206465706f736974000000000060448201526064016102ed565b6001805460ff1916811790554260058190556003546040517fdce4f63a820a70db387211b8a0b55c3370c291792cdbc8036cf973441d575fc4926103a592600292869290610d77565b60405180910390a150565b60006001600160e01b03198216637965db0b60e01b14806103e157506301ffc9a760e01b6001600160e01b03198316145b92915050565b6103ff600080516020610e268339815191523361083b565b61041b5760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561043357610433610c2d565b146104745760405162461bcd60e51b815260206004820152601160248201527015985d5b1d0e881b9bdd081b1bd8dad959607a1b60448201526064016102ed565b600082116104b95760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b6001805460ff1916600217905542600655600480548391906000906104df908490610da6565b90915550506040517f511f53603374f23d68700b47cdb96506ca244987097c55aed250d30c4b01ed119061051b90600290869086908690610d77565b60405180910390a1505050565b6002805461053590610c99565b80601f016020809104026020016040519081016040528092919081815260200182805461056190610c99565b80156105ae5780601f10610583576101008083540402835291602001916105ae565b820191906000526020600020905b81548152906001019060200180831161059157829003601f168201915b505050505081565b6060600060015460ff1660048111156105d1576105d1610c2d565b036105f957506040805180820190915260088152674157414954494e4760c01b602082015290565b6001805460ff16600481111561061157610611610c2d565b0361063757506040805180820190915260068152651313d0d2d15160d21b602082015290565b600260015460ff16600481111561065057610650610c2d565b0361067d575060408051808201909152600d81526c1210531197d49153115054d151609a1b602082015290565b600360015460ff16600481111561069657610696610c2d565b036106bd575060408051808201909152600781526614d1551513115160ca1b602082015290565b50604080518082019091526008815267149151955391115160c21b602082015290565b6000828152602081905260409020600101546106fb816109e5565b61070583836109f2565b50505050565b6001600160a01b03811633146107345760405163334bd91960e11b815260040160405180910390fd5b61073e8282610a84565b505050565b61075b600080516020610e268339815191523361083b565b6107775760405162461bcd60e51b81526004016102ed90610c6b565b6001805460ff16600481111561078f5761078f610c2d565b14806107b15750600260015460ff1660048111156107af576107af610c2d565b145b6107f55760405162461bcd60e51b81526020600482015260156024820152745661756c743a206e6f7420726566756e6461626c6560581b60448201526064016102ed565b6001805460ff191660041790554260088190556040517f145dc9770278153bf55661b2995f99690edf947eb7b1ceda6dc584b6dc5c7001916103a5916002918591610dc7565b6000918252602082815260408084206001600160a01b0393909316845291905290205460ff1690565b60008281526020819052604090206001015461087f816109e5565b6107058383610a84565b6108a1600080516020610e268339815191523361083b565b6108bd5760405162461bcd60e51b81526004016102ed90610c6b565b600260015460ff1660048111156108d6576108d6610c2d565b146109235760405162461bcd60e51b815260206004820152601860248201527f5661756c743a206e6f742068616c662072656c6561736564000000000000000060448201526064016102ed565b600082116109685760405162461bcd60e51b815260206004820152601260248201527115985d5b1d0e881e995c9bc81c185e5bdd5d60721b60448201526064016102ed565b816004600082825461097a9190610da6565b90915550506003546004541080159061099f576001805460ff19166003179055426007555b7fb4c3d1fece72a35bac91fbf712c1e9af695c44dd643a1fe1bc86f606d075c2236002858585856040516109d7959493929190610dec565b60405180910390a150505050565b6109ef8133610aef565b50565b60006109fe838361083b565b610a7c576000838152602081815260408083206001600160a01b03861684529091529020805460ff19166001179055610a343390565b6001600160a01b0316826001600160a01b0316847f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45060016103e1565b5060006103e1565b6000610a90838361083b565b15610a7c576000838152602081815260408083206001600160a01b0386168085529252808320805460ff1916905551339286917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45060016103e1565b610af9828261083b565b610b285760405163e2517d3f60e01b81526001600160a01b0382166004820152602481018390526044016102ed565b5050565b600060208284031215610b3e57600080fd5b5035919050565b600060208284031215610b5757600080fd5b81356001600160e01b031981168114610b6f57600080fd5b9392505050565b600080600060608486031215610b8b57600080fd5b505081359360208301359350604090920135919050565b60006020808352835180602085015260005b81811015610bd057858101830151858201604001528201610bb4565b506000604082860101526040601f19601f8301168501019250505092915050565b60008060408385031215610c0457600080fd5b8235915060208301356001600160a01b0381168114610c2257600080fd5b809150509250929050565b634e487b7160e01b600052602160045260246000fd5b6020810160058310610c6557634e487b7160e01b600052602160045260246000fd5b91905290565b6020808252601490820152735661756c743a206174746573746572206f6e6c7960601b604082015260600190565b600181811c90821680610cad57607f821691505b602082108103610ccd57634e487b7160e01b600052602260045260246000fd5b50919050565b8054600090600181811c9080831680610ced57607f831692505b60208084108203610d0e57634e487b7160e01b600052602260045260246000fd5b83885260208801828015610d295760018114610d3f57610d6a565b60ff198716825285151560051b82019750610d6a565b60008981526020902060005b87811015610d6457815484820152908601908401610d4b565b83019850505b5050505050505092915050565b608081526000610d8a6080830187610cd3565b6020830195909552506040810192909252606090910152919050565b808201808211156103e157634e487b7160e01b600052601160045260246000fd5b606081526000610dda6060830186610cd3565b60208301949094525060400152919050565b60a081526000610dff60a0830188610cd3565b90508560208301528460408301528360608301528215156080830152969550505050505056feca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a2646970667358221220b618b48e4fa68af0296a275855262d6b0d71e9b37cedc5c8780860cf4e3d450364736f6c63430008180033ca77cd8f9856ef5fcd36f6b15bf469fe216c2f9114f60dd031f0a2b97b67aaa9a2646970667358221220b4074a5ca6e955960c1980bf5ffb68c779f3709d1582acd8ac2716285db3a04f64736f6c63430008180033", "linkReferences": {}, "deployedLinkReferences": {} } diff --git a/contracts/cache/solidity-files-cache.json b/contracts/cache/solidity-files-cache.json index a4100d9..d5f49e0 100644 --- a/contracts/cache/solidity-files-cache.json +++ b/contracts/cache/solidity-files-cache.json @@ -2,8 +2,8 @@ "_format": "hh-sol-cache-2", "files": { "/Users/eugenius/Work/HackVillage/contracts/PrizeVault.sol": { - "lastModificationDate": 1790193833632, - "contentHash": "2647bf12a0f4ac0e7c36771fb14698a1", + "lastModificationDate": 1790529753911, + "contentHash": "660381956562f20dde2bcffaa3252b8b", "sourceName": "contracts/PrizeVault.sol", "solcConfig": { "version": "0.8.24", @@ -226,8 +226,8 @@ ] }, "/Users/eugenius/Work/HackVillage/contracts/PrizeVaultFactory.sol": { - "lastModificationDate": 1790193833811, - "contentHash": "ad4347fded61c2aa93c8a09a943bedd6", + "lastModificationDate": 1790529792025, + "contentHash": "0cc806acea3cafdac54037a03cd88fb2", "sourceName": "contracts/PrizeVaultFactory.sol", "solcConfig": { "version": "0.8.24", @@ -263,6 +263,192 @@ "artifacts": [ "PrizeVaultFactory" ] + }, + "/Users/eugenius/Work/HackVillage/node_modules/.pnpm/@openzeppelin+contracts@5.6.1/node_modules/@openzeppelin/contracts/access/AccessControl.sol": { + "lastModificationDate": 1790224198615, + "contentHash": "798a29e3c30dbd3eda1a67add26571e2", + "sourceName": "@openzeppelin/contracts/access/AccessControl.sol", + "solcConfig": { + "version": "0.8.24", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "paris", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode", + "evm.deployedBytecode", + "evm.methodIdentifiers", + "metadata" + ], + "": [ + "ast" + ] + } + } + } + }, + "imports": [ + "./IAccessControl.sol", + "../utils/Context.sol", + "../utils/introspection/ERC165.sol" + ], + "versionPragmas": [ + "^0.8.20" + ], + "artifacts": [ + "AccessControl" + ] + }, + "/Users/eugenius/Work/HackVillage/node_modules/.pnpm/@openzeppelin+contracts@5.6.1/node_modules/@openzeppelin/contracts/utils/Context.sol": { + "lastModificationDate": 1790224198619, + "contentHash": "67bfbc07588eb8683b3fd8f6f909563e", + "sourceName": "@openzeppelin/contracts/utils/Context.sol", + "solcConfig": { + "version": "0.8.24", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "paris", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode", + "evm.deployedBytecode", + "evm.methodIdentifiers", + "metadata" + ], + "": [ + "ast" + ] + } + } + } + }, + "imports": [], + "versionPragmas": [ + "^0.8.20" + ], + "artifacts": [ + "Context" + ] + }, + "/Users/eugenius/Work/HackVillage/node_modules/.pnpm/@openzeppelin+contracts@5.6.1/node_modules/@openzeppelin/contracts/access/IAccessControl.sol": { + "lastModificationDate": 1790224198633, + "contentHash": "def1e8f7b6cac577cf2600655bf3bdf8", + "sourceName": "@openzeppelin/contracts/access/IAccessControl.sol", + "solcConfig": { + "version": "0.8.24", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "paris", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode", + "evm.deployedBytecode", + "evm.methodIdentifiers", + "metadata" + ], + "": [ + "ast" + ] + } + } + } + }, + "imports": [], + "versionPragmas": [ + ">=0.8.4" + ], + "artifacts": [ + "IAccessControl" + ] + }, + "/Users/eugenius/Work/HackVillage/node_modules/.pnpm/@openzeppelin+contracts@5.6.1/node_modules/@openzeppelin/contracts/utils/introspection/ERC165.sol": { + "lastModificationDate": 1790224198625, + "contentHash": "0906d06dca25210d4696dcef6dad2909", + "sourceName": "@openzeppelin/contracts/utils/introspection/ERC165.sol", + "solcConfig": { + "version": "0.8.24", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "paris", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode", + "evm.deployedBytecode", + "evm.methodIdentifiers", + "metadata" + ], + "": [ + "ast" + ] + } + } + } + }, + "imports": [ + "./IERC165.sol" + ], + "versionPragmas": [ + "^0.8.20" + ], + "artifacts": [ + "ERC165" + ] + }, + "/Users/eugenius/Work/HackVillage/node_modules/.pnpm/@openzeppelin+contracts@5.6.1/node_modules/@openzeppelin/contracts/utils/introspection/IERC165.sol": { + "lastModificationDate": 1790224198636, + "contentHash": "7074c93b1ea0a122063f26ddd1db1032", + "sourceName": "@openzeppelin/contracts/utils/introspection/IERC165.sol", + "solcConfig": { + "version": "0.8.24", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "evmVersion": "paris", + "outputSelection": { + "*": { + "*": [ + "abi", + "evm.bytecode", + "evm.deployedBytecode", + "evm.methodIdentifiers", + "metadata" + ], + "": [ + "ast" + ] + } + } + } + }, + "imports": [], + "versionPragmas": [ + ">=0.4.16" + ], + "artifacts": [ + "IERC165" + ] } } } diff --git a/contracts/test/prize-vault.test.ts b/contracts/test/prize-vault.test.ts index 7be5b49..53405f2 100644 --- a/contracts/test/prize-vault.test.ts +++ b/contracts/test/prize-vault.test.ts @@ -159,3 +159,87 @@ describe("PrizeVault state machine", () => { await expect(vault.connect(outsider).lock(REF)).to.be.revertedWith("Vault: attester only"); }); }); + +describe("Attester rotation (hot key holds no admin — rotate via factory)", () => { + const EVENT_ID = "evt_key_ceremony"; + const REF = ethers.id("paystack-ref-rotate"); + const WINNER = ethers.id("user_rotation"); + const TXREF = ethers.id("transfer-ref-rotate"); + + async function deployViaFactory() { + const [deployer, attester, newAttester, outsider] = await ethers.getSigners(); + const Factory = await ethers.getContractFactory("PrizeVaultFactory"); + const factory = await Factory.connect(deployer).deploy(); + await factory.waitForDeployment(); + await factory.createVault(EVENT_ID, 500_000, attester.address); + const vault = await ethers.getContractAt("PrizeVault", await factory.vaultFor(EVENT_ID)); + return { deployer, attester, newAttester, outsider, factory, vault }; + } + + it("grants the attester ONLY ATTESTER_ROLE — the factory keeps DEFAULT_ADMIN_ROLE", async () => { + const { attester, factory, vault } = await deployViaFactory(); + const ADMIN = await vault.DEFAULT_ADMIN_ROLE(); + const ATTESTER = await vault.ATTESTER_ROLE(); + expect(await vault.hasRole(ATTESTER, attester.address)).to.equal(true); + expect(await vault.hasRole(ADMIN, attester.address)).to.equal(false); + expect(await vault.hasRole(ADMIN, await factory.getAddress())).to.equal(true); + }); + + it("blocks the attester from calling grantRole/revokeRole on the vault", async () => { + const { attester, newAttester, vault } = await deployViaFactory(); + const ATTESTER = await vault.ATTESTER_ROLE(); + await expect( + vault.connect(attester).grantRole(ATTESTER, newAttester.address) + ).to.be.revertedWithCustomError(vault, "AccessControlUnauthorizedAccount"); + await expect( + vault.connect(attester).revokeRole(ATTESTER, attester.address) + ).to.be.revertedWithCustomError(vault, "AccessControlUnauthorizedAccount"); + }); + + it("factory admin rotates: new key attests, old key is dead on-chain", async () => { + const { attester, newAttester, factory, vault } = await deployViaFactory(); + const vaultAddress = await vault.getAddress(); + + await expect(factory.rotateAttester(vaultAddress, attester.address, newAttester.address)) + .to.emit(factory, "AttesterRotated") + .withArgs(vaultAddress, attester.address, newAttester.address); + + const ATTESTER = await vault.ATTESTER_ROLE(); + expect(await vault.hasRole(ATTESTER, newAttester.address)).to.equal(true); + expect(await vault.hasRole(ATTESTER, attester.address)).to.equal(false); + + // The new key can attest. + await expect(vault.connect(newAttester).lock(REF)) + .to.emit(vault, "DepositLocked") + .withArgs(EVENT_ID, REF, 500_000, anyValue); + + // The revoked key cannot — the role check fires before any state check. + await expect(vault.connect(attester).recordInstantPayout(WINNER, 250_000, TXREF)) + .to.be.revertedWith("Vault: attester only"); + }); + + it("rejects rotation from non-admin callers — including the attester itself", async () => { + const { attester, newAttester, outsider, factory, vault } = await deployViaFactory(); + const vaultAddress = await vault.getAddress(); + await expect( + factory.connect(outsider).rotateAttester(vaultAddress, attester.address, newAttester.address) + ).to.be.revertedWithCustomError(factory, "AccessControlUnauthorizedAccount"); + await expect( + factory.connect(attester).rotateAttester(vaultAddress, attester.address, newAttester.address) + ).to.be.revertedWithCustomError(factory, "AccessControlUnauthorizedAccount"); + }); + + it("validates rotation inputs", async () => { + const { attester, newAttester, factory, vault } = await deployViaFactory(); + const vaultAddress = await vault.getAddress(); + await expect( + factory.rotateAttester(ethers.ZeroAddress, attester.address, newAttester.address) + ).to.be.revertedWith("Factory: zero vault"); + await expect( + factory.rotateAttester(vaultAddress, attester.address, ethers.ZeroAddress) + ).to.be.revertedWith("Factory: zero new attester"); + await expect( + factory.rotateAttester(vaultAddress, attester.address, attester.address) + ).to.be.revertedWith("Factory: same attester"); + }); +}); diff --git a/db/migrations/9_5_rate_limit_bucket/migration.sql b/db/migrations/9_5_rate_limit_bucket/migration.sql new file mode 100644 index 0000000..c29b239 --- /dev/null +++ b/db/migrations/9_5_rate_limit_bucket/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "RateLimitBucket" ( + "key" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "resetAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RateLimitBucket_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE INDEX "RateLimitBucket_resetAt_idx" ON "RateLimitBucket"("resetAt"); diff --git a/db/migrations/9_6_categories_not_null/migration.sql b/db/migrations/9_6_categories_not_null/migration.sql new file mode 100644 index 0000000..37c1eda --- /dev/null +++ b/db/migrations/9_6_categories_not_null/migration.sql @@ -0,0 +1,6 @@ +-- Close the drift left by 9_1_hackathon_categories: it added "categories" +-- nullable with a default, but schema.prisma declares the column NOT NULL. +-- Backfill existing rows first, then enforce the constraint. +UPDATE "Event" SET "categories" = ARRAY[]::TEXT[] WHERE "categories" IS NULL; + +ALTER TABLE "Event" ALTER COLUMN "categories" SET NOT NULL; diff --git a/db/schema.prisma b/db/schema.prisma index c415a1f..7081e2e 100644 --- a/db/schema.prisma +++ b/db/schema.prisma @@ -927,6 +927,17 @@ model Dispute { winner Winner @relation(fields: [winnerId], references: [id], onDelete: Cascade) } +// ── Rate limiting (durable fixed-window buckets — shared across serverless +// instances; the in-memory limiter in lib/rate-limit.ts is the fallback) ──── + +model RateLimitBucket { + key String @id + count Int @default(0) + resetAt DateTime + + @@index([resetAt]) +} + // ── Newsletter (public marketing signup — no User account required) ──────── /// Public newsletter list. Anonymous by design: no relation to User, since diff --git a/db/seeds/seed.ts b/db/seeds/seed.ts index afa02c0..122b874 100644 --- a/db/seeds/seed.ts +++ b/db/seeds/seed.ts @@ -1,18 +1,186 @@ /** - * Development seed — a realistic Nairobi demo (plan §17 Phase 2): one LIVE - * Prize Verified event with teams and submissions, plus one PENDING_DEPOSIT - * event so every UI state is demonstrable locally. + * Development seed — a realistic Nairobi demo (plan §17): events across the + * full lifecycle (DRAFT → PENDING_DEPOSIT → LIVE/IN_PROGRESS → JUDGING → + * WINNERS_ANNOUNCED → SETTLED) with the money graph to match: escrow vaults, + * deposits, ledger entries, winners, payouts, milestones, a dispute, judging + * artifacts, media, trust, hiring, and a due legacy check-in. * * Demo accounts all use the password: `demopass123` (dev-only). - * Idempotent: upserts keyed by slug/email/handle. + * Idempotent: upserts keyed by slug/email/handle/unique business keys. */ +import { createHash } from "node:crypto"; import { hash } from "@node-rs/argon2"; -import { PrismaClient } from "@prisma/client"; +import { PrismaClient, type Team, type User, type Winner } from "@prisma/client"; const prisma = new PrismaClient(); const DAY = 24 * 60 * 60 * 1000; const now = Date.now(); +/** Deterministic, unique fake chain tx hash (matches the 0x + 64 hex shape). */ +function txHashFor(slug: string, kind: string): string { + return `0x${createHash("sha256").update(`hv-seed:${slug}:${kind}`).digest("hex")}`; +} + +/** + * The escrow graph every LOCKED-or-beyond event needs: a SUCCEEDED deposit + * (gross = pool × 1.05, ADR-012), a LOCKED vault (contractAddress null in + * simulation), and the VAULT_CREATED + DEPOSIT_LOCKED ledger entries the + * attestation jobs would have written (reconcile.ts checks this invariant). + */ +async function seedEscrow( + event: { id: string; slug: string }, + poolKes: number, + paidAt: Date +): Promise { + const gross = Math.round(poolKes * 1.05); + const depositRef = `seed-dep-${event.slug}`; + await prisma.deposit.upsert({ + where: { paystackReference: depositRef }, + create: { + eventId: event.id, + paystackReference: depositRef, + grossAmountKes: gross, + poolAmountKes: poolKes, + feeKes: gross - poolKes, + channel: "card", + status: "SUCCEEDED", + paidAt, + }, + update: {}, + }); + const lockTx = txHashFor(event.slug, "lock"); + await prisma.vaultState.upsert({ + where: { eventId: event.id }, + create: { + eventId: event.id, + amountKes: poolKes, + contractAddress: null, + chainState: "LOCKED", + lockedAt: new Date(paidAt.getTime() + 60 * 60 * 1000), + lastTxHash: lockTx, + }, + update: {}, + }); + await prisma.ledgerEntry.upsert({ + where: { txHash: txHashFor(event.slug, "vault") }, + create: { + eventId: event.id, + type: "VAULT_CREATED", + payload: { amountKes: poolKes, contractAddress: null }, + txHash: txHashFor(event.slug, "vault"), + blockNumber: 1, + }, + update: {}, + }); + await prisma.ledgerEntry.upsert({ + where: { txHash: lockTx }, + create: { + eventId: event.id, + type: "DEPOSIT_LOCKED", + payload: { amountKes: poolKes, paystackRef: depositRef }, + txHash: lockTx, + blockNumber: 2, + }, + update: {}, + }); +} + +/** A team with JOINED members, keyed by invite code. */ +async function seedTeam( + eventId: string, + input: { inviteCode: string; name: string; leader: User; members: User[]; status?: "OPEN" | "LOCKED" } +): Promise { + const team = await prisma.team.upsert({ + where: { inviteCode: input.inviteCode }, + create: { + eventId, + name: input.name, + leaderId: input.leader.id, + inviteCode: input.inviteCode, + status: input.status ?? "OPEN", + }, + update: {}, + }); + for (const member of input.members) { + await prisma.teamMember.upsert({ + where: { teamId_userId: { teamId: team.id, userId: member.id } }, + create: { teamId: team.id, userId: member.id, status: "JOINED" }, + update: {}, + }); + } + return team; +} + +/** + * One announced winner with everything announceWinners + the instant payout + * job would have persisted: the Winner row, its Milestone, a SUCCEEDED + * INSTANT payout, and the matching INSTANT_PAYOUT ledger entry. + */ +async function seedWinner( + event: { id: string; slug: string }, + input: { team: Team; leader: User; place: number; amountKes: number; announcedAt: Date } +): Promise { + const winner = await prisma.winner.upsert({ + where: { eventId_place: { eventId: event.id, place: input.place } }, + create: { + eventId: event.id, + teamId: input.team.id, + place: input.place, + userId: input.leader.id, + amountKes: input.amountKes, + milestoneRequired: true, + announcedAt: input.announcedAt, + }, + update: {}, + }); + await prisma.milestone.upsert({ + where: { winnerId: winner.id }, + create: { + winnerId: winner.id, + title: `Milestone handover: place ${input.place}`, + description: "Deliver and confirm the handover to release the final 50%.", + dueAt: new Date(input.announcedAt.getTime() + 30 * DAY), + }, + update: {}, + }); + const instantKes = Math.ceil(input.amountKes * 0.5); + const paidAt = new Date(input.announcedAt.getTime() + 45 * 60 * 1000); + const instantRef = `seed-trf-${event.slug}-p${input.place}-instant`; + await prisma.payout.upsert({ + where: { idempotencyKey: `${winner.id}:INSTANT` }, + create: { + winnerId: winner.id, + tranche: "INSTANT", + amountKes: instantKes, + idempotencyKey: `${winner.id}:INSTANT`, + paystackTransferCode: `seed-tc-${event.slug}-p${input.place}-instant`, + paystackReference: instantRef, + recipientCode: `seed-rcp-${input.leader.handle}`, + status: "SUCCEEDED", + attemptCount: 1, + paidAt, + }, + update: {}, + }); + await prisma.ledgerEntry.upsert({ + where: { txHash: txHashFor(event.slug, `instant-p${input.place}`) }, + create: { + eventId: event.id, + type: "INSTANT_PAYOUT", + payload: { + winnerId: winner.id, + winnerHandle: input.leader.handle, + amountKes: instantKes, + txRef: instantRef, + }, + txHash: txHashFor(event.slug, `instant-p${input.place}`), + blockNumber: 3 + input.place, + }, + update: {}, + }); + return winner; +} + async function main(): Promise { // ── Organization + organizer ──────────────────────────────────────── const organizer = await prisma.user.upsert({ @@ -101,6 +269,19 @@ async function main(): Promise { }); developers.push(user); } + const [wanjiku, kamau, zawadi, chirchir, mumbi] = developers; + + // Winners need a verified payout recipient before prizes can flow (the + // announce gate, plan §6.1) — give the three seeded winners one. + for (const winnerUser of [wanjiku, zawadi, mumbi]) { + await prisma.developerProfile.update({ + where: { userId: winnerUser.id }, + data: { + payoutRecipientCode: `seed-rcp-${winnerUser.handle}`, + payoutMethod: "MPESA", + }, + }); + } // ── Event 1: LIVE & Prize Verified (demo — escrow state simulated) ── const liveEvent = await prisma.event.upsert({ @@ -249,7 +430,7 @@ async function main(): Promise { location: string | null; startDay: number; lengthDays: number; - status: "LIVE" | "PENDING_DEPOSIT" | "SETTLED"; + status: "LIVE" | "IN_PROGRESS" | "PENDING_DEPOSIT" | "SETTLED"; verified: boolean; rolesWanted: string[]; categories: string[]; @@ -308,7 +489,7 @@ async function main(): Promise { location: null, startDay: -4, lengthDays: 21, - status: "LIVE", + status: "IN_PROGRESS", verified: true, rolesWanted: ["frontend", "backend", "civic"], categories: ["civic"], @@ -540,23 +721,645 @@ async function main(): Promise { create: { eventId: liveEvent.id, userId: judge.id, status: "ACTIVE" }, update: {}, }); + const RUBRIC_CRITERIA = [ + { id: "innovation", label: "Innovation", weight: 25 }, + { id: "execution", label: "Execution & completeness", weight: 25 }, + { id: "impact", label: "Impact on the problem", weight: 20 }, + { id: "presentation", label: "Presentation & demo", weight: 15 }, + { id: "quality", label: "Code quality & repo", weight: 15 }, + ]; await prisma.rubric.upsert({ where: { eventId: liveEvent.id }, create: { eventId: liveEvent.id, - criteria: [ - { id: "innovation", label: "Innovation", weight: 25 }, - { id: "execution", label: "Execution & completeness", weight: 25 }, - { id: "impact", label: "Impact on the problem", weight: 20 }, - { id: "presentation", label: "Presentation & demo", weight: 15 }, - { id: "quality", label: "Code quality & repo", weight: 15 }, + criteria: RUBRIC_CRITERIA, + }, + update: {}, + }); + + // ── Escrow: every LIVE/IN_PROGRESS event has a LOCKED vault ────────── + // The LIVE-requires-LOCKED invariant: deposit SUCCEEDED (gross = pool × + // 1.05), VaultState LOCKED (contractAddress null in simulation), and the + // VAULT_CREATED + DEPOSIT_LOCKED ledger entries the attestation jobs write. + const runningEvents = await prisma.event.findMany({ + where: { + slug: { + in: [ + "fintech-for-matatu-culture", + "climate-data-sprint", + "coastal-agritech-build", + "civic-tech-build-sprint", + "clean-energy-hack", + "agri-supply-chain-challenge", + ], + }, + status: { in: ["LIVE", "IN_PROGRESS"] }, + }, + include: { prizes: true }, + }); + for (const event of runningEvents) { + const poolKes = event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0); + await seedEscrow(event, poolKes, event.prizeVerifiedAt ?? event.publishedAt ?? new Date(now)); + } + + // ── A DRAFT event (the organizer's unpublished work in progress) ────── + const draftEvent = await prisma.event.upsert({ + where: { slug: "sacco-digital-hack" }, + create: { + orgId: org.id, + isDemo: true, + slug: "sacco-digital-hack", + title: "Sacco Digital Hack", + summary: "Member statements, loan tracking, and dividend tools for Kenya's SACCO movement.", + problemStatement: + "SACCOs hold savings for millions of Kenyans but many still run member records in spreadsheets. Build tools for member statements, loan tracking, or dividend calculations that a SACCO back office can actually run.", + venueType: "PHYSICAL", + location: "Nairobi", + startsAt: new Date(now + 60 * DAY), + endsAt: new Date(now + 62 * DAY), + registrationDeadline: new Date(now + 58 * DAY), + maxTeams: 15, + rolesWanted: ["fintech", "backend"], + categories: ["fintech"], + status: "DRAFT", + }, + update: {}, + }); + await prisma.prizeBreakdown.upsert({ + where: { eventId_place: { eventId: draftEvent.id, place: 1 } }, + create: { + eventId: draftEvent.id, + place: 1, + label: "1st place", + amountKes: 100_000, + milestoneRequired: true, + }, + update: { label: "1st place", amountKes: 100_000 }, + }); + + // ── A JUDGING event: ended, judge mid-review, nothing finalized ─────── + const judgingEvent = await prisma.event.upsert({ + where: { slug: "shamba-data-hack" }, + create: { + orgId: org.id, + isDemo: true, + slug: "shamba-data-hack", + title: "Shamba Data Hack", + summary: "Farm-gate data tools that turn county crop reports into decisions co-ops can act on.", + problemStatement: + "County governments collect crop and livestock data that never reaches the farmers it describes. Build tools that turn those reports into planting, grazing, or market decisions a co-op can act on, in English and Kiswahili.", + rules: "Teams of 2 to 5. Winners retain their IP; organizers get a demo license.", + venueType: "PHYSICAL", + location: "Egerton University, Njoro", + startsAt: new Date(now - 12 * DAY), + endsAt: new Date(now - 10 * DAY), + registrationDeadline: new Date(now - 14 * DAY), + maxTeams: 12, + rolesWanted: ["data", "mobile", "backend"], + categories: ["agritech", "ai"], + coverUrl: "/marketing/hackathons/agritech-hackathon-kenya.webp", + status: "JUDGING", + prizeVerifiedAt: new Date(now - 13 * DAY), + mediaDeadlineAt: new Date(now - 8 * DAY), + publishedAt: new Date(now - 16 * DAY), + }, + update: {}, + }); + for (const [place, label, amount] of [ + [1, "1st place", 150_000], + [2, "2nd place", 75_000], + ] as const) { + await prisma.prizeBreakdown.upsert({ + where: { eventId_place: { eventId: judgingEvent.id, place } }, + create: { eventId: judgingEvent.id, place, label, amountKes: amount, milestoneRequired: true }, + update: { label, amountKes: amount }, + }); + } + await seedEscrow(judgingEvent, 225_000, new Date(now - 13 * DAY)); + + const teamC = await seedTeam(judgingEvent.id, { + inviteCode: "teamcodecc", + name: "Mavuno Tech", + leader: mumbi, + members: [mumbi, chirchir], + status: "LOCKED", + }); + await prisma.submission.upsert({ + where: { teamId: teamC.id }, + create: { + teamId: teamC.id, + repoUrl: "https://github.com/mavuno-tech/shamba-reports", + demoUrl: "https://shamba-reports.vercel.app", + description: + "Turns county crop PDFs into a weekly SMS advisory for co-op members: planting windows, expected prices, and pest alerts in Kiswahili.", + splitDeclaration: [ + { userId: mumbi.id, percent: 60 }, + { userId: chirchir.id, percent: 40 }, ], }, update: {}, }); + await prisma.judgeAssignment.upsert({ + where: { eventId_userId: { eventId: judgingEvent.id, userId: judge.id } }, + create: { eventId: judgingEvent.id, userId: judge.id, status: "ACTIVE" }, + update: {}, + }); + await prisma.rubric.upsert({ + where: { eventId: judgingEvent.id }, + create: { eventId: judgingEvent.id, criteria: RUBRIC_CRITERIA }, + update: {}, + }); + const judgingScores: [string, number][] = [ + ["innovation", 8], + ["execution", 7], + ["impact", 9], + ["presentation", 8], + ["quality", 6], + ]; + for (const [criterionId, value] of judgingScores) { + await prisma.score.upsert({ + where: { + judgeId_teamId_criterionId: { judgeId: judge.id, teamId: teamC.id, criterionId }, + }, + create: { judgeId: judge.id, teamId: teamC.id, criterionId, value }, + update: { value }, + }); + } + // Structured feedback (one per kind) but the review is NOT finalized — + // the judging flow stays demonstrable end to end. + await prisma.feedback.deleteMany({ where: { judgeId: judge.id, teamId: teamC.id } }); + await prisma.feedback.createMany({ + data: [ + { + judgeId: judge.id, + teamId: teamC.id, + kind: "STRENGTH", + point: "The SMS advisory worked end to end on a real feature phone during the demo — no shortcuts.", + }, + { + judgeId: judge.id, + teamId: teamC.id, + kind: "IMPROVEMENT", + point: "PDF parsing fails silently on two county formats; surface the error instead of skipping rows.", + }, + { + judgeId: judge.id, + teamId: teamC.id, + kind: "NEXT_STEP", + point: "Pilot with one co-op in Njoro and instrument weekly active farmers before scaling.", + }, + ], + }); + await prisma.judgingProgress.upsert({ + where: { judgeId_teamId: { judgeId: judge.id, teamId: teamC.id } }, + create: { judgeId: judge.id, teamId: teamC.id, finalizedAt: null }, + update: {}, + }); + // ── A WINNERS_ANNOUNCED event: instant tranche paid, milestone flow ─── + const announcedEvent = await prisma.event.upsert({ + where: { slug: "blue-economy-hack" }, + create: { + orgId: org.id, + isDemo: true, + slug: "blue-economy-hack", + title: "Blue Economy Hack", + summary: "Cold chain, catch reporting, and market tools for Lake Victoria's fishing communities.", + problemStatement: + "Lake Victoria's fishers lose income to spoilage and middlemen because catch data never leaves the beach. Build tools for catch logging, cold chain tracking, or direct market access that work where the network is weakest.", + rules: "Teams of 2 to 5. Winners retain their IP; organizers get a demo license.", + venueType: "HYBRID", + location: "LakeHub, Kisumu", + startsAt: new Date(now - 8 * DAY), + endsAt: new Date(now - 6 * DAY), + registrationDeadline: new Date(now - 10 * DAY), + maxTeams: 16, + rolesWanted: ["mobile", "data", "design"], + categories: ["climate", "civic"], + coverUrl: "/marketing/hackathons/climate-tech-hackathon-kenya.webp", + status: "WINNERS_ANNOUNCED", + prizeVerifiedAt: new Date(now - 9 * DAY), + mediaDeadlineAt: new Date(now - 4 * DAY), + publishedAt: new Date(now - 12 * DAY), + }, + update: {}, + }); + for (const [place, label, amount] of [ + [1, "1st place", 200_000], + [2, "2nd place", 100_000], + [3, "3rd place", 50_000], + ] as const) { + await prisma.prizeBreakdown.upsert({ + where: { eventId_place: { eventId: announcedEvent.id, place } }, + create: { eventId: announcedEvent.id, place, label, amountKes: amount, milestoneRequired: true }, + update: { label, amountKes: amount }, + }); + } + await seedEscrow(announcedEvent, 350_000, new Date(now - 9 * DAY)); + + const teamD = await seedTeam(announcedEvent.id, { + inviteCode: "teamcodedd", + name: "Samaki Flow", + leader: wanjiku, + members: [wanjiku, kamau], + status: "LOCKED", + }); + const teamE = await seedTeam(announcedEvent.id, { + inviteCode: "teamcodeee", + name: "Pier Nine", + leader: zawadi, + members: [zawadi, chirchir], + status: "LOCKED", + }); + const teamF = await seedTeam(announcedEvent.id, { + inviteCode: "teamcodeff", + name: "Nuance Labs", + leader: mumbi, + members: [mumbi], + status: "LOCKED", + }); + const announcedAt = new Date(now - 5 * DAY); + for (const [team, leader, place, repo] of [ + [teamD, wanjiku, 1, "https://github.com/samaki-flow/beach-ledger"], + [teamE, zawadi, 2, "https://github.com/pier-nine/cold-chain"], + [teamF, mumbi, 3, "https://github.com/nuance-labs/soko-fresh"], + ] as const) { + const submission = await prisma.submission.upsert({ + where: { teamId: team.id }, + create: { + teamId: team.id, + repoUrl: repo, + demoUrl: `${repo.replace("https://github.com/", "https://").replace("/", "-")}.vercel.app`, + description: `Blue Economy Hack submission by ${team.name}: catch logging and cold chain tooling for Lake Victoria landing beaches.`, + splitDeclaration: [{ userId: leader.id, percent: 100 }], + }, + update: {}, + }); + const winner = await seedWinner(announcedEvent, { + team, + leader, + place, + amountKes: place === 1 ? 200_000 : place === 2 ? 100_000 : 50_000, + announcedAt, + }); + // The announcement also materializes each winner's portfolio item. + await prisma.portfolioItem.upsert({ + where: { submissionId: submission.id }, + create: { + submissionId: submission.id, + developerId: leader.id, + title: `${team.name}: winning submission`, + summary: submission.description, + repoUrl: submission.repoUrl, + demoUrl: submission.demoUrl, + lifecycle: "DEMO", + }, + update: {}, + }); + // 1st place: milestone confirmed by the organizer → MILESTONE payout + // QUEUED, waiting on the payout worker (the milestone flow in action). + if (place === 1) { + const confirmedAt = new Date(now - 2 * DAY); + await prisma.milestone.update({ + where: { winnerId: winner.id }, + data: { confirmedBy: organizer.id, confirmedAt }, + }); + await prisma.payout.upsert({ + where: { idempotencyKey: `${winner.id}:MILESTONE` }, + create: { + winnerId: winner.id, + tranche: "MILESTONE", + amountKes: 100_000, + idempotencyKey: `${winner.id}:MILESTONE`, + recipientCode: `seed-rcp-${leader.handle}`, + status: "QUEUED", + queuedAt: confirmedAt, + }, + // Reseeding restores the demo state: the test suite's stuck-payout + // sweep drives any stale QUEUED payout on the shared dev database. + update: { + status: "QUEUED", + attemptCount: 0, + paidAt: null, + paystackReference: null, + paystackTransferCode: null, + lastError: null, + queuedAt: confirmedAt, + }, + }); + } + // 2nd place: milestone still unconfirmed and the winner opened a + // dispute — the admin arbitration queue has something to show. + if (place === 2) { + await prisma.dispute.upsert({ + where: { winnerId: winner.id }, + create: { + winnerId: winner.id, + openedBy: leader.id, + claim: + "We delivered the milestone handover (cold chain pilot at Dunga beach) over a week ago, but the organizer has not confirmed it and the final 50% is still locked.", + status: "OPEN", + }, + update: {}, + }); + } + } + // Instant tranches are all paid → the vault is HALF_RELEASED. + await prisma.vaultState.update({ + where: { eventId: announcedEvent.id }, + data: { + chainState: "HALF_RELEASED", + halfReleasedAt: new Date(announcedAt.getTime() + 60 * 60 * 1000), + lastTxHash: txHashFor(announcedEvent.slug, "instant-p3"), + }, + }); + + // ── One SETTLED event upgraded to the full graph ────────────────────── + const settledEvent = await prisma.event.findUniqueOrThrow({ + where: { slug: "swahili-nlp-hackathon" }, + }); + await prisma.event.update({ + where: { id: settledEvent.id }, + data: { mediaDeadlineAt: new Date(settledEvent.endsAt.getTime() + 2 * DAY) }, + }); + await seedEscrow(settledEvent, 430_000, new Date(now - 31 * DAY)); + + const teamG = await seedTeam(settledEvent.id, { + inviteCode: "teamcodegg", + name: "Luga Labs", + leader: wanjiku, + members: [wanjiku, kamau], + status: "LOCKED", + }); + const teamH = await seedTeam(settledEvent.id, { + inviteCode: "teamcodehh", + name: "Sauti Systems", + leader: zawadi, + members: [zawadi, chirchir], + status: "LOCKED", + }); + const teamI = await seedTeam(settledEvent.id, { + inviteCode: "teamcodeii", + name: "Nlp Juniors", + leader: mumbi, + members: [mumbi], + status: "LOCKED", + }); + const settledAnnouncedAt = new Date(now - 15 * DAY); + const milestonePaidAt = new Date(now - 12 * DAY); + for (const [team, leader, place, repo, lifecycle] of [ + [teamG, wanjiku, 1, "https://github.com/luga-labs/swh-asr", "IN_PRODUCTION"], + [teamH, zawadi, 2, "https://github.com/sauti-systems/sheng-translate", "DEMO"], + [teamI, mumbi, 3, "https://github.com/nlp-juniors/swh-search", "DEMO"], + ] as const) { + const submission = await prisma.submission.upsert({ + where: { teamId: team.id }, + create: { + teamId: team.id, + repoUrl: repo, + demoUrl: `${repo.replace("https://github.com/", "https://").replace("/", "-")}.vercel.app`, + description: `Swahili NLP Hackathon submission by ${team.name}: speech and text models for Swahili and Sheng as people actually speak them.`, + splitDeclaration: [{ userId: leader.id, percent: 100 }], + }, + update: {}, + }); + const amountKes = place === 1 ? 250_000 : place === 2 ? 120_000 : 60_000; + const winner = await seedWinner(settledEvent, { + team, + leader, + place, + amountKes, + announcedAt: settledAnnouncedAt, + }); + // Both tranches SUCCEEDED: milestone confirmed, then paid. + const confirmedAt = new Date(now - 13 * DAY); + await prisma.milestone.update({ + where: { winnerId: winner.id }, + data: { confirmedBy: organizer.id, confirmedAt }, + }); + const milestoneKes = amountKes - Math.ceil(amountKes * 0.5); + const milestoneRef = `seed-trf-${settledEvent.slug}-p${place}-milestone`; + await prisma.payout.upsert({ + where: { idempotencyKey: `${winner.id}:MILESTONE` }, + create: { + winnerId: winner.id, + tranche: "MILESTONE", + amountKes: milestoneKes, + idempotencyKey: `${winner.id}:MILESTONE`, + paystackTransferCode: `seed-tc-${settledEvent.slug}-p${place}-milestone`, + paystackReference: milestoneRef, + recipientCode: `seed-rcp-${leader.handle}`, + status: "SUCCEEDED", + attemptCount: 1, + paidAt: milestonePaidAt, + }, + update: {}, + }); + await prisma.ledgerEntry.upsert({ + where: { txHash: txHashFor(settledEvent.slug, `milestone-p${place}`) }, + create: { + eventId: settledEvent.id, + type: "MILESTONE_PAYOUT", + payload: { + winnerId: winner.id, + winnerHandle: leader.handle, + amountKes: milestoneKes, + txRef: milestoneRef, + }, + txHash: txHashFor(settledEvent.slug, `milestone-p${place}`), + blockNumber: 7 + place, + }, + update: {}, + }); + await prisma.portfolioItem.upsert({ + where: { submissionId: submission.id }, + create: { + submissionId: submission.id, + developerId: leader.id, + title: `${team.name}: winning submission`, + summary: submission.description, + repoUrl: submission.repoUrl, + demoUrl: submission.demoUrl, + lifecycle, + }, + update: {}, + }); + // The 3-month legacy check-in on the 1st place build is due today — + // the dashboard check-in card has exactly one item to show. + if (place === 1) { + await prisma.legacyCheckin.upsert({ + where: { submissionId: submission.id }, + create: { + submissionId: submission.id, + dueAt: new Date(now), + }, + update: { dueAt: new Date(now) }, + }); + } + } + await prisma.vaultState.update({ + where: { eventId: settledEvent.id }, + data: { + chainState: "SETTLED", + halfReleasedAt: new Date(settledAnnouncedAt.getTime() + 60 * 60 * 1000), + settledAt: milestonePaidAt, + lastTxHash: txHashFor(settledEvent.slug, "milestone-p3"), + }, + }); + await prisma.endorsement.upsert({ + where: { + judgeId_developerId_eventId: { + judgeId: judge.id, + developerId: wanjiku.id, + eventId: settledEvent.id, + }, + }, + create: { + judgeId: judge.id, + developerId: wanjiku.id, + eventId: settledEvent.id, + quote: + "Wanjiku's Swahili speech pipeline was the most complete NLP work we have judged on this platform — production-grade evaluation, documentation, and a demo that held up live.", + }, + update: {}, + }); + // An approved media item from the settled event's finals. + await prisma.mediaAsset.upsert({ + where: { r2Key: "seed/swahili-nlp-hackathon/finals-demo.webp" }, + create: { + eventId: settledEvent.id, + r2Key: "seed/swahili-nlp-hackathon/finals-demo.webp", + url: "/marketing/hackathons/ai-hackathon-kenya.webp", + kind: "PHOTO", + caption: "Luga Labs demoing their Swahili ASR pipeline at finals.", + uploadedBy: wanjiku.id, + status: "APPROVED", + uploadedAt: new Date(now - 19 * DAY), + }, + update: {}, + }); + // One still pending review — the media review queue stays demonstrable. + await prisma.mediaAsset.upsert({ + where: { r2Key: "seed/blue-economy-hack/team-samaki-flow.webp" }, + create: { + eventId: announcedEvent.id, + r2Key: "seed/blue-economy-hack/team-samaki-flow.webp", + url: "/marketing/hackathons/climate-tech-hackathon-kenya.webp", + kind: "PHOTO", + caption: "Samaki Flow testing the catch-logging app at Dunga beach.", + uploadedBy: wanjiku.id, + status: "PENDING", + uploadedAt: new Date(now - 5 * DAY), + }, + update: {}, + }); + + // ── Admin + hiring partner accounts ──────────────────────────────────── + const admin = await prisma.user.upsert({ + where: { email: "admin@hackvillage.dev" }, + create: { + email: "admin@hackvillage.dev", + name: "Brian Otieno", + handle: "brian-admin", + passwordHash: await hash("demopass123"), + emailVerified: new Date(), + primaryRole: "DEVELOPER", + onboardingCompletedAt: new Date(now - 60 * DAY), + }, + update: {}, + }); + await prisma.roleGrant.upsert({ + where: { userId_role: { userId: admin.id, role: "ADMIN" } }, + create: { userId: admin.id, role: "ADMIN" }, + update: {}, + }); + + const hiring = await prisma.user.upsert({ + where: { email: "hiring@hackvillage.dev" }, + create: { + email: "hiring@hackvillage.dev", + name: "Laura Njoki", + handle: "laura-hiring", + passwordHash: await hash("demopass123"), + emailVerified: new Date(), + primaryRole: "DEVELOPER", + onboardingCompletedAt: new Date(now - 15 * DAY), + }, + update: {}, + }); + await prisma.roleGrant.upsert({ + where: { userId_role: { userId: hiring.id, role: "HIRING" } }, + create: { userId: hiring.id, role: "HIRING" }, + update: {}, + }); + // A hiring partner requesting an intro to a verified winner (Phase 6). + await prisma.introduction.upsert({ + where: { + hiringPartnerId_developerId_eventId: { + hiringPartnerId: hiring.id, + developerId: wanjiku.id, + eventId: settledEvent.id, + }, + }, + create: { + hiringPartnerId: hiring.id, + developerId: wanjiku.id, + eventId: settledEvent.id, + message: + "Hi Wanjiku — we're hiring NLP engineers for our Kiswahili voice products team and your Swahili NLP Hackathon win stood out. Open to a 20-minute intro call?", + status: "REQUESTED", + }, + update: {}, + }); + + // ── Newsletter + trust ───────────────────────────────────────────────── + await prisma.newsletterSubscriber.upsert({ + where: { email: "demo-subscriber@hackvillage.dev" }, + create: { email: "demo-subscriber@hackvillage.dev", source: "landing-cta" }, + update: {}, + }); + // Payout excellence on the settled event bumped the org's trust score. + const trustReason = + "Every instant payout on Swahili NLP Hackathon cleared within an hour of the winners announcement."; + const existingTrust = await prisma.trustEvent.findFirst({ + where: { orgId: org.id, type: "PAYOUT_EXCELLENCE", reason: trustReason }, + }); + if (!existingTrust) { + await prisma.trustEvent.create({ + data: { + orgId: org.id, + type: "PAYOUT_EXCELLENCE", + delta: 10, + reason: trustReason, + actorId: admin.id, + }, + }); + } + // trustScore = 100 + Σ TrustEvents (floor 0) — kept in sync by the seed. + const trustEvents = await prisma.trustEvent.findMany({ + where: { orgId: org.id }, + select: { delta: true }, + }); + const trustScore = Math.max( + 0, + 100 + trustEvents.reduce((sum, trustEvent) => sum + trustEvent.delta, 0) + ); + await prisma.organization.update({ where: { id: org.id }, data: { trustScore } }); - console.log("Seeded: Technetium Kenya org, 1 organizer, 5 developers, 1 judge, 13 hackathons, 2 teams, 1 submission."); - console.log("Demo login: organizer@hackvillage.dev / wanjiku@hackvillage.dev / judge@hackvillage.dev … password: demopass123"); + console.log( + "Seeded: Technetium Kenya org, 1 organizer, 5 developers, 1 judge, 1 admin, 1 hiring partner." + ); + console.log( + "Events: 16 — 1 draft, 4 pending deposit, 5 live, 1 in progress, 1 judging, 1 winners announced, 3 settled." + ); + console.log( + "Escrow: 9 vaults + succeeded deposits + ledger locks; winners/payouts/milestones on blue-economy-hack (half released) and swahili-nlp-hackathon (settled)." + ); + console.log( + "Extras: 9 teams, 8 submissions, 6 winners, 10 payouts, 1 dispute, 1 introduction, 6 portfolio items, 1 endorsement, 1 legacy check-in due today, 2 media assets, 1 trust event, 1 newsletter subscriber." + ); + console.log( + "Demo login: organizer@hackvillage.dev / admin@hackvillage.dev / hiring@hackvillage.dev / wanjiku@hackvillage.dev / judge@hackvillage.dev … password: demopass123" + ); } main() diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 8a79f49..03c3a10 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -6,6 +6,19 @@ --- +## v1.1 Amendments (2026-09-27) + +Where shipped reality diverges from the v1.0 plan text, the code is the truth: + +- **Sessions**: JWT sessions with DB re-verification supersede ADR-005's database sessions — revocation is preserved via re-checks (see `lib/auth/`). +- **Routes**: `/events` was renamed to `/hackathons` across the app; `next.config.ts` redirects keep old links working. +- **`/trust` and `/developers`**: PR #31 removed both pages; v1.1 restores `/trust` (the public ledger page). `/developers/[handle]` profiles never left — only the listing was removed. +- **`RateLimitBucket`** added (migration `9_5_rate_limit_bucket`): rate limiting is DB-backed (atomic fixed-window upsert) with an in-memory fail-open fallback — see `lib/rate-limit.ts`. +- **Security headers shipped**: CSP, HSTS, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `frame-ancestors 'none'` in `next.config.ts` — §14.2's "strict CSP" is now real. `script-src` keeps `'unsafe-inline'` because the App Router streams RSC payloads in inline scripts; documented in `next.config.ts`. +- **IN_PROGRESS lifecycle wired**: events transition `LIVE → IN_PROGRESS` at `startsAt` (see `lib/events/lifecycle.ts`). + +--- + ## Table of Contents 1. [Executive Summary](#1-executive-summary) @@ -739,17 +752,20 @@ Local Hardhat tests cover every legal/illegal transition + access control (unkno ## 12. Background Jobs & Scheduled Workflows (pg-boss) +Queue name == job name (pg-boss v10). The registry of record is `lib/jobs/handlers.ts`; this table mirrors it: + | Job | Schedule | What it does | Failure policy | |---|---|---|---| | `payout.execute` | on-enqueue | Create Paystack Transfer per payout | Retry ×5 exp-backoff → MANUAL_REVIEW | -| `payout.retry` | cron 5m | Re-drive FAILED payouts under attempt cap | Alert admin at cap | -| `escrow.attest` | on-enqueue | Post state transition to PrizeVault | Retry ×10 (chain RPC flaky is OK) | -| `ledger.mirror` | on-chain event sub + nightly | Mirror + three-way reconcile | Alert on mismatch | -| `media.deadline-check` | cron 15m | Enforce 48h vault deadline | Idempotent by (event, deadline) | -| `legacy.checkin` | cron daily | 3-month check-in due/reminder | Idempotent by (submission, dueAt) | -| `milestone.reminder` | cron daily | 30/60/90-day organizer nudges | — | -| `deposit.expire` | cron hourly | Expire INITIATED deposits >24h | — | -| `notifications.dispatch` | on-enqueue | Email (Brevo) fan-out with templates | Retry ×3, dead-letter table | +| `payout.attest` | on-enqueue | Attest instant/milestone payout to PrizeVault | Retry ×5, backoff | +| `escrow.attest-vault-created` | on-enqueue | Attest vault creation to PrizeVaultFactory | Retry ×5, backoff | +| `escrow.attest-vault-locked` | on-enqueue | Attest deposit lock to PrizeVault | Retry ×5, backoff | +| `hackathon.announce` | on-enqueue | Fan out "Prize Verified" hackathon alerts to builders | Retry ×5, backoff | +| `escrow.cron` | cron hourly (`0 * * * *`) | `expire-deposits`: expire INITIATED deposits >24h | Idempotent re-run | +| `payout.cron` | cron 10m (`*/10 * * * *`) | `sweep-payouts`: re-drive stuck payouts under attempt cap | Alert admin at cap | +| `media.cron` | cron 15m (`*/15 * * * *`) | `enforce-media-deadlines`: 48h vault deadline → TrustEvent penalty | Idempotent by (event, deadline) | +| `legacy.cron` | cron daily 09:00 (`0 9 * * *`) | `legacy-sweep`: 3-month check-ins + 30/60/90-day milestone reminders | Idempotent by (submission, dueAt) | +| `ledger.cron` | cron nightly 03:00 (`0 3 * * *`) | `reconcile-ledger`: three-way match (webhook ⇄ DB ⇄ chain) + admin digest on findings | Alert on mismatch | All jobs idempotent (safe to re-run); all carry actor/event correlation IDs into structured logs. @@ -941,12 +957,13 @@ Per workflow-architect methodology — every workflow has a status. Specs live i | **Instant payout (50%)** | WORKFLOW-payout-instant.md | **Built (Phase 5)** | 5 | Announce winners | | **Milestone payout (50%)** | WORKFLOW-payout-milestone.md | **Built (Phase 5)** | 5/6 | Organizer confirm | | Payout failure ops | WORKFLOW-payout-failure.md | **Built (Phase 5)** | 5 | transfer.failed | -| Refund (pre-live cancel) | WORKFLOW-refund.md | Planned | 5 | Event cancel | +| Refund (pre-live cancel) | WORKFLOW-refund.md | **Built (Phase 5)** — `services/escrow/refund.ts` `refundLockedVault()` (lands with the audit-remediation branch) | 5 | Event cancel | | Dispute resolution | WORKFLOW-dispute.md | **Built (Phase 8)** | 8 | Developer dispute | | 48h media enforcement | WORKFLOW-media-deadline.md | **Built (Phase 7)** | 7 | Cron | | Trust adjustment + appeal | — | **Built (Phase 7)** | 7 | Penalty | | Legacy check-in | — | **Built (Phase 8)** | 8 | Cron +3mo | -| Introduction request/accept | — | Planned | 6 | Hiring CTA | +| Introduction request/accept | — | **Built (Phase 6)** — `services/pow/actions.ts` | 6 | Hiring CTA | +| Hackathon announce fan-out | — | **Built (v1.1)** — `hackathon.announce` job | — | Prize Verified go-live | | Notification dispatch | — | **Built (Phase 7)** | 7 | All events above | | Ledger reconcile (3-way) | — | **Built (Phase 9)** | 3/5 | Nightly | diff --git a/docs/GO_LIVE_CHECKLIST.md b/docs/GO_LIVE_CHECKLIST.md index b0b2284..e854264 100644 --- a/docs/GO_LIVE_CHECKLIST.md +++ b/docs/GO_LIVE_CHECKLIST.md @@ -2,6 +2,16 @@ The gate before the first production event. Every box is verified in a real environment — staging-verified items are marked. +> **2026-09-27 remediation progress** (code landed, not yet verified in a real +> environment — no boxes ticked by this work): +> - Security headers shipped in `next.config.ts`: CSP, HSTS (2y, preload), `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `frame-ancestors 'none'`; `X-Powered-By` off. +> - Coverage gate wired: `pnpm test:coverage` runs in CI with thresholds on `services/escrow/**` + `services/payout/**` (floor 75 lines/95 functions, ratcheting toward 90 — plan §14). +> - Health endpoint live at `GET /api/health` (db + queue probes, port modes, uptime; 200/503). +> - Rate limiting is DB-backed (`RateLimitBucket`) with per-IP secondary limits on auth actions; mutation API routes throttled 30/h/user. +> - Sentry wired (DSN-gated no-op until `SENTRY_DSN`/`NEXT_PUBLIC_SENTRY_DSN` are set — feeds the §4 Sentry box). +> - Script guards: dev/ops scripts refuse non-localhost `DATABASE_URL` without `--allow-prod`. +> - Simulation in prod now requires the explicit `PAYSTACK_ALLOW_SIMULATION_IN_PROD=true` opt-in (fail closed otherwise). + ## 1. Legal & Compliance - [ ] Paystack **live** account verified (business KYB on the Paystack side) — test keys swapped for live in all environments. @@ -31,7 +41,7 @@ The gate before the first production event. Every box is verified in a real envi - [ ] Vercel production deploy from `main` (squash-merge only). - [ ] Neon production database with PITR backups; connection pooling on. -- [ ] `DATABASE_URL` + `DIRECT_URL` (migrations) configured; `db:deploy` run against prod. +- [ ] `DATABASE_URL` configured; `db:deploy` run against prod. (2026-09-27: `DIRECT_URL` removed — the Prisma datasource uses `DATABASE_URL` only; a pooled-vs-direct split can return if Neon migrations ever need it.) - [ ] Sentry DSN live with alerts (error-rate + the `[payout] MANUAL_REVIEW` log). - [ ] Vercel Analytics on; Lighthouse mobile perf ≥ 90 on `/` and `/events` (budget: landing ≥ 95 — plan §15). - [ ] Rate limits sane in prod (auth 5/min/IP is default). diff --git a/docs/design/hero-imagery.json b/docs/design/hero-imagery.json index ae7f065..57f67cf 100644 --- a/docs/design/hero-imagery.json +++ b/docs/design/hero-imagery.json @@ -7,49 +7,49 @@ "prompt": "Use case: photorealistic-natural. Asset type: individual editorial photograph for HackVillage's website hero collage. Realistic Black Kenyan software engineers at a contemporary Nairobi tech event, natural skin texture and authentic candid energy, premium documentary photography, navy and bright blue accents, warm daylight, real modern Nairobi coworking interior. No graphics, watermarks, legible text or logos. This is ONE full-bleed photo, not a collage or website. Portrait aspect ratio 1:2, ideally 768x1536. A confident young Black Kenyan female software engineer with neat natural braids, wearing a cobalt blue overshirt and white t-shirt, holding a closed laptop at her waist, looking just off-camera with a warm smile. Three-quarter body portrait, centered with head in upper third, Nairobi glass office windows softly out of focus behind her.", "width": 768, "height": 1536, - "path": "public/marketing/hero/kenya/center-developer.webp" + "path": "public/marketing/hero/kenya/kenyan-software-engineer-holding-laptop.webp" }, { "name": "coding-focus", "prompt": "Use case: photorealistic-natural. Asset type: individual editorial photograph for HackVillage's website hero collage. Realistic Black Kenyan software engineers at a contemporary Nairobi tech event, natural skin texture and authentic candid energy, premium documentary photography, navy and bright blue accents, warm daylight, real modern Nairobi coworking interior. No graphics, watermarks, legible text or logos. This is ONE full-bleed photo, not a collage or website. Portrait aspect ratio 4:5, ideally 1024x1280. Candid close-up side view of a young Black Kenyan woman with short natural hair and thin round glasses, focused on coding at a laptop, navy t-shirt, sunlit coworking space. Frame head and shoulders and laptop, subject centered.", "width": 480, "height": 600, - "path": "public/marketing/hero/kenya/coding-focus.webp" + "path": "public/marketing/hero/kenya/kenyan-software-engineer-coding-on-laptop.webp" }, { "name": "team-build", "prompt": "Use case: photorealistic-natural. Asset type: individual editorial photograph for HackVillage's website hero collage. Realistic Black Kenyan software engineers at a contemporary Nairobi tech event, natural skin texture and authentic candid energy, premium documentary photography, navy and bright blue accents, warm daylight, real modern Nairobi coworking interior. No graphics, watermarks, legible text or logos. This is ONE full-bleed photo, not a collage or website. Landscape aspect ratio 6:5, ideally 1200x1000. Two young Black Kenyan engineers, a woman with braids and a man with short hair, leaning together over a laptop at a hackathon table, excited discussion, blue and cream casual clothing, genuine expressions. Medium shot faces in middle upper half.", "width": 640, "height": 534, - "path": "public/marketing/hero/kenya/team-build.webp" + "path": "public/marketing/hero/kenya/kenyan-developers-collaborating-at-hackathon.webp" }, { "name": "speaker", "prompt": "Use case: photorealistic-natural. Asset type: individual editorial photograph for HackVillage's website hero collage. Realistic Black Kenyan software engineers at a contemporary Nairobi tech event, natural skin texture and authentic candid energy, premium documentary photography, navy and bright blue accents, warm daylight, real modern Nairobi coworking interior. No graphics, watermarks, legible text or logos. This is ONE full-bleed photo, not a collage or website. Landscape aspect ratio 6:5, ideally 1200x1000. Black Kenyan female tech event speaker with natural afro hair in a sky blue blazer, speaking into a handheld microphone, animated and confident, softly blurred dark navy conference backdrop. Waist-up candid shot with subject centered.", "width": 640, "height": 534, - "path": "public/marketing/hero/kenya/speaker.webp" + "path": "public/marketing/hero/kenya/kenyan-tech-speaker-at-hackathon.webp" }, { "name": "hackathon-pair", "prompt": "Use case: photorealistic-natural. Asset type: individual editorial photograph for HackVillage's website hero collage. Realistic Black Kenyan software engineers at a contemporary Nairobi tech event, natural skin texture and authentic candid energy, premium documentary photography, navy and bright blue accents, warm daylight, real modern Nairobi coworking interior. No graphics, watermarks, legible text or logos. This is ONE full-bleed photo, not a collage or website. Landscape aspect ratio 6:5, ideally 1200x1000. Two young Black Kenyan male developers, one in navy hoodie one in cobalt blue t-shirt, seated at a desk at a Nairobi hackathon, sharing a laugh over laptop code, candid eye-level medium close-up, warm sunlit office.", "width": 640, "height": 534, - "path": "public/marketing/hero/kenya/hackathon-pair.webp" + "path": "public/marketing/hero/kenya/kenyan-engineers-coding-together.webp" }, { "name": "community", "prompt": "Use case: photorealistic-natural. Asset type: individual editorial photograph for HackVillage's website hero collage. Realistic Black Kenyan software engineers at a contemporary Nairobi tech event, natural skin texture and authentic candid energy, premium documentary photography, navy and bright blue accents, warm daylight, real modern Nairobi coworking interior. No graphics, watermarks, legible text or logos. This is ONE full-bleed photo, not a collage or website. Landscape aspect ratio 6:5, ideally 1200x1000. Small group of Black Kenyan software engineers, men and women in their twenties and thirties, gathered at a coworking table at a Nairobi tech event, smiling in a collaborative conversation, open laptops and notebooks, window daylight, casual navy and white clothes. Authentic candid photo.", "width": 640, "height": 534, - "path": "public/marketing/hero/kenya/community.webp" + "path": "public/marketing/hero/kenya/nairobi-tech-community-gathering.webp" }, { "name": "event-arrival", "prompt": "Use case: photorealistic-natural. Asset type: individual editorial photograph for HackVillage's website hero collage. Realistic Black Kenyan software engineers at a contemporary Nairobi tech event, natural skin texture and authentic candid energy, premium documentary photography, navy and bright blue accents, warm daylight, real modern Nairobi coworking interior. No graphics, watermarks, legible text or logos. This is ONE full-bleed photo, not a collage or website. Portrait aspect ratio 4:5, ideally 1024x1280. A young Black Kenyan male software engineer with short twists and glasses, wearing a navy overshirt and a plain blue event lanyard, carrying his laptop walking through a bright Nairobi tech venue, casual smiling candid portrait from waist up, blurred attendees behind.", "width": 480, "height": 600, - "path": "public/marketing/hero/kenya/event-arrival.webp" + "path": "public/marketing/hero/kenya/kenyan-developer-arriving-at-hackathon.webp" } ] } diff --git a/docs/design/how-it-works-imagery.json b/docs/design/how-it-works-imagery.json index 1203cec..dbc6431 100644 --- a/docs/design/how-it-works-imagery.json +++ b/docs/design/how-it-works-imagery.json @@ -7,28 +7,28 @@ "prompt": "Use case: photorealistic-natural. Generate ONE standalone premium documentary photograph for a HackVillage website card, portrait 4:5 aspect ratio, ideally 1024x1280. Realistic Black Kenyan adults at a contemporary Nairobi hackathon. Authentic candid expressions, natural skin texture, professional editorial photography, soft warm daylight, navy and light blue clothing accents, modern Kenyan coworking architecture. Frame the main faces in the upper half, with the bottom quarter showing desk, clothing or softly blurred surroundings so website captions can overlay there. No embedded text, logos, watermarks, interface graphics, badges, collage or borders. A Black Kenyan female hackathon organizer in her early thirties with neat natural braids and a light blue shirt, seated at a workspace planning an event with a Black Kenyan male colleague wearing a navy shirt. She gestures toward a laptop and an open notebook on the desk. Medium shot, genuine focused discussion, bright Nairobi office with plants and large windows.", "width": 768, "height": 960, - "path": "public/marketing/how-it-works/launch.webp" + "path": "public/marketing/how-it-works/organizers-planning-hackathon-challenge.webp" }, { "name": "build", "prompt": "Use case: photorealistic-natural. Generate ONE standalone premium documentary photograph for a HackVillage website card, portrait 4:5 aspect ratio, ideally 1024x1280. Realistic Black Kenyan adults at a contemporary Nairobi hackathon. Authentic candid expressions, natural skin texture, professional editorial photography, soft warm daylight, navy and light blue clothing accents, modern Kenyan coworking architecture. Frame the main faces in the upper half, with the bottom quarter showing desk, clothing or softly blurred surroundings so website captions can overlay there. No embedded text, logos, watermarks, interface graphics, badges, collage or borders. Three Black Kenyan software engineers, two women and one man in their twenties, collaborating around a laptop at a hackathon desk. One woman in a cobalt blue casual shirt points toward the screen, her colleagues lean in smiling and problem-solving. Natural hairstyles and casual tech-event clothes, energetic focused teamwork, medium shot, bright Nairobi coworking venue.", "width": 768, "height": 960, - "path": "public/marketing/how-it-works/build.webp" + "path": "public/marketing/how-it-works/developers-collaborating-at-nairobi-hackathon.webp" }, { "name": "judge", "prompt": "Use case: photorealistic-natural. Generate ONE standalone premium documentary photograph for a HackVillage website card, portrait 4:5 aspect ratio, ideally 1024x1280. Realistic Black Kenyan adults at a contemporary Nairobi hackathon. Authentic candid expressions, natural skin texture, professional editorial photography, soft warm daylight, navy and light blue clothing accents, modern Kenyan coworking architecture. Frame the main faces in the upper half, with the bottom quarter showing desk, clothing or softly blurred surroundings so website captions can overlay there. No embedded text, logos, watermarks, interface graphics, badges, collage or borders. A Black Kenyan male developer with short natural hair and a navy overshirt presenting his laptop demo to a Black Kenyan female judge in a pale blue blazer at a Nairobi hackathon table. The judge listens intently, notebook and pen on the desk, small blurred audience behind them. Medium shot with both faces clearly visible in upper half, authentic professional discussion rather than posing.", "width": 768, "height": 960, - "path": "public/marketing/how-it-works/judge.webp" + "path": "public/marketing/how-it-works/developer-demoing-project-to-judge.webp" }, { "name": "reward", "prompt": "Use case: photorealistic-natural. Generate ONE standalone premium documentary photograph for a HackVillage website card, portrait 4:5 aspect ratio, ideally 1024x1280. Realistic Black Kenyan adults at a contemporary Nairobi hackathon. Authentic candid expressions, natural skin texture, professional editorial photography, soft warm daylight, navy and light blue clothing accents, modern Kenyan coworking architecture. Frame the main faces in the upper half, with the bottom quarter showing desk, clothing or softly blurred surroundings so website captions can overlay there. No embedded text, logos, watermarks, interface graphics, badges, collage or borders. Two joyful Black Kenyan hackathon winners, a woman with natural afro hair in a cobalt blue shirt and a man with short twists in a navy shirt, standing together holding a small elegant clear glass trophy between them. Genuine proud smiles, blurred fellow Kenyan participants applauding in a bright Nairobi event venue behind them. Medium portrait framed from waist up, faces and trophy in upper two thirds. No huge cheque, no printed text, no confetti.", "width": 768, "height": 960, - "path": "public/marketing/how-it-works/reward.webp" + "path": "public/marketing/how-it-works/hackathon-winners-with-trophy.webp" } ] } diff --git a/docs/marketing-images.md b/docs/marketing-images.md index e1b72ca..1786862 100644 --- a/docs/marketing-images.md +++ b/docs/marketing-images.md @@ -35,238 +35,238 @@ Each prompt produced one new photograph with no reference image. Encoding and di ### process/launch -- File: `public/marketing/process/launch.webp` +- File: `public/marketing/process/organizers-planning-hackathon-at-laptop.webp` - Export: 1024×1280 Use case: photorealistic-natural. Create ONE finished portrait editorial photograph for HackVillage's 'The Prize Is Already There' how-it-works card. Output 1024x1280, 4:5. Two Black Kenyan hackathon organizers, a woman and man in their late twenties, seated close together reviewing event planning and a prize budget on a laptop in a contemporary Nairobi coworking office. One holds a phone as the other checks the laptop; believable calm concentration, not posing. Natural daylight, authentic skin texture, stylish navy and warm neutral clothing. Place both faces in upper third with generous margin above heads; laptop and tabletop occupy lower half that will sit behind a dark caption. Subjects centered and close enough to survive narrow crop. No text, logos, watermarks, giant cheques, floating icons, fake UI, or extra limbs. Documentary photography, realistic anatomy, polished but candid. ### process/build -- File: `public/marketing/process/build.webp` +- File: `public/marketing/process/developers-building-at-nairobi-hackathon.webp` - Export: 1024×1280 Use case: photorealistic-natural. ONE editorial documentary photograph for a HackVillage how-it-works card. Portrait 4:5, requested 1024x1280. Contemporary Nairobi tech event, all people Black Kenyan adults, authentic skin texture, natural daylight, navy/blue/neutral casual clothes. Faces and the main activity in upper 45%, generous headroom, lower half is table/equipment space behind a dark website caption. No visible writing, logos, watermarks, floating graphics, oversized cheques, or anatomically incorrect hands. Three developers, two women and one man, seated close together pair programming and sketching a simple app wireframe beside two laptops. Engaged discussion, one woman types as teammates watch, candid working moment. Frame the trio tightly enough to read clearly in a narrow card. ### process/judge -- File: `public/marketing/process/judge.webp` +- File: `public/marketing/process/developer-demoing-project-to-hackathon-judges.webp` - Export: 1024×1280 Use case: photorealistic-natural. ONE editorial documentary photograph for a HackVillage how-it-works card. Portrait 4:5, requested 1024x1280. Contemporary Nairobi tech event, all people Black Kenyan adults, authentic skin texture, natural daylight, navy/blue/neutral casual clothes. Faces and the main activity in upper 45%, generous headroom, lower half is table/equipment space behind a dark website caption. No visible writing, logos, watermarks, floating graphics, oversized cheques, or anatomically incorrect hands. A woman hackathon judge with short natural hair reviewing a young man's laptop demo at a small table. Another woman judge listening beside her, holding a clipboard. The judge points towards the prototype while discussing it constructively; fair technical review, attentive expressions. Keep faces at matching height in upper third. ### process/reward -- File: `public/marketing/process/reward.webp` +- File: `public/marketing/process/hackathon-winners-checking-payout-beside-trophy.webp` - Export: 1024×1280 Use case: photorealistic-natural. ONE editorial documentary photograph for a HackVillage how-it-works card. Portrait 4:5, requested 1024x1280. Contemporary Nairobi tech event, all people Black Kenyan adults, authentic skin texture, natural daylight, navy/blue/neutral casual clothes. Faces and the main activity in upper 45%, generous headroom, lower half is table/equipment space behind a dark website caption. No visible writing, logos, watermarks, floating graphics, oversized cheques, or anatomically incorrect hands. Two winning software developers, a woman with locs and a man with close cropped hair, smiling together at a small gold trophy on the table while the woman checks her phone for their prize payout. Both seated, shoulders close, candid relief and joy after the hackathon. Faces upper third, trophy and phone near the middle, laptop and table lower half. ### journey/build -- File: `public/marketing/journey/build.webp` +- File: `public/marketing/journey/kenyan-developer-building-hackathon-project.webp` - Export: 960×1600 Use case: photorealistic-natural. Generate ONE new editorial portrait photograph, vertical 3:5, 960x1600 requested. For HackVillage builder journey section, displayed in a tall narrow pill crop with rounded top/bottom. Contemporary Nairobi tech workspace. Black Kenyan adults, authentic varied skin texture, natural light, candid expressions, believable anatomy, no text/logos/watermark. Keep main face centered at 35% from top, entire head and shoulders safely within central 60% width, activity mid-frame, ample headroom for rounded crop. A young woman software engineer with natural short hair, in a slate blue shirt, sitting at a laptop debugging her project, side-three-quarter view. Focus and determination, green plant and warm window light behind her. Medium shot showing face and typing hands. ### journey/ship -- File: `public/marketing/journey/ship.webp` +- File: `public/marketing/journey/developer-demoing-mobile-app.webp` - Export: 960×1600 Use case: photorealistic-natural. Generate ONE new editorial portrait photograph, vertical 3:5, 960x1600 requested. For HackVillage builder journey section, displayed in a tall narrow pill crop with rounded top/bottom. Contemporary Nairobi tech workspace. Black Kenyan adults, authentic varied skin texture, natural light, candid expressions, believable anatomy, no text/logos/watermark. Keep main face centered at 35% from top, entire head and shoulders safely within central 60% width, activity mid-frame, ample headroom for rounded crop. A young male developer in an olive overshirt presenting his completed mobile app on a phone, glancing towards a colleague just outside frame. Seated at his laptop, calm pride in a finished prototype. Clean modern office, shallow depth of field. No legible interface text. ### journey/win -- File: `public/marketing/journey/win.webp` +- File: `public/marketing/journey/hackathon-winner-with-trophy-and-laptop.webp` - Export: 960×1600 Use case: photorealistic-natural. Generate ONE new editorial portrait photograph, vertical 3:5, 960x1600 requested. For HackVillage builder journey section, displayed in a tall narrow pill crop with rounded top/bottom. Contemporary Nairobi tech workspace. Black Kenyan adults, authentic varied skin texture, natural light, candid expressions, believable anatomy, no text/logos/watermark. Keep main face centered at 35% from top, entire head and shoulders safely within central 60% width, activity mid-frame, ample headroom for rounded crop. A joyful Black Kenyan woman developer with shoulder-length twists, wearing a cobalt shirt and dark trousers, standing in a bright hackathon venue holding a modest gold trophy in one hand and closed silver laptop tucked under other arm. Knees-up portrait, centered, relaxed celebratory smile, softly blurred Black teammates behind. Trophy, face and laptop completely within central 60% width, plenty of room above head. ### journey/paid -- File: `public/marketing/journey/paid.webp` +- File: `public/marketing/journey/developer-receiving-hackathon-prize-payout.webp` - Export: 960×1600 Use case: photorealistic-natural. Generate ONE new editorial portrait photograph, vertical 3:5, 960x1600 requested. For HackVillage builder journey section, displayed in a tall narrow pill crop with rounded top/bottom. Contemporary Nairobi tech workspace. Black Kenyan adults, authentic varied skin texture, natural light, candid expressions, believable anatomy, no text/logos/watermark. Keep main face centered at 35% from top, entire head and shoulders safely within central 60% width, activity mid-frame, ample headroom for rounded crop. A young Black Kenyan male software engineer seated beside his open laptop, checking a payout notification on his phone with an understated happy smile. Navy casual shirt, soft light. Phone screen facing him, no visible text or interface, face and phone centered and fully visible. ### journey/hired -- File: `public/marketing/journey/hired.webp` +- File: `public/marketing/journey/engineer-presenting-portfolio-to-hiring-manager.webp` - Export: 960×1600 Use case: photorealistic-natural. Generate ONE new editorial portrait photograph, vertical 3:5, 960x1600 requested. For HackVillage builder journey section, displayed in a tall narrow pill crop with rounded top/bottom. Contemporary Nairobi tech workspace. Black Kenyan adults, authentic varied skin texture, natural light, candid expressions, believable anatomy, no text/logos/watermark. Keep main face centered at 35% from top, entire head and shoulders safely within central 60% width, activity mid-frame, ample headroom for rounded crop. A Black Kenyan woman engineer in a light beige jacket explaining her laptop portfolio to a hiring manager whose shoulder is softly blurred in the foreground. The engineer is central and in sharp focus, warm confident eye contact with interviewer, slim laptop in lower frame, welcoming Nairobi coworking meeting room. ### trust/escrow -- File: `public/marketing/trust/escrow.webp` +- File: `public/marketing/trust/organizer-reviewing-hackathon-prize-funding.webp` - Export: 960×1280 Use case: photorealistic-natural. ONE finished editorial photograph for HackVillage escrow and payouts section. Portrait 3:4, requested 960x1280. Contemporary Nairobi office, all people Black Kenyan adults. Authentic texture and natural daylight, candid documentary realism. Safe centered composition, face(s) around upper third with headroom for rounded crop, central action visible even in small thumbnail. No text, logos, watermarks, stylized overlay graphics, physical piles of money, or fake security symbolism. An experienced Black Kenyan woman event organizer with short natural hair in a navy blazer, seated at a neat desk reviewing the hackathon prize funding on her laptop with a smartphone beside it. Thoughtful focused expression, believable responsible organizer handling budget, green plants, warm concrete and wood office details. Medium shot; no one else prominent. Keep face and laptop centered. ### trust/deposit -- File: `public/marketing/trust/deposit.webp` +- File: `public/marketing/trust/organizer-funding-hackathon-prize-pool.webp` - Export: 960×1280 Use case: photorealistic-natural. ONE finished editorial photograph for HackVillage escrow and payouts section. Portrait 3:4, requested 960x1280. Contemporary Nairobi office, all people Black Kenyan adults. Authentic texture and natural daylight, candid documentary realism. Safe centered composition, face(s) around upper third with headroom for rounded crop, central action visible even in small thumbnail. No text, logos, watermarks, stylized overlay graphics, physical piles of money, or fake security symbolism. A Black Kenyan male hackathon organizer in a pale blue shirt making the prize-pool deposit on a phone while seated beside an open laptop. Side three quarter medium portrait, concentrated expression, phone held at mid-chest, screen turned away from camera. ### trust/payout -- File: `public/marketing/trust/payout.webp` +- File: `public/marketing/trust/developer-celebrating-prize-payout.webp` - Export: 960×1280 Use case: photorealistic-natural. ONE finished editorial photograph for HackVillage escrow and payouts section. Portrait 3:4, requested 960x1280. Contemporary Nairobi office, all people Black Kenyan adults. Authentic texture and natural daylight, candid documentary realism. Safe centered composition, face(s) around upper third with headroom for rounded crop, central action visible even in small thumbnail. No text, logos, watermarks, stylized overlay graphics, physical piles of money, or fake security symbolism. A Black Kenyan woman developer with braids checking her smartphone beside a closed laptop, smiling with relief after receiving her hackathon prize. Casual deep green shirt, seated in a bright coworking lounge, medium portrait with face and phone safely centered. ### trust/record -- File: `public/marketing/trust/record.webp` +- File: `public/marketing/trust/engineer-checking-payout-record.webp` - Export: 960×1280 Use case: photorealistic-natural. ONE finished editorial photograph for HackVillage escrow and payouts section. Portrait 3:4, requested 960x1280. Contemporary Nairobi office, all people Black Kenyan adults. Authentic texture and natural daylight, candid documentary realism. Safe centered composition, face(s) around upper third with headroom for rounded crop, central action visible even in small thumbnail. No text, logos, watermarks, stylized overlay graphics, physical piles of money, or fake security symbolism. A Black Kenyan male software engineer with round glasses in a navy shirt checking a transaction record on his laptop, hand on trackpad and a small paper notebook beside it. Candid mid shot, attentive face visible, screen turned away from viewer, softly blurred coworking interior. ### newsletter/community -- File: `public/marketing/newsletter/community.webp` +- File: `public/marketing/newsletter/developers-exploring-next-hackathon.webp` - Export: 1600×1120 Use case: photorealistic-natural. ONE new editorial photo for HackVillage newsletter 'Stay in the loop', a community for verified hackathons and rewards. Landscape 10:7 requested 1600x1120; also cropped to 16:9 on phones. Two Black Kenyan software developers, a woman with short natural hair and a man in round glasses, discovering an upcoming tech challenge together on a laptop in an airy Nairobi coworking lounge. Candid engaged smiles, woman points to laptop, natural body language. Mid wide shot, both whole heads and shoulders inside center 70% width and middle 65% height so both landscape crops work. Soft daylight, deep blue and terracotta clothes, warm wood and leafy background. Realistic skin texture, hands, no logos, visible writing, watermark, UI overlays, or cash. ### auth/welcome -- File: `public/marketing/auth/welcome.webp` +- File: `public/marketing/auth/hackathon-builders-welcome.webp` - Export: 1200×1600 Use case: photorealistic-natural. ONE new editorial photograph for HackVillage sign in and sign up full-height background. Portrait 3:4 requested 1200x1600. Black Kenyan woman software engineer with neatly tied locs, wearing a muted blue shirt, seated at a laptop in a beautiful modern Nairobi tech hub, one other Black colleague softly out of focus in distance. Frame a medium wide environmental portrait: her face is centered horizontally and at 49% image height, her head occupies only 12% of height, laptop in lower middle. Reserve TOP 30% for quiet dark navy architectural wall and soft shadow for website white heading, bottom 20% uncluttered dark desk for overlay card. Daylight lights her face with natural skin texture, inviting atmosphere. No words, brands, watermarks, in-image graphics, or extra fingers. Must be a photograph, not a UI design. ### blog/first-hackathon -- File: `public/marketing/blog/first-hackathon.webp` +- File: `public/marketing/blog/volunteer-welcoming-developers-to-first-hackathon.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, 2100x900 requested. All people Black Kenyan adults. Contemporary Nairobi tech environment, authentic skin texture, natural daylight, believable candid moment. Wide composition with whole faces fully inside middle vertical 65%, comfortable breathing room above heads, no cut off faces. No logos, readable text, watermark, decorative graphics, split panels, or exaggerated stock-photo poses. A friendly woman event volunteer greeting two young developers arriving at a hackathon registration desk, one carries a laptop sleeve and the other a backpack. Welcoming relaxed smiles, blurred tables and laptops in spacious venue behind. Mid-wide shot at eye level. ### blog/team -- File: `public/marketing/blog/team.webp` +- File: `public/marketing/blog/kenyan-developers-building-together.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, 2100x900 requested. All people Black Kenyan adults. Contemporary Nairobi tech environment, authentic skin texture, natural daylight, believable candid moment. Wide composition with whole faces fully inside middle vertical 65%, comfortable breathing room above heads, no cut off faces. No logos, readable text, watermark, decorative graphics, split panels, or exaggerated stock-photo poses. Three software teammates, two women and one man, planning a small app release at a shared table, one draws a simple wireframe in a notebook while another types on a laptop and third listens. Communicate complementary roles and teamwork, everyone seated so all heads fit a horizontal band. Warm wooden table and understated modern office. ### blog/portfolio -- File: `public/marketing/blog/portfolio.webp` +- File: `public/marketing/blog/engineer-presenting-portfolio-to-hiring-manager.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, 2100x900 requested. All people Black Kenyan adults. Contemporary Nairobi tech environment, authentic skin texture, natural daylight, believable candid moment. Wide composition with whole faces fully inside middle vertical 65%, comfortable breathing room above heads, no cut off faces. No logos, readable text, watermark, decorative graphics, split panels, or exaggerated stock-photo poses. A confident woman software engineer presenting her laptop project portfolio to a male hiring manager across a coworking meeting table. Two people in profile three-quarter view, eye-level, professional but casual. Hiring manager listens attentively, her open laptop and small notebook at center; warm daylight. ### blog/open-source -- File: `public/marketing/blog/open-source.webp` +- File: `public/marketing/blog/open-source-contributors-reviewing-code.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, 2100x900 requested. All people Black Kenyan adults. Contemporary Nairobi tech environment, authentic skin texture, natural daylight, believable candid moment. Wide composition with whole faces fully inside middle vertical 65%, comfortable breathing room above heads, no cut off faces. No logos, readable text, watermark, decorative graphics, split panels, or exaggerated stock-photo poses. Two open-source contributors, a woman in a blue shirt and man in muted green, doing a friendly code review side by side with two laptops and a monitor showing abstract soft unreadable lines of code. Shared concentration, notebook between them, natural tech workshop atmosphere. ### blog/media -- File: `public/marketing/blog/media.webp` +- File: `public/marketing/blog/photographer-reviewing-hackathon-photos.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, 2100x900 requested. All people Black Kenyan adults. Contemporary Nairobi tech environment, authentic skin texture, natural daylight, believable candid moment. Wide composition with whole faces fully inside middle vertical 65%, comfortable breathing room above heads, no cut off faces. No logos, readable text, watermark, decorative graphics, split panels, or exaggerated stock-photo poses. A woman photographer at a hackathon reviewing photos on the rear screen of her DSLR camera beside a male event coordinator sorting event pictures on a laptop. Camera and laptop clearly visible, lively but softly blurred developers behind them, candid documentation of a real tech gathering. ### blog/judging -- File: `public/marketing/blog/judging.webp` +- File: `public/marketing/blog/judges-reviewing-prototype-with-scorecard.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, requested 2100x900. Contemporary Nairobi tech workspace with blank walls and unbranded equipment. All people Black Kenyan adults. Natural skin texture and soft daylight, professional candid documentary photograph. Keep whole heads inside horizontal composition with headroom; medium-wide eye-level shot. Absolutely no text anywhere: blank walls, plain clothing, blank notebooks or indistinct marks, no posters, no slogans, no brand logos, no watermark. Two hackathon judges, an experienced woman and younger man, both seated on one side of a table reviewing a woman developer's prototype on a laptop. One judge marks a paper scorecard with simple empty grid cells, the other listens to the developer. Thoughtful fair review, heads all at similar height, laptop at center. ### blog/payouts -- File: `public/marketing/blog/payouts.webp` +- File: `public/marketing/blog/hackathon-winners-checking-prize-payout.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, requested 2100x900. Contemporary Nairobi tech workspace with blank walls and unbranded equipment. All people Black Kenyan adults. Natural skin texture and soft daylight, professional candid documentary photograph. Keep whole heads inside horizontal composition with headroom; medium-wide eye-level shot. Absolutely no text anywhere: blank walls, plain clothing, blank notebooks or indistinct marks, no posters, no slogans, no brand logos, no watermark. A woman hackathon winner checking her phone and smiling with her male teammate; modest gold trophy and open laptop on the table. A small quiet celebration after receiving their prize payment, no money or cheques. Keep phone, both faces and trophy visible in center horizontal band. ### blog/organizing -- File: `public/marketing/blog/organizing.webp` +- File: `public/marketing/blog/organizer-coordinating-hackathon-team.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, requested 2100x900. Contemporary Nairobi tech workspace with blank walls and unbranded equipment. All people Black Kenyan adults. Natural skin texture and soft daylight, professional candid documentary photograph. Keep whole heads inside horizontal composition with headroom; medium-wide eye-level shot. Absolutely no text anywhere: blank walls, plain clothing, blank notebooks or indistinct marks, no posters, no slogans, no brand logos, no watermark. A Black Kenyan woman event organizer leading a preparation meeting with two Black teammates around a laptop and paper schedule. Wide view of a bright modern hackathon venue being readied in background, neat empty tables with laptops, organizer explaining calmly, team listening. No stage signage. ### blog/follow-up -- File: `public/marketing/blog/follow-up.webp` +- File: `public/marketing/blog/developers-reviewing-project-after-hackathon.webp` - Export: 2100×900 Use case: photorealistic-natural. ONE editorial blog cover for HackVillage. ULTRAWIDE 21:9 landscape, requested 2100x900. Contemporary Nairobi tech workspace with blank walls and unbranded equipment. All people Black Kenyan adults. Natural skin texture and soft daylight, professional candid documentary photograph. Keep whole heads inside horizontal composition with headroom; medium-wide eye-level shot. Absolutely no text anywhere: blank walls, plain clothing, blank notebooks or indistinct marks, no posters, no slogans, no brand logos, no watermark. Two Black Kenyan software founders, a man and woman, checking in on their shipped hackathon project three months later. They are seated at a desk reviewing a tablet prototype together, with an open laptop and notebook nearby. Mature focused collaborative mood, comfortable small startup studio with plants, unbranded simple clothing. ### hackathons/ai -- File: `public/marketing/hackathons/ai.webp` +- File: `public/marketing/hackathons/ai-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults in contemporary Kenya. Keep complete faces and key equipment inside middle horizontal 60% height so crop is safe. Candid documentary photography with natural skin texture, natural daylight, realistic hands, ordinary professional clothing. Blank walls, plain clothes, no readable words, no posters, no logos, no watermarks, no floating digital overlays. Two Kenyan AI engineers, a woman and a man, testing a speech recognition prototype at a laptop with a small tabletop microphone and headphones. One speaks softly towards the microphone while the other monitors an abstract audio waveform on screen. Bright modern tech studio, practical machine learning development, no robots. ### hackathons/web3 -- File: `public/marketing/hackathons/web3.webp` +- File: `public/marketing/hackathons/web3-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults in contemporary Kenya. Keep complete faces and key equipment inside middle horizontal 60% height so crop is safe. Candid documentary photography with natural skin texture, natural daylight, realistic hands, ordinary professional clothing. Blank walls, plain clothes, no readable words, no posters, no logos, no watermarks, no floating digital overlays. Two Kenyan blockchain developers, woman and man, at a laptop discussing a distributed application prototype. A freestanding small whiteboard beside them has a simple hand-drawn interconnected node diagram with circles and lines only, no text. Practical coding workshop atmosphere, no coins or cryptocurrency logos. ### hackathons/fintech -- File: `public/marketing/hackathons/fintech.webp` +- File: `public/marketing/hackathons/fintech-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults in contemporary Kenya. Keep complete faces and key equipment inside middle horizontal 60% height so crop is safe. Candid documentary photography with natural skin texture, natural daylight, realistic hands, ordinary professional clothing. Blank walls, plain clothes, no readable words, no posters, no logos, no watermarks, no floating digital overlays. A Kenyan woman fintech engineer testing a mobile payment prototype on a smartphone beside a male colleague using a compact card terminal. Open laptop on the shared desk, they compare device results with focused interest. Nairobi startup office, screens angled away, no bank brands or cash. ### hackathons/health -- File: `public/marketing/hackathons/health.webp` +- File: `public/marketing/hackathons/health-tech-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults in contemporary Kenya. Keep complete faces and key equipment inside middle horizontal 60% height so crop is safe. Candid documentary photography with natural skin texture, natural daylight, realistic hands, ordinary professional clothing. Blank walls, plain clothes, no readable words, no posters, no logos, no watermarks, no floating digital overlays. A Kenyan female clinician in blue scrubs and a Kenyan male software developer in casual clothes reviewing a tablet health-record prototype together at a bright clinic office desk. Laptop nearby, neutral clinical room, no patients, no private medical details, calm collaborative expressions. ### hackathons/agritech -- File: `public/marketing/hackathons/agritech.webp` +- File: `public/marketing/hackathons/agritech-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults in contemporary Kenya. Keep complete faces and key equipment inside middle horizontal 60% height so crop is safe. Candid documentary photography with natural skin texture, natural daylight, realistic hands, ordinary professional clothing. Blank walls, plain clothes, no readable words, no posters, no logos, no watermarks, no floating digital overlays. A Kenyan woman agricultural engineer and a Kenyan male developer examining a small soil moisture sensor connected to a laptop on a bench inside a leafy greenhouse. Vegetable rows visible behind them, sunlight, practical agriculture technology experimentation. Both faces and sensor centered vertically, medium-wide view. ### hackathons/climate -- File: `public/marketing/hackathons/climate.webp` +- File: `public/marketing/hackathons/climate-tech-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults. IMPORTANT medium-wide framing with plenty of background above heads: whole faces and key equipment inside central horizontal 60% height, camera far enough away for safe wide crop. Contemporary Kenya, realistic natural daylight and skin texture, documentary photography. Plain clothes, blank walls, no writing, no posters, no logos, watermark, or floating digital graphics. A Kenyan woman renewable-energy engineer and a Kenyan man software developer at a workbench examining a small solar panel wired to a sensor and laptop. Open bright maker workshop with green trees visible outside. Focused discussion about environmental monitoring, both faces at same height, solar panel visible mid-frame. ### hackathons/edtech -- File: `public/marketing/hackathons/edtech.webp` +- File: `public/marketing/hackathons/edtech-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults. IMPORTANT medium-wide framing with plenty of background above heads: whole faces and key equipment inside central horizontal 60% height, camera far enough away for safe wide crop. Contemporary Kenya, realistic natural daylight and skin texture, documentary photography. Plain clothes, blank walls, no writing, no posters, no logos, watermark, or floating digital graphics. A Kenyan woman teacher and Kenyan man software developer seated together testing an interactive learning prototype on a tablet in a bright classroom with empty desks behind. Adults only, no children. Clear educational setting, laptop nearby, helpful engaged body language, tablet screen angled away. ### hackathons/civic -- File: `public/marketing/hackathons/civic.webp` +- File: `public/marketing/hackathons/civic-tech-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults. IMPORTANT medium-wide framing with plenty of background above heads: whole faces and key equipment inside central horizontal 60% height, camera far enough away for safe wide crop. Contemporary Kenya, realistic natural daylight and skin texture, documentary photography. Plain clothes, blank walls, no writing, no posters, no logos, watermark, or floating digital graphics. Three Kenyan civic technology volunteers, two women and a man, gathered around a laptop and a simple paper city street map on a community workspace table. Discussing access to local public services and open data. Blank pinboard behind, open welcoming community room. Everyone seated, faces and map visible. ### hackathons/mobility -- File: `public/marketing/hackathons/mobility.webp` +- File: `public/marketing/hackathons/mobility-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults. IMPORTANT medium-wide framing with plenty of background above heads: whole faces and key equipment inside central horizontal 60% height, camera far enough away for safe wide crop. Contemporary Kenya, realistic natural daylight and skin texture, documentary photography. Plain clothes, blank walls, no writing, no posters, no logos, watermark, or floating digital graphics. A Kenyan woman transport software developer showing a tablet route-planning prototype to a Kenyan male transit operator beside a parked Nairobi minibus. Both standing in open shade, medium-wide composition, neutral white and blue minibus softly blurred behind, no brand names or licence plate text visible. Practical urban mobility innovation. ### hackathons/security -- File: `public/marketing/hackathons/security.webp` +- File: `public/marketing/hackathons/cybersecurity-hackathon-kenya.webp` - Export: 1600×900 Use case: photorealistic-natural. ONE category cover photograph for HackVillage hackathons. Landscape 16:9, requested 1600x900, also displayed center-cropped to 21:9. All people Black Kenyan adults. IMPORTANT medium-wide framing with plenty of background above heads: whole faces and key equipment inside central horizontal 60% height, camera far enough away for safe wide crop. Contemporary Kenya, realistic natural daylight and skin texture, documentary photography. Plain clothes, blank walls, no writing, no posters, no logos, watermark, or floating digital graphics. A Kenyan woman cybersecurity engineer with short natural hair and a Kenyan male colleague reviewing network monitoring on a laptop in a calm bright modern office. A compact networking device and hardware security key on desk, a monitor with softly blurred abstract monitoring charts behind. No hoodies, masks, ominous lighting or hacker cliches. diff --git a/docs/runbooks/chain-outage.md b/docs/runbooks/chain-outage.md index bd60f88..6f83227 100644 --- a/docs/runbooks/chain-outage.md +++ b/docs/runbooks/chain-outage.md @@ -13,7 +13,7 @@ 1. Confirm the RPC provider is back (`RPC_URL` health). 2. Re-drive parked attestations: for each event missing a ledger entry, call `attestVaultCreation` / `attestVaultLocked` / `attestPayout` — all idempotent (one entry per type/entity; covered by integration tests). -3. Run the nightly reconciliation job (`ledger.reconcile-cron`): a three-way match of DB rows ↔ chain events ↔ Paystack references. Investigate any mismatch before trusting the mirror again. +3. Run the nightly reconciliation job (`ledger.cron`, scheduled `0 3 * * *` in `lib/jobs/handlers.ts`): a three-way match of DB rows ↔ chain events ↔ Paystack references. Investigate any mismatch before trusting the mirror again. ## During a prolonged outage diff --git a/docs/runbooks/seo-aeo.md b/docs/runbooks/seo-aeo.md index 0d270d4..d5e8fe2 100644 --- a/docs/runbooks/seo-aeo.md +++ b/docs/runbooks/seo-aeo.md @@ -8,7 +8,7 @@ what only an owner can do in dashboards, and how to keep it healthy. | Surface | File | Purpose | | ----------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/sitemap.xml` | `app/sitemap.ts` | Discovery manifest: static pages, blog posts + pagination, public hackathons, developer profiles. Regenerates hourly. | -| `/robots.txt` | `app/robots.ts` | Crawler policy + `Sitemap:` line. Private surfaces disallowed; AI crawlers explicitly allowed. | +| `/robots.txt` | `app/robots.txt/route.ts` | Crawler policy + `Sitemap:` line. Private surfaces disallowed; AI crawlers explicitly allowed. | | `/llms.txt` | `app/llms.txt/route.ts` | AI-engine discovery file (community convention): citable facts + page map. Regenerates hourly. | | `/favicon.ico` | `app/favicon.ico` | Legacy crawler/browser fallback (the modern icon is `app/icon.png`). | | `/manifest.webmanifest` | `app/manifest.ts` | PWA metadata. | @@ -19,7 +19,7 @@ what only an owner can do in dashboards, and how to keep it healthy. **Rules when editing pages:** - New public page → give it `alternates.canonical`, a keyword-aware title/description, and add it to `app/sitemap.ts`. -- New private/auth surface → `robots: { index: false }` metadata (and add its path prefix to `PRIVATE_PATHS` in `app/robots.ts` only if it must not be crawled at all — noindex pages must stay crawlable). +- New private/auth surface → `robots: { index: false }` metadata (and add its path prefix to `PRIVATE_PATHS` in `app/robots.txt/route.ts` only if it must not be crawled at all — noindex pages must stay crawlable). - Structured data must match visible page content exactly (Google spam policy treats mismatches as manual-action risk). ## Owner dashboard actions (one-time) diff --git a/instrumentation-client.ts b/instrumentation-client.ts new file mode 100644 index 0000000..9d60eee --- /dev/null +++ b/instrumentation-client.ts @@ -0,0 +1,27 @@ +import * as Sentry from "@sentry/nextjs"; + +/** + * Client-side Sentry (SDK v11 convention: instrumentation-client.ts replaces + * sentry.client.config.ts). The browser only sees NEXT_PUBLIC_* vars, so the + * client DSN is a separate public value; when it is absent `enabled: false` + * makes the SDK a complete no-op — no transport, no warnings. + */ +const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; + +Sentry.init({ + dsn, + enabled: Boolean(dsn), + // See sentry.server.config.ts — v11 collects broadly by default; pin off. + dataCollection: { + userInfo: false, + cookies: false, + httpHeaders: { request: false, response: false }, + httpBodies: [], + urlQueryParams: false, + genAI: { inputs: false, outputs: false }, + }, + tracesSampleRate: 0, +}); + +/** Navigation spans for App Router (no-op while tracing is off). */ +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/instrumentation.ts b/instrumentation.ts index f13eff0..2c55673 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,4 +1,14 @@ export async function register(): Promise { + // Sentry: DSN-gated, so a boot without SENTRY_DSN never even loads the SDK. + if (process.env.SENTRY_DSN) { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } + } + // Jobs (pg-boss + Prisma) only run on the Node.js runtime — never the edge. if (process.env.NEXT_RUNTIME === "nodejs") { // Never registered during `next build` collection passes. diff --git a/lib/account/actions.ts b/lib/account/actions.ts index 2cc7b0e..678c6e6 100644 --- a/lib/account/actions.ts +++ b/lib/account/actions.ts @@ -31,7 +31,7 @@ export async function changePasswordAction( return { error: parsed.error.issues[0]?.message ?? "Check the form and try again." }; } - const limit = rateLimit(`pwchange:${user.id}`, 5, 60 * 60 * 1000); + const limit = await rateLimit(`pwchange:${user.id}`, 5, 60 * 60 * 1000); if (!limit.ok) return { error: "Too many attempts. Try again later." }; const { verify } = await import("@node-rs/argon2"); diff --git a/lib/admin/event-actions.ts b/lib/admin/event-actions.ts new file mode 100644 index 0000000..cbe9f94 --- /dev/null +++ b/lib/admin/event-actions.ts @@ -0,0 +1,51 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { requireUser } from "@/lib/auth/guards"; +import { cancelEventAsAdmin, EventCancelError } from "@/lib/events/cancel"; + +export interface AdminEventActionState { + error?: string; + message?: string; +} + +/** + * Admin force-cancel (audit remediation): requires the ADMIN grant (re-checked + * against the database inside cancelEventAsAdmin), a real reason (min 4 chars, + * audit-logged), and a pre-winners hackathon. A LOCKED or HALF_RELEASED vault + * is refunded through the escrow refund primitive; the organizer is notified. + */ +export async function adminCancelEventAction( + eventId: string, + reason: string +): Promise { + const admin = await requireUser(); + const parsedId = z.string().cuid().safeParse(eventId); + const parsedReason = z.string().trim().min(4).max(500).safeParse(reason); + if (!parsedId.success) return { error: "Unknown hackathon." }; + if (!parsedReason.success) { + return { error: "Give a short reason (at least 4 characters) — it's audit-logged." }; + } + + try { + const { slug, refunded } = await cancelEventAsAdmin({ + eventId: parsedId.data, + adminId: admin.id, + reason: parsedReason.data, + }); + revalidatePath("/admin/events"); + revalidatePath(`/hackathons/${slug}`); + revalidatePath("/hackathons"); + return { + message: refunded + ? "Hackathon cancelled and the locked vault was refunded." + : "Hackathon cancelled.", + }; + } catch (error) { + if (error instanceof EventCancelError) return { error: error.message }; + console.error("[admin] event cancel failed", error); + return { error: "Something went wrong. Try again in a moment." }; + } +} diff --git a/lib/admin/kyb-actions.ts b/lib/admin/kyb-actions.ts index 6f9530f..e86b9b2 100644 --- a/lib/admin/kyb-actions.ts +++ b/lib/admin/kyb-actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { requireUser } from "@/lib/auth/guards"; -import { prisma } from "@/lib/db"; +import { decideKyb, KybDecisionError } from "@/lib/orgs/verification-service"; import { sendMail } from "@/lib/ports/mail"; import { kybApprovedEmail, kybRejectedEmail } from "@/lib/notifications/templates/organizations"; import { appUrl } from "@/lib/url"; @@ -15,14 +15,14 @@ export interface KybDecisionState { /** * Admin KYB decisions (plan §14.1: admin overrides require a reason, logged * to the append-only AuditLog). Paystack-automated KYB replaces the manual - * review at go-live (Phase 9) — the interface stays identical. + * review at go-live (Phase 9) — the interface stays identical. The decision + * itself (with its PENDING precondition) lives in decideKyb. */ export async function decideKybAction( _prev: KybDecisionState, formData: FormData ): Promise { const admin = await requireUser(); - if (!admin.roles.includes("ADMIN")) return { error: "Admins only." }; const orgId = String(formData.get("orgId") ?? ""); const decision = String(formData.get("decision") ?? ""); @@ -35,39 +35,24 @@ export async function decideKybAction( return { error: "A rejection needs a reason the organizer can act on." }; } - const org = await prisma.organization.findUnique({ - where: { id: orgId }, - include: { owner: { select: { email: true } } }, - }); - if (!org) return { error: "Organization not found." }; - - await prisma.$transaction([ - prisma.organization.update({ - where: { id: orgId }, - data: { kycStatus: decision === "APPROVE" ? "VERIFIED" : "FAILED" }, - }), - // Organizations that asked before structured submissions have no row; updateMany skips them. - prisma.kybSubmission.updateMany({ - where: { orgId }, - data: { reviewedAt: new Date(), reviewNote: reason || null }, - }), - prisma.auditLog.create({ - data: { - actorId: admin.id, - action: decision === "APPROVE" ? "kyc.approved" : "kyc.rejected", - entity: "Organization", - entityId: orgId, - reason: reason || null, - }, - }), - ]); - - if (decision === "APPROVE") { - await sendMail({ to: org.owner.email, ...kybApprovedEmail(org.name, appUrl("/organizer")) }); - } else { - await sendMail({ to: org.owner.email, ...kybRejectedEmail(org.name, reason) }); + let outcome: { orgName: string; ownerEmail: string }; + try { + outcome = await decideKyb({ adminId: admin.id, orgId, decision, reason }); + } catch (error) { + if (error instanceof KybDecisionError) return { error: error.message }; + console.error(`[admin] KYB decision failed (org=${orgId})`, error); + return { error: "We couldn't record the decision. Please try again." }; } + const template = + decision === "APPROVE" + ? kybApprovedEmail(outcome.orgName, appUrl("/organizer")) + : kybRejectedEmail(outcome.orgName, reason); + // The decision is committed — a mail failure must not fail the action. + await sendMail({ to: outcome.ownerEmail, ...template }).catch((error: unknown) => + console.error(`[admin] KYB outcome email failed (org=${orgId})`, error) + ); + revalidatePath("/admin/kyb"); return {}; } diff --git a/lib/auth/actions.ts b/lib/auth/actions.ts index 93e89ab..184487f 100644 --- a/lib/auth/actions.ts +++ b/lib/auth/actions.ts @@ -3,6 +3,7 @@ import { createHash, randomBytes } from "node:crypto"; import { hash } from "@node-rs/argon2"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { z } from "zod"; @@ -10,6 +11,7 @@ import { signIn, signOut } from "@/lib/auth"; import { passwordResetEmail, verificationEmail } from "@/lib/auth/mail-templates"; import { candidateHandles, firstAvailableHandle, validateHandle } from "@/lib/auth/handles"; import { passwordSchema } from "@/lib/auth/password-policy"; +import { clientNetwork } from "@/lib/auth/sign-in-context"; import { sendMail } from "@/lib/ports/mail"; import { prisma } from "@/lib/db"; import { rateLimit } from "@/lib/rate-limit"; @@ -56,6 +58,25 @@ async function issueToken(email: string, ttlMs: number): Promise { // (see lib/auth/password-policy.ts — "use server" files export async // functions only) +// ── Per-IP secondary limits ────────────────────────────────────────────── + +/** + * Secondary per-IP throttle (30/hour) on the credential endpoints, alongside + * the per-email limits: an attacker cycling through target emails from one + * network still hits a wall. The client IP comes from the same + * Cloudflare/Vercel header logic as the sign-in alert email + * (lib/auth/sign-in-context.ts), so spoofed cf-* headers from non-edge + * addresses are never trusted. When no IP is provable the per-IP check is + * skipped rather than keyed on a shared "unknown" bucket. + */ +const IP_LIMIT_PER_HOUR = 30; + +async function rateLimitClientIp(scope: string): Promise<{ ok: boolean } | null> { + const ip = clientNetwork(await headers()).ip; + if (!ip) return null; + return rateLimit(`${scope}:${ip}`, IP_LIMIT_PER_HOUR, 60 * 60 * 1000); +} + // ── Sign up (credentials) ──────────────────────────────────────────────── const signUpSchema = z.object({ @@ -81,8 +102,10 @@ export async function signUpAction( const { name, email, password, role, handle } = parsed.data; const emailNormalized = email.toLowerCase(); - const limit = rateLimit(`signup:${emailNormalized}`, 5, 60 * 60 * 1000); + const limit = await rateLimit(`signup:${emailNormalized}`, 5, 60 * 60 * 1000); if (!limit.ok) return { error: "Too many attempts. Try again later." }; + const ipLimit = await rateLimitClientIp("signup-ip"); + if (ipLimit && !ipLimit.ok) return { error: "Too many attempts. Try again later." }; const existing = await prisma.user.findFirst({ where: { email: emailNormalized, deletedAt: null }, @@ -153,10 +176,14 @@ export async function signInAction( const { email, password } = parsed.data; const emailNormalized = email.toLowerCase(); - const limit = rateLimit(`signin:${emailNormalized}`, 10, 15 * 60 * 1000); + const limit = await rateLimit(`signin:${emailNormalized}`, 10, 15 * 60 * 1000); if (!limit.ok) { return { error: "Too many attempts. Wait a few minutes and try again." }; } + const ipLimit = await rateLimitClientIp("signin-ip"); + if (ipLimit && !ipLimit.ok) { + return { error: "Too many attempts. Wait a few minutes and try again." }; + } const user = await prisma.user.findUnique({ where: { email: emailNormalized } }); if (!user || user.deletedAt) { @@ -215,7 +242,7 @@ export async function resendVerificationAction( if (!email.success) return { error: "Enter the email you signed up with." }; const emailNormalized = email.data.toLowerCase(); - const limit = rateLimit(`verify-resend:${emailNormalized}`, 3, 60 * 60 * 1000); + const limit = await rateLimit(`verify-resend:${emailNormalized}`, 3, 60 * 60 * 1000); if (!limit.ok) return { error: "Verification emails are limited. Try again in a while." }; const user = await prisma.user.findUnique({ where: { email: emailNormalized } }); @@ -240,8 +267,10 @@ export async function requestPasswordResetAction( if (!email.success) return { error: "That email doesn't look right." }; const emailNormalized = email.data.toLowerCase(); - const limit = rateLimit(`reset:${emailNormalized}`, 3, 60 * 60 * 1000); + const limit = await rateLimit(`reset:${emailNormalized}`, 3, 60 * 60 * 1000); if (!limit.ok) return { error: "Reset emails are limited. Try again in a while." }; + const ipLimit = await rateLimitClientIp("reset-ip"); + if (ipLimit && !ipLimit.ok) return { error: "Reset emails are limited. Try again in a while." }; const user = await prisma.user.findUnique({ where: { email: emailNormalized } }); if (user && !user.deletedAt && user.passwordHash) { diff --git a/lib/env.ts b/lib/env.ts index 81f8e37..60f2ec0 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -30,6 +30,12 @@ const serverEnvSchema = z.object({ // Paystack (optional — absent key switches payments to dev simulation) PAYSTACK_SECRET_KEY: z.string().optional(), + // Explicit opt-in to run the SIMULATION payment port in production (the + // pre-go-live state). Without this, a keyless production boot fails closed. + PAYSTACK_ALLOW_SIMULATION_IN_PROD: z + .string() + .optional() + .transform((value) => value === "true"), // Chain (optional — absent config switches attestations to simulation) SMART_CONTRACT_ADDRESS: z.string().optional(), diff --git a/lib/events/actions.ts b/lib/events/actions.ts index 164c160..c51c300 100644 --- a/lib/events/actions.ts +++ b/lib/events/actions.ts @@ -7,10 +7,11 @@ import { z } from "zod"; import { requireUser } from "@/lib/auth/guards"; import { getEnv } from "@/lib/env"; import { prisma } from "@/lib/db"; +import { cancelEventAsOrganizer, EventCancelError } from "@/lib/events/cancel"; import { sendNotification } from "@/lib/notifications/send"; import { eventPublishedEmail } from "@/lib/notifications/templates/events"; import { appUrl } from "@/lib/url"; -import { canPublishDraft } from "@/lib/events/lifecycle"; +import { canPublishDraft, mediaDeadlineFor } from "@/lib/events/lifecycle"; import { eventSlugCandidates, eventWizardSchema, @@ -216,8 +217,8 @@ export async function publishEventAction(eventId: string): Promise { + const user = await requireUser(); + const parsedId = z.string().cuid().safeParse(eventId); + if (!parsedId.success) return { error: "Unknown hackathon." }; + + try { + const { slug } = await cancelEventAsOrganizer({ eventId: parsedId.data, userId: user.id }); + revalidatePath("/organizer"); + revalidatePath(`/organizer/hackathons/${slug}`); + revalidatePath(`/hackathons/${slug}`); + revalidatePath("/hackathons"); + return {}; + } catch (error) { + if (error instanceof EventCancelError) return { error: error.message }; + console.error("[events] cancel failed", error); + return { error: "Something went wrong. Try again in a moment." }; + } +} diff --git a/lib/events/announce.ts b/lib/events/announce.ts index dcde6d9..8f745a0 100644 --- a/lib/events/announce.ts +++ b/lib/events/announce.ts @@ -12,7 +12,16 @@ import { formatKes } from "@/lib/utils"; /** In-app notification type; also how a builder is marked as already told. */ export const HACKATHON_LIVE_NOTIFICATION = "hackathon.live"; +/** Users pulled per page — cursor-paginated so memory stays flat. */ +const USER_PAGE_SIZE = 200; +/** Emails sent in parallel within a page. */ const SEND_BATCH_SIZE = 10; +/** + * Hard bound on total work per invocation: a retry (the where clause excludes + * anyone already told) picks up the remainder — a huge user base can never + * turn one job run into an unbounded loop. + */ +const MAX_RECIPIENTS_PER_RUN = 2_000; const dayFormat = new Intl.DateTimeFormat("en-KE", { day: "numeric", @@ -46,10 +55,26 @@ export function announcementRecipientsWhere(event: { }; } +/** + * Which users from one page actually get told: drop opted-out preferences + * (batch-loaded, never per-user) and anyone a racing run already notified + * since the page was read. Pure — unit-tested. + */ +export function filterAnnouncementTargets( + users: T[], + optedOutUserIds: ReadonlySet, + alreadyToldUserIds: ReadonlySet +): T[] { + return users.filter((user) => !optedOutUserIds.has(user.id) && !alreadyToldUserIds.has(user.id)); +} + /** * Emails builders that a hackathon just went live with its prize locked, and * records an in-app notification for each. Demo hackathons and anything not * public are never announced. Returns how many builders were told. + * + * Batched by construction: cursor-paginated user pages, batch-loaded + * preferences, one createMany per page, bounded total work per run. */ export async function announceHackathonToBuilders(eventId: string): Promise { const event = await prisma.event.findFirst({ @@ -69,13 +94,6 @@ export async function announceHackathonToBuilders(eventId: string): Promise isRealAccountEmail(user.email)); - const template = newPrizeVerifiedHackathonEmail({ eventTitle: event.title, eventUrl: appUrl(`/hackathons/${event.slug}`), @@ -86,30 +104,79 @@ export async function announceHackathonToBuilders(eventId: string): Promise { - // Record first: if the email fails, a retry won't send it twice. - await prisma.notification.create({ - data: { - userId: user.id, - type: HACKATHON_LIVE_NOTIFICATION, - payload: { eventId: event.id, eventTitle: event.title, slug: event.slug }, - }, - }); - await sendNotification({ - userId: user.id, - to: user.email, - category: "eventUpdates", - template, - }); + let cursor: string | undefined; + // Paginate by primary key so the scan is stable even as notifications land. + while (told < MAX_RECIPIENTS_PER_RUN) { + const page = await prisma.user.findMany({ + where: announcementRecipientsWhere(event), + select: { id: true, email: true }, + orderBy: { id: "asc" }, + take: USER_PAGE_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + if (page.length === 0) break; + cursor = page[page.length - 1]!.id; + + const pageIds = page.map((user) => user.id); + // Batch-load instead of per-user queries (the old fan-out was an N+1). + const [preferences, alreadyTold] = await Promise.all([ + prisma.notificationPreference.findMany({ + where: { userId: { in: pageIds } }, + select: { userId: true, eventUpdates: true }, }), + // Re-check inside the page: narrows the race with a concurrent run. + prisma.notification.findMany({ + where: { + userId: { in: pageIds }, + type: HACKATHON_LIVE_NOTIFICATION, + payload: { path: ["eventId"], equals: event.id }, + }, + select: { userId: true }, + }), + ]); + const optedOut = new Set( + preferences.filter((p) => p.eventUpdates === false).map((p) => p.userId) + ); + const toldIds = new Set(alreadyTold.map((n) => n.userId)); + + const targets = filterAnnouncementTargets( + page.filter((user) => isRealAccountEmail(user.email)), + optedOut, + toldIds ); - for (const result of results) { - if (result.status === "fulfilled") told += 1; - else console.error(`[announce] notifying a builder about ${event.id} failed`, result.reason); + if (targets.length > 0) { + // Record first: if an email fails, a retry won't send it twice. + await prisma.notification.createMany({ + data: targets.map((user) => ({ + userId: user.id, + type: HACKATHON_LIVE_NOTIFICATION, + payload: { eventId: event.id, eventTitle: event.title, slug: event.slug }, + })), + skipDuplicates: true, + }); + + for (let start = 0; start < targets.length; start += SEND_BATCH_SIZE) { + const batch = targets.slice(start, start + SEND_BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(async (user) => { + await sendNotification({ + userId: user.id, + to: user.email, + category: "eventUpdates", + preferencePrechecked: true, + template, + }); + }) + ); + for (const result of results) { + if (result.status === "fulfilled") told += 1; + else console.error(`[announce] notifying a builder about ${event.id} failed`, result.reason); + } + } } + + if (page.length < USER_PAGE_SIZE) break; // last page } + return told; } diff --git a/lib/events/cancel.ts b/lib/events/cancel.ts new file mode 100644 index 0000000..8b62225 --- /dev/null +++ b/lib/events/cancel.ts @@ -0,0 +1,155 @@ +import { prisma } from "@/lib/db"; +import { sendNotification } from "@/lib/notifications/send"; +import { eventCancelledEmail } from "@/lib/notifications/templates/events"; +import { appUrl } from "@/lib/url"; + +/** + * Event cancellation (audit remediation). Two entry points share this module: + * the organizer self-service cancel (drafts/unfunded only, no vault) and the + * admin cancel (pre-winners, with vault refund when money is locked). + */ +export class EventCancelError extends Error { + constructor( + message: string, + public code: "NOT_FOUND" | "FORBIDDEN" | "WRONG_STATE" + ) { + super(message); + } +} + +/** Organizer self-service: only DRAFT/PENDING_DEPOSIT, and never with a vault. */ +export async function cancelEventAsOrganizer(input: { + eventId: string; + userId: string; +}): Promise<{ slug: string }> { + const event = await prisma.event.findUnique({ + where: { id: input.eventId }, + select: { + id: true, + orgId: true, + status: true, + slug: true, + vault: { select: { id: true } }, + }, + }); + if (!event) throw new EventCancelError("Hackathon not found.", "NOT_FOUND"); + + const membership = await prisma.orgMember.findFirst({ + where: { + orgId: event.orgId, + userId: input.userId, + status: "ACTIVE", + role: { in: ["OWNER", "ADMIN"] }, + }, + select: { id: true }, + }); + if (!membership) { + throw new EventCancelError("Only organization owners and admins can cancel a hackathon.", "FORBIDDEN"); + } + if (event.status !== "DRAFT" && event.status !== "PENDING_DEPOSIT") { + throw new EventCancelError( + "Only drafts and unfunded hackathons can be cancelled from here.", + "WRONG_STATE" + ); + } + if (event.vault) { + // A vault means money moved — unwinding it is the admin/refund path. + throw new EventCancelError( + "This hackathon has a prize vault. Contact support@hackvillage.xyz to unwind it safely.", + "WRONG_STATE" + ); + } + + await prisma.$transaction([ + prisma.event.update({ where: { id: event.id }, data: { status: "CANCELLED" } }), + prisma.auditLog.create({ + data: { + actorId: input.userId, + action: "event.cancelled", + entity: "Event", + entityId: event.id, + reason: "Cancelled by the organizer.", + }, + }), + ]); + return { slug: event.slug }; +} + +/** Statuses an admin may still cancel: anything before winners are announced. */ +const ADMIN_CANCELLABLE_STATUSES = new Set([ + "DRAFT", + "PENDING_DEPOSIT", + "LIVE", + "IN_PROGRESS", + "JUDGING", +]); + +/** Admin cancel: audited reason required; refunds a LOCKED/HALF_RELEASED vault. */ +export async function cancelEventAsAdmin(input: { + eventId: string; + adminId: string; + reason: string; +}): Promise<{ slug: string; refunded: boolean }> { + const admin = await prisma.roleGrant.findFirst({ + where: { userId: input.adminId, role: "ADMIN" }, + select: { id: true }, + }); + if (!admin) throw new EventCancelError("Admins only.", "FORBIDDEN"); + + const event = await prisma.event.findUnique({ + where: { id: input.eventId }, + select: { + id: true, + status: true, + slug: true, + title: true, + vault: { select: { chainState: true } }, + org: { select: { owner: { select: { id: true, email: true } } } }, + }, + }); + if (!event) throw new EventCancelError("Hackathon not found.", "NOT_FOUND"); + if (!ADMIN_CANCELLABLE_STATUSES.has(event.status)) { + throw new EventCancelError( + "Hackathons with announced winners can't be cancelled — resolve payouts and disputes instead.", + "WRONG_STATE" + ); + } + + await prisma.$transaction([ + prisma.event.update({ where: { id: event.id }, data: { status: "CANCELLED" } }), + prisma.auditLog.create({ + data: { + actorId: input.adminId, + action: "event.admin-cancelled", + entity: "Event", + entityId: event.id, + reason: input.reason, + }, + }), + ]); + + // The vault refund runs outside the status transaction: refundLockedVault + // owns its own state machine + attestation and is idempotent. + let refunded = false; + if (event.vault && (event.vault.chainState === "LOCKED" || event.vault.chainState === "HALF_RELEASED")) { + const { refundLockedVault } = await import("@/services/escrow/refund"); + const result = await refundLockedVault( + event.id, + `Admin cancellation: ${input.reason}`, + input.adminId + ); + refunded = result.refunded; + } + + // Post-commit notice — money-relevant, so it always sends (no category); + // a mail failure must never fail the cancellation. + await sendNotification({ + userId: event.org.owner.id, + to: event.org.owner.email, + template: eventCancelledEmail(event.title, input.reason, appUrl("/organizer")), + }).catch((error: unknown) => + console.error(`[events] admin-cancel notice failed (event=${event.id})`, error) + ); + + return { slug: event.slug, refunded }; +} diff --git a/lib/events/cover-service.ts b/lib/events/cover-service.ts index 584dd7c..1359543 100644 --- a/lib/events/cover-service.ts +++ b/lib/events/cover-service.ts @@ -54,7 +54,13 @@ export async function setCover(input: { userId: string; eventId: string; key: st if (!isCoverKeyFor(input.eventId, input.key)) { throw new CoverError("That upload doesn't belong to this hackathon."); } - const coverUrl = getStoragePort().urlForKey(input.key); + const storage = getStoragePort(); + // The object must actually exist — otherwise the hackathon points at a + // cover that 404s forever. + if (!(await storage.verifyUploaded(input.key))) { + throw new CoverError("The uploaded cover never landed. Upload it again."); + } + const coverUrl = storage.urlForKey(input.key); await prisma.$transaction([ prisma.event.update({ where: { id: event.id }, data: { coverUrl } }), prisma.auditLog.create({ diff --git a/lib/events/covers.ts b/lib/events/covers.ts index e9e34a8..6198cd4 100644 --- a/lib/events/covers.ts +++ b/lib/events/covers.ts @@ -1,7 +1,8 @@ import { isCategory, type HackathonCategory } from "@/lib/events/categories"; import { RENAMED_IMAGES } from "@/lib/seo/renamed-images"; -export const DEFAULT_COVER = "/marketing/blog/kenyan-developers-building-together.webp"; +// Fallback when no category matches — module-private (not part of the API). +const DEFAULT_COVER = "/marketing/blog/kenyan-developers-building-together.webp"; /** Category-specific compositions work in both the 21:9 cards and 16:9 detail panel. */ const CATEGORY_COVERS: Record = { diff --git a/lib/events/format.ts b/lib/events/format.ts index 18e272f..0fc46eb 100644 --- a/lib/events/format.ts +++ b/lib/events/format.ts @@ -1,4 +1,6 @@ /** Events are run and displayed on Nairobi time, wherever the server sits. */ +import { mediaDeadlineFor } from "@/lib/events/lifecycle"; + const EVENT_TIME_ZONE = "Africa/Nairobi"; const dayMonthYear = new Intl.DateTimeFormat("en-KE", { @@ -79,9 +81,6 @@ export function formatDateTime(date: Date): string { return `${pick("weekday")} ${pick("day")} ${pick("month")}, ${pick("hour")}:${pick("minute")}`; } -/** Media must be delivered within 48 hours of the end (plan §13 media standard). */ -const MEDIA_WINDOW_MS = 48 * 60 * 60 * 1000; - export interface KeyDate { label: string; at: Date; @@ -102,10 +101,8 @@ export function keyDates( ["Registration closes", event.registrationDeadline], ["Hacking starts", event.startsAt], ["Submissions close", event.endsAt], - [ - "Media delivered by", - event.mediaDeadlineAt ?? new Date(event.endsAt.getTime() + MEDIA_WINDOW_MS), - ], + // Media must be delivered within 48 hours of the end (plan §13 media standard). + ["Media delivered by", event.mediaDeadlineAt ?? mediaDeadlineFor(event.endsAt)], ]; const nextIndex = milestones.findIndex(([, at]) => at.getTime() > now.getTime()); return milestones.map(([label, at], index) => ({ diff --git a/lib/events/lifecycle.ts b/lib/events/lifecycle.ts index 6b13766..f0a24fa 100644 --- a/lib/events/lifecycle.ts +++ b/lib/events/lifecycle.ts @@ -17,16 +17,13 @@ export type EventStatus = export type VenueType = "PHYSICAL" | "ONLINE" | "HYBRID"; -/** Statuses visible on the public browse (anything published, incl. cancelled). */ -export const PUBLIC_STATUSES: EventStatus[] = [ - "PENDING_DEPOSIT", - "LIVE", - "IN_PROGRESS", - "JUDGING", - "WINNERS_ANNOUNCED", - "SETTLED", - "CANCELLED", -]; +/** + * The media deadline: endsAt + 48h (plan §10.6). The single source for the + * 48-hour window — services/media re-exports it; never hardcode the window. + */ +export function mediaDeadlineFor(endsAt: Date): Date { + return new Date(endsAt.getTime() + 48 * 60 * 60 * 1000); +} /** Registration is open before the deadline, while the event is announced. */ export function registrationOpen( diff --git a/lib/events/registration-actions.ts b/lib/events/registration-actions.ts index 7a22c67..3cb8be0 100644 --- a/lib/events/registration-actions.ts +++ b/lib/events/registration-actions.ts @@ -7,6 +7,7 @@ import { requireUser } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; import { isPublicHackathon } from "@/lib/events/visibility"; import { registrationOpen } from "@/lib/events/lifecycle"; +import { setRegistrationStatus } from "@/lib/events/registrations"; import { sendNotification } from "@/lib/notifications/send"; import { registrationCancelledEmail, @@ -18,6 +19,13 @@ export interface RegistrationActionState { error?: string; } +/** + * Registration receipts are user-initiated transactional mail (the person + * clicked the button and needs the confirmation) — they go out WITHOUT a + * notification category, so they always send and carry no preference-based + * unsubscribe, and only when the write actually changed the status. + */ + /** Developer registers for a published event (before the deadline). */ export async function registerForEventAction(eventSlug: string): Promise { const user = await requireUser(); @@ -28,19 +36,20 @@ export async function registerForEventAction(eventSlug: string): Promise { redirect(`/hackathons/${eventSlug}?registration=closed`); } - await prisma.registration.upsert({ - where: { eventId_userId: { eventId: event.id, userId: user.id } }, - create: { eventId: event.id, userId: user.id, status: "REGISTERED" }, - update: { status: "REGISTERED" }, - }); - - await sendNotification({ + const { changed } = await setRegistrationStatus({ + eventId: event.id, userId: user.id, - to: user.email, - category: "eventUpdates", - template: registrationConfirmedEmail(event.title, appUrl(`/hackathons/${eventSlug}`)), + status: "REGISTERED", }); + if (changed) { + await sendNotification({ + userId: user.id, + to: user.email, + template: registrationConfirmedEmail(event.title, appUrl(`/hackathons/${eventSlug}`)), + }); + } + revalidatePath(`/hackathons/${eventSlug}`); redirect(`/hackathons/${eventSlug}/workspace`); } @@ -64,18 +73,20 @@ export async function cancelRegistrationAction(eventSlug: string): Promise redirect(`/hackathons/${eventSlug}/workspace?leave=team-first`); } - await prisma.registration.updateMany({ - where: { eventId: event.id, userId: user.id }, - data: { status: "CANCELLED" }, - }); - - await sendNotification({ + const { changed } = await setRegistrationStatus({ + eventId: event.id, userId: user.id, - to: user.email, - category: "eventUpdates", - template: registrationCancelledEmail(event.title), + status: "CANCELLED", }); + if (changed) { + await sendNotification({ + userId: user.id, + to: user.email, + template: registrationCancelledEmail(event.title), + }); + } + revalidatePath(`/hackathons/${eventSlug}`); redirect("/dashboard/hackathons"); } diff --git a/lib/events/registrations.ts b/lib/events/registrations.ts new file mode 100644 index 0000000..e2539bc --- /dev/null +++ b/lib/events/registrations.ts @@ -0,0 +1,41 @@ +import { Prisma } from "@prisma/client"; + +import { prisma } from "@/lib/db"; + +/** + * Registration status transitions. The receipt emails are transactional and + * user-initiated (a person clicked register/cancel), so they always send — + * but only when a transition ACTUALLY happened. Both paths write with the + * current status in the WHERE clause, so an idempotent re-click (or double + * submit) is a no-op write that triggers no duplicate receipt. + */ +export async function setRegistrationStatus(input: { + eventId: string; + userId: string; + status: "REGISTERED" | "CANCELLED"; +}): Promise<{ changed: boolean }> { + if (input.status === "REGISTERED") { + try { + await prisma.registration.create({ + data: { eventId: input.eventId, userId: input.userId, status: "REGISTERED" }, + }); + return { changed: true }; + } catch (error) { + const isUniqueViolation = + error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"; + if (!isUniqueViolation) throw error; + } + // A row already exists: flip it back only if it isn't REGISTERED already. + const result = await prisma.registration.updateMany({ + where: { eventId: input.eventId, userId: input.userId, status: { not: "REGISTERED" } }, + data: { status: "REGISTERED" }, + }); + return { changed: result.count > 0 }; + } + + const result = await prisma.registration.updateMany({ + where: { eventId: input.eventId, userId: input.userId, status: "REGISTERED" }, + data: { status: "CANCELLED" }, + }); + return { changed: result.count > 0 }; +} diff --git a/lib/events/status-jobs.ts b/lib/events/status-jobs.ts new file mode 100644 index 0000000..8e6844b --- /dev/null +++ b/lib/events/status-jobs.ts @@ -0,0 +1,16 @@ +import { prisma } from "@/lib/db"; + +/** + * Event status lifecycle jobs (audit remediation — the LIVE → IN_PROGRESS + * leg was missing, so started hackathons rendered "Live" forever). Wired + * into the hourly escrow.cron handler in lib/jobs/handlers.ts. + */ + +/** LIVE events whose start time has passed become IN_PROGRESS (bulk). */ +export async function advanceStartedEvents(now: Date = new Date()): Promise { + const advanced = await prisma.event.updateMany({ + where: { status: "LIVE", startsAt: { lte: now } }, + data: { status: "IN_PROGRESS" }, + }); + return advanced.count; +} diff --git a/lib/events/validation.ts b/lib/events/validation.ts index b33a4d1..a3499af 100644 --- a/lib/events/validation.ts +++ b/lib/events/validation.ts @@ -4,7 +4,8 @@ import { parseCategoryList } from "@/lib/events/categories"; /** * Event wizard validation (server-authoritative). The wizard is a 5-step - * client stepper over ONE form — this schema is the whole contract. + * client stepper over ONE form: the step schemas below validate each step + * client-side, and the merged eventWizardSchema is the server contract. */ export const eventBasicsSchema = z.object({ @@ -46,7 +47,7 @@ export const eventProblemSchema = z.object({ ), }); -export const prizePlaceSchema = z.object({ +const prizePlaceSchema = z.object({ place: z.coerce.number().int().min(1).max(20), label: z.string().trim().min(2).max(60), amountKes: z.coerce.number().int().min(1_000, "Each prize must be at least KES 1,000."), @@ -74,13 +75,6 @@ export const eventWizardSchema = eventBasicsSchema path: ["endsAt"], }); -export type EventWizardInput = z.infer; - -/** Sum of prize places = the pool the organizer must fund (ADR-012). */ -export function poolFromPrizes(prizes: { amountKes: number }[]): number { - return prizes.reduce((total, prize) => total + prize.amountKes, 0); -} - /** Places must be unique (unique(eventId, place) is a DB constraint too). */ export function placesAreUnique(prizes: { place: number }[]): boolean { return new Set(prizes.map((p) => p.place)).size === prizes.length; diff --git a/lib/jobs/handlers.ts b/lib/jobs/handlers.ts index 1d803fb..5ea5e2e 100644 --- a/lib/jobs/handlers.ts +++ b/lib/jobs/handlers.ts @@ -51,6 +51,10 @@ export async function registerJobs(): Promise { await registerJob(boss, "escrow.attest-vault-locked", async (data) => attestations.attestVaultLocked(String(data.eventId)) ); + // Refund attestation (escrow.refund primitive) — state-aware handler. + await registerJob(boss, "escrow.attest-refund", async (data) => + attestations.attestRefund(String(data.eventId), String(data.refundRef)) + ); // New Prize Verified hackathon alert to builders (fan-out off the money path). await registerJob(boss, "hackathon.announce", async (data) => { @@ -73,7 +77,7 @@ export async function registerJobs(): Promise { }); }); - // Hourly cron: deposit expiry + payout recovery sweep. + // Hourly cron: deposit expiry + webhook outbox repair + event lifecycle. await boss.createQueue("escrow.cron").catch((error: { code?: string }) => { if (error?.code !== "B03") throw error; }); @@ -85,6 +89,16 @@ export async function registerJobs(): Promise { if (job.data?.kind !== "expire-deposits") continue; const expired = await deposits.expireStaleDeposits(); if (expired > 0) console.log(`[cron] expired ${expired} stale deposit(s)`); + + // Webhook outbox: re-drive events recorded but never processed (>1h). + const { reprocessStaleWebhookEvents } = await import("@/services/escrow/webhook"); + const reprocessed = await reprocessStaleWebhookEvents(); + if (reprocessed > 0) console.log(`[cron] reprocessed ${reprocessed} stale webhook event(s)`); + + // Lifecycle: LIVE events past their start time become IN_PROGRESS. + const { advanceStartedEvents } = await import("@/lib/events/status-jobs"); + const advanced = await advanceStartedEvents(); + if (advanced > 0) console.log(`[cron] advanced ${advanced} event(s) to IN_PROGRESS`); } }); @@ -99,6 +113,9 @@ export async function registerJobs(): Promise { if (job.data?.kind !== "sweep-payouts") continue; const driven = await payouts.sweepStuckPayouts(); if (driven > 0) console.log(`[cron] payout sweep re-drove ${driven} payout(s)`); + // Vault healing: recompute stuck WINNERS_ANNOUNCED vaults from payout facts. + const healed = await payouts.healVaultStates(); + if (healed > 0) console.log(`[cron] healed ${healed} vault state(s)`); } }); diff --git a/lib/judging/compute.ts b/lib/judging/compute.ts index 1f44b7a..22675ff 100644 --- a/lib/judging/compute.ts +++ b/lib/judging/compute.ts @@ -74,7 +74,6 @@ export interface TeamResult { export interface RankedResults { results: TeamResult[]; - allJudgesFinalized: boolean; } /** @@ -114,7 +113,7 @@ export function computeResults( result.rank = currentRank; } - return { results: scored, allJudgesFinalized: scored.every((t) => t.judgeCount > 0) }; + return { results: scored }; } /** Score values are integers 0–10. */ diff --git a/lib/net/cloudflare.ts b/lib/net/cloudflare.ts index 45b3209..5322631 100644 --- a/lib/net/cloudflare.ts +++ b/lib/net/cloudflare.ts @@ -1,13 +1,20 @@ import { BlockList, isIP } from "node:net"; /** - * Cloudflare's published edge ranges (https://www.cloudflare.com/ips/, - * fetched 26 September 2026). hackvillage.xyz is proxied through Cloudflare + * Cloudflare's edge ranges. hackvillage.xyz is proxied through Cloudflare * before Vercel, so Vercel sees one of these addresses instead of the - * visitor's. Only a request that really arrived from one of them may have its - * cf-* headers trusted: anyone can send those headers to the vercel.app URL. + * visitor's. Only a request that really arrived from one of them may have + * its cf-* headers trusted: anyone can send those headers to the + * vercel.app URL. + * + * The ranges drift a few times a year, so the authoritative lists + * (https://www.cloudflare.com/ips-v4, /ips-v6) are re-fetched every 24h in + * process. Module load NEVER blocks on the network: the bundled snapshot is + * the synchronous default and stays in service whenever the fetch fails. */ -const CLOUDFLARE_IPV4 = [ + +// ── Fallback snapshot (fetched from cloudflare.com 26 September 2026) ────── +const FALLBACK_IPV4 = [ "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", @@ -25,7 +32,7 @@ const CLOUDFLARE_IPV4 = [ "131.0.72.0/22", ]; -const CLOUDFLARE_IPV6 = [ +const FALLBACK_IPV6 = [ "2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", @@ -35,14 +42,56 @@ const CLOUDFLARE_IPV6 = [ "2c0f:f248::/32", ]; -const ranges = new BlockList(); -for (const cidr of CLOUDFLARE_IPV4) { - const [network, prefix] = cidr.split("/"); - ranges.addSubnet(network, Number(prefix), "ipv4"); +const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; +const CIDR_PATTERN = /^[\da-f.:]+\/\d{1,3}$/i; + +function buildBlockList(cidrs: string[]): BlockList { + const list = new BlockList(); + for (const cidr of cidrs) { + const [network, prefix] = cidr.split("/"); + list.addSubnet(network, Number(prefix), isIP(network) === 6 ? "ipv6" : "ipv4"); + } + return list; +} + +// Synchronous default — the bundled snapshot. A successful refresh swaps the +// reference; isCloudflareIp always reads whatever is current. +let ranges = buildBlockList([...FALLBACK_IPV4, ...FALLBACK_IPV6]); + +function parseCidrs(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => CIDR_PATTERN.test(line)); } -for (const cidr of CLOUDFLARE_IPV6) { - const [network, prefix] = cidr.split("/"); - ranges.addSubnet(network, Number(prefix), "ipv6"); + +async function fetchCidrs(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url} -> HTTP ${response.status}`); + return parseCidrs(await response.text()); +} + +async function refreshRanges(): Promise { + try { + const [v4, v6] = await Promise.all([ + fetchCidrs("https://www.cloudflare.com/ips-v4"), + fetchCidrs("https://www.cloudflare.com/ips-v6"), + ]); + // Refuse an empty/garbage response: the snapshot is better than nothing. + if (v4.length === 0 || v6.length === 0) throw new Error("empty range list"); + ranges = buildBlockList([...v4, ...v6]); + } catch (error) { + console.error("[cloudflare] range refresh failed — keeping the bundled snapshot", error); + } finally { + // 24h in-process cache; unref'd so the timer never holds the process open. + setTimeout(() => void refreshRanges(), REFRESH_INTERVAL_MS).unref(); + } +} + +// Fire-and-forget at module init. Skipped in tests so suite results never +// depend on outbound network access (the snapshot is exercised instead). +if (process.env.NODE_ENV !== "test") { + void refreshRanges(); } export function isCloudflareIp(ip: string): boolean { diff --git a/lib/newsletter/actions.ts b/lib/newsletter/actions.ts index a086302..8178fa2 100644 --- a/lib/newsletter/actions.ts +++ b/lib/newsletter/actions.ts @@ -4,9 +4,17 @@ import { z } from "zod"; import { prisma } from "@/lib/db"; import { rateLimit } from "@/lib/rate-limit"; -import { newsletterWelcomeEmail } from "@/lib/newsletter/mail-templates"; +import { + newsletterResubscribeConfirmEmail, + newsletterWelcomeEmail, +} from "@/lib/newsletter/mail-templates"; import { sendNewsletterMail } from "@/lib/newsletter/send"; -import { verifyNewsletterUnsubscribeToken } from "@/lib/newsletter/unsubscribe-token"; +import { + createNewsletterResubscribeToken, + verifyNewsletterResubscribeToken, + verifyNewsletterUnsubscribeToken, +} from "@/lib/newsletter/unsubscribe-token"; +import { appUrl } from "@/lib/url"; export interface SubscribeState { error?: string; @@ -22,6 +30,10 @@ const subscribeSchema = z.object({ * a generic success message so a failed welcome-email send (provider outage, * for example) never blocks the subscription itself, and so the response * gives no signal about whether an email was already on the list. + * + * Re-subscribing a previously unsubscribed address does NOT clear + * unsubscribedAt directly: a forwarded form can't silently re-add someone. + * They get a signed confirmation link instead; only the click re-opts them in. */ export async function subscribeAction( _prev: SubscribeState, @@ -41,17 +53,33 @@ export async function subscribeAction( const email = parsed.data.email.toLowerCase(); - const limit = rateLimit(`newsletter:${email}`, 5, 60 * 60 * 1000); + const limit = await rateLimit(`newsletter:${email}`, 5, 60 * 60 * 1000); if (!limit.ok) { return { error: "Too many attempts with that email, try again in a while." }; } + // Previously unsubscribed: confirm-instead-of-auto-resubscribe. + const existing = await prisma.newsletterSubscriber.findUnique({ where: { email } }); + if (existing?.unsubscribedAt) { + const token = createNewsletterResubscribeToken(existing.id); + await sendNewsletterMail({ + to: email, + subscriberId: existing.id, + template: newsletterResubscribeConfirmEmail( + appUrl(`/newsletter/resubscribe?token=${token}`) + ), + }).catch((error: unknown) => + console.error("[newsletter] resubscribe confirm email failed", error) + ); + return { message: "subscribed" }; + } + let subscriber: { id: string }; try { subscriber = await prisma.newsletterSubscriber.upsert({ where: { email }, create: { email, source: "landing-cta" }, - update: { unsubscribedAt: null }, + update: {}, }); } catch (error: unknown) { // Nothing was saved, so tell the person to retry rather than claiming @@ -100,3 +128,29 @@ export async function newsletterUnsubscribeAction( return { done: true }; } + +/** + * The click on a re-subscribe confirmation link — the ONLY path that clears + * unsubscribedAt for a returning address. + */ +export async function confirmNewsletterResubscribeAction( + _prev: NewsletterUnsubscribeState, + formData: FormData +): Promise { + const token = String(formData.get("token") ?? ""); + const subscriberId = verifyNewsletterResubscribeToken(token); + if (!subscriberId) { + return { error: "This confirmation link is invalid or has expired. Subscribe again." }; + } + + try { + await prisma.newsletterSubscriber.update({ + where: { id: subscriberId }, + data: { unsubscribedAt: null }, + }); + } catch { + return { error: "We couldn't find that subscription. Subscribe again from the site." }; + } + + return { done: true }; +} diff --git a/lib/newsletter/mail-templates.ts b/lib/newsletter/mail-templates.ts index 9d42e6a..64c3a5a 100644 --- a/lib/newsletter/mail-templates.ts +++ b/lib/newsletter/mail-templates.ts @@ -31,6 +31,31 @@ export function newsletterWelcomeEmail(): EmailTemplate { }; } +/** + * Sent when someone re-subscribes an address that had unsubscribed: the list + * stays opted out until the inbox owner clicks the confirm link (a forwarded + * signup form can't silently re-add them). + */ +export function newsletterResubscribeConfirmEmail(confirmUrl: string): EmailTemplate { + return { + subject: "Confirm You Want Back On The List", + html: renderEmail({ + preheader: "You (or someone with your address) asked to re-subscribe.", + section: { + heading: "Confirm Your Re-Subscription", + bodyHtml: html`

We got a new subscription request for this address, but it had unsubscribed before. If that was you, confirm below and you're back on the list. If it wasn't, ignore this email — you'll stay unsubscribed.

`, + ctaUrl: confirmUrl, + ctaLabel: "Yes, Re-Subscribe Me", + }, + }), + text: renderText([ + "We got a new subscription request for this address, but it had unsubscribed before.", + `Confirm here to re-subscribe: ${confirmUrl}`, + "If it wasn't you, ignore this email — you'll stay unsubscribed.", + ]), + }; +} + export interface NewsletterBlastInput { subject: string; heading: string; diff --git a/lib/newsletter/unsubscribe-token.ts b/lib/newsletter/unsubscribe-token.ts index 3bb3aec..91e7478 100644 --- a/lib/newsletter/unsubscribe-token.ts +++ b/lib/newsletter/unsubscribe-token.ts @@ -3,37 +3,87 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { getEnv } from "@/lib/env"; /** - * Stateless one click unsubscribe tokens for the public newsletter list, in - * the same spirit as lib/notifications/unsubscribe.ts: no extra table, the - * token is just {subscriberId} signed with the app's session secret. Kept - * separate from that module because a newsletter subscriber has no User - * account and no NotificationCategory — it is a flat opt in/out. + * Stateless tokens for the public newsletter list, in the same spirit as + * lib/notifications/unsubscribe.ts: no extra table, the token is + * {subscriberId}.{issuedAtMs} signed with the app's session secret, bound to + * a purpose so an unsubscribe link can never confirm a resubscription (and + * vice versa). Kept separate from that module because a newsletter + * subscriber has no User account and no NotificationCategory — it is a flat + * opt in/out. + * + * TTL: new tokens expire after 90 days (the copy says "invalid or expired" — + * now true). Legacy tokens (issued before the TTL, without an issuedAt + * segment) still verify for the unsubscribe purpose only: unsubscribing must + * keep working for every email already sent, and they can't be forged + * without the secret. */ + +export const NEWSLETTER_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; + +type TokenPurpose = "unsubscribe" | "resubscribe"; + function sign(payload: string): string { return createHmac("sha256", getEnv().NEXTAUTH_SECRET).update(payload).digest("base64url"); } -export function createNewsletterUnsubscribeToken(subscriberId: string): string { - return Buffer.from(`${subscriberId}.${sign(subscriberId)}`).toString("base64url"); +function createToken(subscriberId: string, purpose: TokenPurpose, now: Date = new Date()): string { + const payload = `${purpose}:${subscriberId}.${now.getTime()}`; + return Buffer.from(`${subscriberId}.${now.getTime()}.${sign(payload)}`).toString("base64url"); } -export function verifyNewsletterUnsubscribeToken(token: string): string | null { +function verifyToken( + token: string, + purpose: TokenPurpose, + now: Date = new Date() +): string | null { try { const decoded = Buffer.from(token, "base64url").toString("utf8"); const separatorIndex = decoded.lastIndexOf("."); if (separatorIndex === -1) return null; - const subscriberId = decoded.slice(0, separatorIndex); + const body = decoded.slice(0, separatorIndex); const signature = decoded.slice(separatorIndex + 1); - if (!subscriberId || !signature) return null; + if (!body || !signature) return null; - const expected = sign(subscriberId); + // Legacy format: "{subscriberId}" with no issuedAt — unsubscribe only, + // no TTL enforceable (see module note). + const legacy = purpose === "unsubscribe" && !body.includes("."); + if (!legacy) { + const [subscriberId, issuedAtRaw] = body.split("."); + const issuedAt = Number(issuedAtRaw); + if (!subscriberId || !Number.isFinite(issuedAt)) return null; + if (now.getTime() - issuedAt > NEWSLETTER_TOKEN_TTL_MS) return null; + if (issuedAt > now.getTime() + 60_000) return null; // clock-skew guard + } + + const expected = sign(legacy ? body : `${purpose}:${body}`); const provided = Buffer.from(signature); const wanted = Buffer.from(expected); if (provided.length !== wanted.length || !timingSafeEqual(provided, wanted)) return null; - return subscriberId; + return legacy ? body : body.split(".")[0]; } catch { return null; } } + +export function createNewsletterUnsubscribeToken(subscriberId: string): string { + return createToken(subscriberId, "unsubscribe"); +} + +export function verifyNewsletterUnsubscribeToken(token: string): string | null { + return verifyToken(token, "unsubscribe"); +} + +/** + * Resubscribe confirmations: re-subscribing a previously unsubscribed + * address must not clear unsubscribedAt directly — the person proves inbox + * ownership by clicking this link first. + */ +export function createNewsletterResubscribeToken(subscriberId: string): string { + return createToken(subscriberId, "resubscribe"); +} + +export function verifyNewsletterResubscribeToken(token: string): string | null { + return verifyToken(token, "resubscribe"); +} diff --git a/lib/notifications/send.ts b/lib/notifications/send.ts index 2ba375e..dbe8bae 100644 --- a/lib/notifications/send.ts +++ b/lib/notifications/send.ts @@ -11,6 +11,13 @@ export interface SendNotificationInput { template: EmailTemplate; /** Omit for security or money critical mail, which always sends. */ category?: NotificationCategory; + /** + * Caller has ALREADY verified this user's category preference in the + * current batch (the announce fan-out batch-loads preferences to avoid an + * N+1). Skips the per-user preference query. Never set unless the caller + * really checked — a wrong true emails an opted-out user. + */ + preferencePrechecked?: boolean; } const MUTED = "#6b6b6b"; @@ -23,7 +30,7 @@ const INK = "#000092"; * header (RFC 8058) so Gmail and Yahoo show their own unsubscribe control too. */ export async function sendNotification(input: SendNotificationInput): Promise<{ delivered: boolean }> { - if (input.category) { + if (input.category && !input.preferencePrechecked) { const preference = await prisma.notificationPreference.findUnique({ where: { userId: input.userId }, }); diff --git a/lib/notifications/templates/admin.ts b/lib/notifications/templates/admin.ts index 581284a..0648923 100644 --- a/lib/notifications/templates/admin.ts +++ b/lib/notifications/templates/admin.ts @@ -1,5 +1,5 @@ import { renderEmail, renderText, type EmailTemplate } from "@/lib/notifications/layout"; -import { html } from "@/lib/notifications/html"; +import { html, trustedHtml } from "@/lib/notifications/html"; export function ledgerReconciliationDigestEmail( findingCount: number, @@ -7,10 +7,15 @@ export function ledgerReconciliationDigestEmail( findings: { kind: string; detail: string }[], url: string ): EmailTemplate { - const rows = findings - .slice(0, 10) - .map((f) => `
  • ${f.kind}: ${f.detail}
  • `) - .join(""); + // Each row is built with the html`` tag (kind/detail escaped), then joined + // and re-wrapped — interpolating a plain joined string would escape the + // tags and render "
  • " literally in the email. + const rows = trustedHtml( + findings + .slice(0, 10) + .map((f) => html`
  • ${f.kind}: ${f.detail}
  • `.value) + .join("") + ); return { subject: `Ledger Reconciliation Found ${findingCount} Issue${findingCount === 1 ? "" : "s"}`, html: renderEmail({ @@ -62,6 +67,24 @@ export function disputeOpenedOrganizerEmail(eventTitle: string): EmailTemplate { }; } +export function disputeRefundedEmail(eventTitle: string, note: string): EmailTemplate { + return { + subject: `Your Dispute On ${eventTitle} Was Resolved With A Refund`, + html: renderEmail({ + preheader: "The prize pool goes back to the organizer.", + section: { + heading: "Dispute Resolved: Refund", + bodyHtml: html`

    Your dispute on ${eventTitle} was upheld. The remaining prize pool is refunded to the organizer — no milestone payout will be made.

    +

    Note from our team: ${note}

    `, + }, + }), + text: renderText([ + `Your dispute on ${eventTitle} was upheld and the remaining pool was refunded to the organizer.`, + `Note from our team: ${note}`, + ]), + }; +} + export function disputeResolvedEmail(eventTitle: string, released: boolean, note: string): EmailTemplate { return { subject: `Your Dispute On ${eventTitle} Was Resolved`, diff --git a/lib/notifications/templates/events.ts b/lib/notifications/templates/events.ts index 36f594f..752c2e9 100644 --- a/lib/notifications/templates/events.ts +++ b/lib/notifications/templates/events.ts @@ -1,6 +1,30 @@ import { renderEmail, renderText, type EmailTemplate } from "@/lib/notifications/layout"; import { html } from "@/lib/notifications/html"; +export function eventCancelledEmail( + eventTitle: string, + reason: string, + organizerUrl: string +): EmailTemplate { + return { + subject: `${eventTitle} Was Cancelled`, + html: renderEmail({ + preheader: "The hackathon was cancelled by the platform.", + section: { + heading: "Hackathon Cancelled", + bodyHtml: html`

    ${eventTitle} was cancelled by the HackVillage team. Any locked prize funds are returned through the refund process.

    +

    Reason: ${reason}

    `, + ctaUrl: organizerUrl, + ctaLabel: "Open Your Organizer Page", + }, + }), + text: renderText([ + `${eventTitle} was cancelled by the HackVillage team. Any locked prize funds are refunded.`, + `Reason: ${reason}`, + ]), + }; +} + export function eventPublishedEmail(eventTitle: string, eventUrl: string): EmailTemplate { return { subject: `${eventTitle} Is Published`, diff --git a/lib/onboarding/actions.ts b/lib/onboarding/actions.ts index 2ca6c78..735a36e 100644 --- a/lib/onboarding/actions.ts +++ b/lib/onboarding/actions.ts @@ -4,22 +4,31 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -import { currentUser, requireUser } from "@/lib/auth/guards"; +import { requireUser } from "@/lib/auth/guards"; import { validateHandle } from "@/lib/auth/handles"; +import { acceptOrgInvitation } from "@/lib/organizations/accept-invitation"; import { generateInviteCode, inviteExpiryFrom } from "@/lib/organizations/invitations"; +import { createWithOrgSlugRetry, freeOrgSlugCandidates } from "@/lib/organizations/slug"; import { prisma } from "@/lib/db"; +import { rateLimit } from "@/lib/rate-limit"; import { sendMail } from "@/lib/ports/mail"; import { orgInviteEmail, orgMemberJoinedEmail } from "@/lib/notifications/templates/organizations"; import { appUrl } from "@/lib/url"; import { parseOrgDetails } from "@/lib/orgs/details"; +/** + * Post-commit notice to the org owner. The membership is already saved, so a + * mail failure must never bubble up and fail the action — log only. + */ async function notifyOwnerOfNewMember(orgId: string, memberName: string): Promise { const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { name: true, owner: { select: { email: true } } }, }); if (!org) return; - await sendMail({ to: org.owner.email, ...orgMemberJoinedEmail(memberName, org.name) }); + await sendMail({ to: org.owner.email, ...orgMemberJoinedEmail(memberName, org.name) }).catch( + (error: unknown) => console.error(`[onboarding] owner-join notice failed (org=${orgId})`, error) + ); } /** @@ -34,19 +43,23 @@ export interface OnboardingActionState { // ── Role selection ─────────────────────────────────────────────────────── +// Server actions receive untrusted input — validate even "typed" params at runtime. +const roleChoiceSchema = z.enum(["DEVELOPER", "ORGANIZER"]); + export async function chooseRoleAction(role: "DEVELOPER" | "ORGANIZER"): Promise { + const parsedRole = roleChoiceSchema.parse(role); const user = await requireUser(); await prisma.$transaction(async (tx) => { - await tx.user.update({ where: { id: user.id }, data: { primaryRole: role } }); + await tx.user.update({ where: { id: user.id }, data: { primaryRole: parsedRole } }); await tx.roleGrant.upsert({ - where: { userId_role: { userId: user.id, role } }, - create: { userId: user.id, role }, + where: { userId_role: { userId: user.id, role: parsedRole } }, + create: { userId: user.id, role: parsedRole }, update: {}, }); }); - redirect(role === "DEVELOPER" ? "/onboarding/developer" : "/onboarding/organizer"); + redirect(parsedRole === "DEVELOPER" ? "/onboarding/developer" : "/onboarding/organizer"); } // ── Developer onboarding ──────────────────────────────────────────────── @@ -145,32 +158,6 @@ function orgDetailsInput(formData: FormData) { }; } -const RESERVED_ORG_SLUGS = new Set([ - "admin", "api", "auth", "dashboard", "developers", "events", "hackathons", "hiring", - "judge", "organizer", "orgs", "settings", "signin", "signup", "support", "trust", -]); - -function slugifyStem(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 30); -} - -async function uniqueOrgSlug(name: string): Promise { - const stem = slugifyStem(name); - if (stem.length < 3 || RESERVED_ORG_SLUGS.has(stem)) return null; - const candidates = [stem, ...Array.from({ length: 20 }, (_, i) => `${stem}-${i + 2}`)]; - const taken = await prisma.organization.findMany({ - where: { slug: { in: candidates, mode: "insensitive" } }, - select: { slug: true }, - }); - const takenSet = new Set(taken.map((t) => t.slug.toLowerCase())); - return candidates.find((c) => !takenSet.has(c)) ?? null; -} - export async function createOrganizationAction( _prev: OnboardingActionState, formData: FormData @@ -194,34 +181,43 @@ export async function createOrganizationAction( return { error: "You already belong to an organization. Leave it before creating another." }; } - const slug = await uniqueOrgSlug(parsed.data.name); - if (!slug) { + const candidates = await freeOrgSlugCandidates(parsed.data.name); + if (candidates.length === 0) { return { error: "That name doesn't produce a usable address. Try different words." }; } - await prisma.$transaction(async (tx) => { - const org = await tx.organization.create({ - data: { - name: parsed.data.name, - slug, - about: parsed.data.about || null, - ...details.data, - ownerId: user.id, - }, - }); - await tx.orgMember.create({ - data: { orgId: org.id, userId: user.id, role: "OWNER", status: "ACTIVE" }, - }); - await tx.roleGrant.upsert({ - where: { userId_role: { userId: user.id, role: "ORGANIZER" } }, - create: { userId: user.id, role: "ORGANIZER" }, - update: {}, - }); - await tx.user.update({ - where: { id: user.id }, - data: { primaryRole: "ORGANIZER", onboardingCompletedAt: new Date() }, - }); - }); + try { + // A concurrent signup can claim the pre-checked slug — retry the next + // candidates on the unique violation instead of erroring out. + await createWithOrgSlugRetry(candidates, (slug) => + prisma.$transaction(async (tx) => { + const org = await tx.organization.create({ + data: { + name: parsed.data.name, + slug, + about: parsed.data.about || null, + ...details.data, + ownerId: user.id, + }, + }); + await tx.orgMember.create({ + data: { orgId: org.id, userId: user.id, role: "OWNER", status: "ACTIVE" }, + }); + await tx.roleGrant.upsert({ + where: { userId_role: { userId: user.id, role: "ORGANIZER" } }, + create: { userId: user.id, role: "ORGANIZER" }, + update: {}, + }); + await tx.user.update({ + where: { id: user.id }, + data: { primaryRole: "ORGANIZER", onboardingCompletedAt: new Date() }, + }); + }) + ); + } catch (error) { + console.error("[onboarding] organization create failed after slug retries", error); + return { error: "That name is taken too often right now. Adjust it slightly." }; + } redirect("/organizer/verification?welcome=1"); } @@ -239,41 +235,13 @@ export async function joinOrganizationAction( .safeParse(String(formData.get("code") ?? "")); if (!code.success) return { error: "Invite codes are the short codes organizers share." }; - const invitation = await prisma.orgInvitation.findUnique({ where: { token: code.data } }); - if (!invitation || invitation.acceptedAt || invitation.expiresAt.getTime() <= Date.now()) { - return { error: "That invite isn't valid anymore. Ask the organizer for a fresh one." }; - } - - await prisma.$transaction(async (tx) => { - const existing = await tx.orgMember.findFirst({ - where: { orgId: invitation.orgId, userId: user.id }, - }); - if (!existing) { - await tx.orgMember.create({ - data: { orgId: invitation.orgId, userId: user.id, role: invitation.role, status: "ACTIVE" }, - }); - } else if (existing.status === "REVOKED") { - await tx.orgMember.update({ - where: { id: existing.id }, - data: { status: "ACTIVE", role: invitation.role }, - }); - } - await tx.orgInvitation.update({ - where: { id: invitation.id }, - data: { acceptedAt: new Date() }, - }); - await tx.roleGrant.upsert({ - where: { userId_role: { userId: user.id, role: "ORGANIZER" } }, - create: { userId: user.id, role: "ORGANIZER" }, - update: {}, - }); - await tx.user.update({ - where: { id: user.id }, - data: { primaryRole: "ORGANIZER", onboardingCompletedAt: new Date() }, - }); + const accepted = await acceptOrgInvitation({ + token: code.data, + user: { id: user.id, email: user.email }, }); + if (!accepted.ok) return { error: accepted.error }; - await notifyOwnerOfNewMember(invitation.orgId, user.name ?? user.email); + await notifyOwnerOfNewMember(accepted.orgId, user.name ?? user.email); redirect("/organizer"); } @@ -283,17 +251,37 @@ export async function joinOrganizationAction( * invitee email is given, the invite is emailed to them directly; left * blank, the code is still created for the organizer to share by hand. */ -export async function createOrgInviteAction(inviteeEmail?: string): Promise { - const user = await currentUser(); - if (!user?.roles.includes("ORGANIZER")) return; +export async function createOrgInviteAction( + inviteeEmail?: string +): Promise { + const user = await requireUser(); + if (!user.roles.includes("ORGANIZER")) { + return { error: "Only organizers can invite teammates." }; + } const membership = await prisma.orgMember.findFirst({ where: { userId: user.id, status: "ACTIVE" }, select: { orgId: true, role: true }, }); - if (!membership || membership.role === "MEMBER") return; + if (!membership || membership.role === "MEMBER") { + return { error: "Only organization owners and admins can create invites." }; + } const trimmedEmail = inviteeEmail?.trim(); + let targetEmail: string | undefined; + if (trimmedEmail) { + const parsed = z.string().email().safeParse(trimmedEmail); + if (!parsed.success) return { error: "That email doesn't look right." }; + targetEmail = parsed.data.toLowerCase(); + } + + // Per-organizer cap: invite codes grant real access, so bulk creation is + // an abuse vector worth limiting even for legit organizers. + const limit = await rateLimit(`org-invite:${membership.orgId}`, 10, 60 * 60 * 1000); + if (!limit.ok) { + return { error: "Too many invites created this hour. Try again a little later." }; + } + const token = generateInviteCode(); const [org] = await prisma.$transaction([ @@ -301,7 +289,10 @@ export async function createOrgInviteAction(inviteeEmail?: string): Promise console.error("[onboarding] invite email failed", error)); } revalidatePath("/organizer"); + return {}; } /** Logged-in users accepting an invite link directly (/invites/[token]). */ export async function acceptInvitationTokenAction(token: string): Promise { const user = await requireUser(); - const invitation = await prisma.orgInvitation.findUnique({ where: { token } }); - if ( - !invitation || - invitation.acceptedAt || - invitation.expiresAt.getTime() <= Date.now() - ) { - redirect("/invites/invalid"); - } - await prisma.$transaction(async (tx) => { - const existing = await tx.orgMember.findFirst({ - where: { orgId: invitation.orgId, userId: user.id }, - }); - if (!existing) { - await tx.orgMember.create({ - data: { orgId: invitation.orgId, userId: user.id, role: invitation.role, status: "ACTIVE" }, - }); - } else if (existing.status === "REVOKED") { - await tx.orgMember.update({ - where: { id: existing.id }, - data: { status: "ACTIVE", role: invitation.role }, - }); - } - await tx.orgInvitation.update({ where: { id: invitation.id }, data: { acceptedAt: new Date() } }); - await tx.roleGrant.upsert({ - where: { userId_role: { userId: user.id, role: "ORGANIZER" } }, - create: { userId: user.id, role: "ORGANIZER" }, - update: {}, - }); + const accepted = await acceptOrgInvitation({ + token, + user: { id: user.id, email: user.email }, }); + if (!accepted.ok) redirect("/invites/invalid"); - await notifyOwnerOfNewMember(invitation.orgId, user.name ?? user.email); + await notifyOwnerOfNewMember(accepted.orgId, user.name ?? user.email); redirect("/organizer"); } @@ -382,10 +351,15 @@ export async function updateDeveloperProfileAction( const handleError = validateHandle(handle); if (handleError) return { error: handleError }; - const taken = await prisma.user.findFirst({ - where: { handle: { equals: handle, mode: "insensitive" }, id: { not: user.id } }, - select: { id: true }, - }); + const [taken, before] = await Promise.all([ + prisma.user.findFirst({ + where: { handle: { equals: handle, mode: "insensitive" }, id: { not: user.id } }, + select: { id: true }, + }), + // The session's handle can be stale — read the current one so a rename + // revalidates BOTH the old and the new public profile paths. + prisma.user.findUnique({ where: { id: user.id }, select: { handle: true } }), + ]); if (taken) return { error: "That handle is taken. Try another." }; const skillList = (skills ?? "") @@ -421,6 +395,10 @@ export async function updateDeveloperProfileAction( ]); revalidatePath("/dashboard/profile"); + if (before?.handle && before.handle.toLowerCase() !== handle.toLowerCase()) { + // The old public URL must drop the cached profile right away. + revalidatePath(`/developers/${before.handle}`); + } revalidatePath(`/developers/${handle}`); return { message: "Profile saved." }; } diff --git a/lib/organizations/accept-invitation.ts b/lib/organizations/accept-invitation.ts new file mode 100644 index 0000000..87ffead --- /dev/null +++ b/lib/organizations/accept-invitation.ts @@ -0,0 +1,84 @@ +import { prisma } from "@/lib/db"; + +/** + * Accepting an organization invitation — the single path shared by + * join-by-code (onboarding) and /invites/[token] (existing users). + * + * Claim-first: the invitation is flipped to accepted inside the transaction + * by a conditional updateMany, so two concurrent accepts can't both win — + * exactly one membership results, the loser gets an error. + * + * Email binding: invites emailed to a specific invitee only work for that + * address (forwarded links/codes fail). Hand-shared codes are created with + * the *creator's* own email (see createOrgInviteAction); the creator is + * already an active member, so a stored email that belongs to an active + * member of the org means "share by hand" and carries no email binding. + */ +export type AcceptInvitationResult = + | { ok: true; orgId: string } + | { ok: false; error: string }; + +const INVALID_INVITE = "That invite isn't valid anymore. Ask the organizer for a fresh one."; + +export async function acceptOrgInvitation(input: { + token: string; + user: { id: string; email: string }; +}): Promise { + const invitation = await prisma.orgInvitation.findUnique({ + where: { token: input.token }, + }); + if (!invitation || invitation.acceptedAt || invitation.expiresAt.getTime() <= Date.now()) { + return { ok: false, error: INVALID_INVITE }; + } + + const storedEmailIsMembers = await prisma.orgMember.findFirst({ + where: { + orgId: invitation.orgId, + status: "ACTIVE", + user: { email: { equals: invitation.email, mode: "insensitive" } }, + }, + select: { id: true }, + }); + const targeted = !storedEmailIsMembers; + if (targeted && invitation.email.toLowerCase() !== input.user.email.toLowerCase()) { + return { ok: false, error: "That invite was sent to a different email address." }; + } + + return prisma.$transaction(async (tx) => { + // Claim first — only one concurrent accept can flip acceptedAt. + const claim = await tx.orgInvitation.updateMany({ + where: { id: invitation.id, acceptedAt: null, expiresAt: { gt: new Date() } }, + data: { acceptedAt: new Date() }, + }); + if (claim.count === 0) return { ok: false as const, error: INVALID_INVITE }; + + const existing = await tx.orgMember.findFirst({ + where: { orgId: invitation.orgId, userId: input.user.id }, + }); + if (!existing) { + await tx.orgMember.create({ + data: { + orgId: invitation.orgId, + userId: input.user.id, + role: invitation.role, + status: "ACTIVE", + }, + }); + } else if (existing.status === "REVOKED") { + await tx.orgMember.update({ + where: { id: existing.id }, + data: { status: "ACTIVE", role: invitation.role }, + }); + } + await tx.roleGrant.upsert({ + where: { userId_role: { userId: input.user.id, role: "ORGANIZER" } }, + create: { userId: input.user.id, role: "ORGANIZER" }, + update: {}, + }); + await tx.user.update({ + where: { id: input.user.id }, + data: { primaryRole: "ORGANIZER", onboardingCompletedAt: new Date() }, + }); + return { ok: true as const, orgId: invitation.orgId }; + }); +} diff --git a/lib/organizations/invitations.ts b/lib/organizations/invitations.ts index 7d4e922..a9cc691 100644 --- a/lib/organizations/invitations.ts +++ b/lib/organizations/invitations.ts @@ -7,11 +7,6 @@ import { randomBytes } from "node:crypto"; export const INVITE_TTL_DAYS = 7; -export interface InvitationLike { - expiresAt: Date; - acceptedAt: Date | null; -} - /** 10-char, unambiguous, human-shareable code (no 0/O/1/I/l). */ export function generateInviteCode(): string { const alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; @@ -26,18 +21,3 @@ export function generateInviteCode(): string { export function inviteExpiryFrom(now: Date = new Date()): Date { return new Date(now.getTime() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000); } - -export function isInvitationUsable(invitation: InvitationLike, now: Date = new Date()): boolean { - return invitation.acceptedAt == null && invitation.expiresAt.getTime() > now.getTime(); -} - -export interface InviteStatus { - usable: boolean; - reason?: "already-used" | "expired"; -} - -export function invitationStatus(invitation: InvitationLike, now: Date = new Date()): InviteStatus { - if (invitation.acceptedAt != null) return { usable: false, reason: "already-used" }; - if (invitation.expiresAt.getTime() <= now.getTime()) return { usable: false, reason: "expired" }; - return { usable: true }; -} diff --git a/lib/organizations/slug.ts b/lib/organizations/slug.ts new file mode 100644 index 0000000..cdb5212 --- /dev/null +++ b/lib/organizations/slug.ts @@ -0,0 +1,71 @@ +import { Prisma } from "@prisma/client"; + +import { prisma } from "@/lib/db"; + +/** + * Organization slugs: candidate generation plus collision-retry creation. + * The pre-checked candidate list can still lose a race against a concurrent + * create, so creation retries the next candidates on a P2002 slug violation + * instead of surfacing a raw 500. + */ + +const RESERVED_ORG_SLUGS = new Set([ + "admin", "api", "auth", "dashboard", "developers", "events", "hackathons", "hiring", + "judge", "organizer", "orgs", "settings", "signin", "signup", "support", "trust", +]); + +export function orgSlugStem(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 30); +} + +export function orgSlugCandidates(name: string): string[] { + const stem = orgSlugStem(name); + if (stem.length < 3 || RESERVED_ORG_SLUGS.has(stem)) return []; + return [stem, ...Array.from({ length: 20 }, (_, i) => `${stem}-${i + 2}`)]; +} + +/** Candidates not taken at read time — creation still re-verifies via the retry below. */ +export async function freeOrgSlugCandidates(name: string): Promise { + const candidates = orgSlugCandidates(name); + if (candidates.length === 0) return []; + const taken = await prisma.organization.findMany({ + where: { slug: { in: candidates, mode: "insensitive" } }, + select: { slug: true }, + }); + const takenSet = new Set(taken.map((t) => t.slug.toLowerCase())); + return candidates.filter((candidate) => !takenSet.has(candidate)); +} + +export function isOrgSlugUniqueViolation(error: unknown): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== "P2002") { + return false; + } + const target = (error.meta as { target?: string[] } | undefined)?.target ?? []; + return target.includes("slug"); +} + +/** + * Try each candidate slug in order; a unique violation on slug moves to the + * next candidate (bounded), any other error aborts immediately. + */ +export async function createWithOrgSlugRetry( + candidates: string[], + create: (slug: string) => Promise, + maxAttempts = 5, +): Promise { + let lastError: unknown; + for (const slug of candidates.slice(0, Math.max(1, maxAttempts))) { + try { + return await create(slug); + } catch (error) { + if (!isOrgSlugUniqueViolation(error)) throw error; + lastError = error; + } + } + throw lastError ?? new Error("No organization slug candidates available."); +} diff --git a/lib/orgs/verification-service.ts b/lib/orgs/verification-service.ts index 4c9acae..8faf108 100644 --- a/lib/orgs/verification-service.ts +++ b/lib/orgs/verification-service.ts @@ -6,6 +6,62 @@ import { kybSubmittedEmail } from "@/lib/notifications/templates/organizations"; export class KybSubmissionError extends Error {} +export class KybDecisionError extends Error {} + +/** + * Admin KYB decision (the other half of submitKyb). Decisions are only valid + * while the organization is actually awaiting review — deciding a VERIFIED or + * FAILED org again would clobber the earlier audited outcome and re-mail the + * organizer, so anything but PENDING is refused. Returns what the caller + * needs for the outcome email. + */ +export async function decideKyb(input: { + adminId: string; + orgId: string; + decision: "APPROVE" | "REJECT"; + reason: string; +}): Promise<{ orgName: string; ownerEmail: string }> { + const admin = await prisma.roleGrant.findFirst({ + where: { userId: input.adminId, role: "ADMIN" }, + select: { id: true }, + }); + if (!admin) throw new KybDecisionError("Admins only."); + + const org = await prisma.organization.findUnique({ + where: { id: input.orgId }, + select: { name: true, kycStatus: true, owner: { select: { email: true } } }, + }); + if (!org) throw new KybDecisionError("Organization not found."); + if (org.kycStatus !== "PENDING") { + throw new KybDecisionError( + "That organization isn't awaiting review — refresh and take the next pending one." + ); + } + + await prisma.$transaction([ + prisma.organization.update({ + where: { id: input.orgId }, + data: { kycStatus: input.decision === "APPROVE" ? "VERIFIED" : "FAILED" }, + }), + // Organizations that asked before structured submissions have no row; updateMany skips them. + prisma.kybSubmission.updateMany({ + where: { orgId: input.orgId }, + data: { reviewedAt: new Date(), reviewNote: input.reason || null }, + }), + prisma.auditLog.create({ + data: { + actorId: input.adminId, + action: input.decision === "APPROVE" ? "kyc.approved" : "kyc.rejected", + entity: "Organization", + entityId: input.orgId, + reason: input.reason || null, + }, + }), + ]); + + return { orgName: org.name, ownerEmail: org.owner.email }; +} + /** * An organization's owner or admin submits its business details for review. * Moves the organization to PENDING; a later decision sets VERIFIED or FAILED. diff --git a/lib/ports/chain.ts b/lib/ports/chain.ts index 0844f2c..652c980 100644 --- a/lib/ports/chain.ts +++ b/lib/ports/chain.ts @@ -9,8 +9,9 @@ import { getEnv } from "@/lib/env"; * the public LEDGER, not the custodian — fiat stays with Paystack. Real mode * signs attestation calls with the platform key (env-only, per SECURITY.md). * - * Simulation mode (no RPC_URL / key / factory address): returns deterministic - * synthetic attestations so the entire escrow flow is testable end-to-end + * Simulation mode (no RPC_URL / key / factory address): a deterministic, + * in-memory mirror of the PrizeVault state machine (same transition rules, + * same revert reasons) so the entire escrow flow is testable end-to-end * without a chain. /trust labels entries clearly in this mode. */ @@ -26,11 +27,53 @@ export type AttestationAction = | { kind: "milestonePayout"; winner: string; amountKes: number; txRef: string } | { kind: "refund"; refundRef: string }; +/** On-chain vault truth, read back via the vault's public views. */ +export interface ChainVaultState { + state: string; + releasedKes: number; +} + +export const CHAIN_STATES = [ + "AWAITING", + "LOCKED", + "HALF_RELEASED", + "SETTLED", + "REFUNDED", +] as const; +export type ChainStateName = (typeof CHAIN_STATES)[number]; + +/** States an attestation write may legitimately target. */ +export type IntendedChainState = "LOCKED" | "HALF_RELEASED" | "SETTLED" | "REFUNDED"; + +/** + * True when the on-chain state already reflects (or has moved past) the state + * an attestation intended to write — the already-applied signal that lets + * attestation handlers skip the write and just mirror the ledger row (P3). + */ +export function chainStateReflects( + onChain: ChainVaultState | null, + intended: IntendedChainState +): boolean { + if (!onChain) return false; + if (intended === "REFUNDED") return onChain.state === "REFUNDED"; + if (onChain.state === "REFUNDED") { + // A refunded vault was necessarily LOCKED first — a refund reflects every + // lock attestation, but it can never stand in for a payout attestation. + return intended === "LOCKED"; + } + const order = ["AWAITING", "LOCKED", "HALF_RELEASED", "SETTLED"]; + const have = order.indexOf(onChain.state); + const want = order.indexOf(intended); + return have >= 0 && want >= 0 && have >= want; +} + export interface ChainPort { mode: "amoy" | "simulation"; createVault(eventId: string, amountKes: number): Promise; attest(eventId: string, action: AttestationAction): Promise; vaultAddressFor(eventId: string): Promise; + /** Read the vault's public state back. null = no vault on-chain. */ + readVaultState(eventId: string): Promise; } // ── Minimal ABIs (hand-pinned; the full artifacts live in contracts/) ──── @@ -46,6 +89,8 @@ const VAULT_ABI = [ "function recordInstantPayout(bytes32 winner, uint256 amount, bytes32 txRef)", "function recordMilestonePayout(bytes32 winner, uint256 amount, bytes32 txRef)", "function refund(bytes32 refundRef)", + "function state() view returns (uint8)", + "function releasedKes() view returns (uint256)", ]; function refHash(value: string): string { @@ -71,6 +116,21 @@ class AmoyChain implements ChainPort { this.factory = new Contract(factoryAddress, FACTORY_ABI, this.signer); } + /** + * Resolve the vault contract for an event, falling back to the on-chain + * factory registry when the in-memory map misses (cold start — the cache + * dies with the process, the factory does not). + */ + private async vaultContractFor(eventId: string): Promise { + const cached = this.vaults.get(eventId); + if (cached) return cached; + const address: string = await this.factory.vaultFor(eventId); + if (address === "0x0000000000000000000000000000000000000000") return null; + const contract = new Contract(address, VAULT_ABI, this.signer); + this.vaults.set(eventId, contract); + return contract; + } + async createVault( eventId: string, amountKes: number @@ -90,15 +150,23 @@ class AmoyChain implements ChainPort { } async vaultAddressFor(eventId: string): Promise { - if (this.vaults.has(eventId)) return this.vaults.get(eventId)!.target as string; - const address: string = await this.factory.vaultFor(eventId); - if (address === "0x0000000000000000000000000000000000000000") return null; - this.vaults.set(eventId, new Contract(address, VAULT_ABI, this.signer)); - return address; + const contract = await this.vaultContractFor(eventId); + return contract ? (contract.target as string) : null; + } + + async readVaultState(eventId: string): Promise { + const vault = await this.vaultContractFor(eventId); + if (!vault) return null; + const [stateIndex, released] = await Promise.all([ + vault.state() as Promise, + vault.releasedKes() as Promise, + ]); + const state = CHAIN_STATES[Number(stateIndex)] ?? "AWAITING"; + return { state, releasedKes: Number(released) }; } async attest(eventId: string, action: AttestationAction): Promise { - const vault = this.vaults.get(eventId) ?? null; + const vault = await this.vaultContractFor(eventId); if (!vault) throw new Error(`AmoyChain: no vault loaded for ${eventId}`); let tx: Promise; @@ -133,10 +201,18 @@ class AmoyChain implements ChainPort { } } +interface SimVault { + address: string; + /// 0 means "unknown" — the vault was auto-vivified without a create call. + amountKes: number; + state: ChainStateName; + releasedKes: number; +} + class SimulatedChain implements ChainPort { mode = "simulation" as const; private block = 4_000_000; - private vaults = new Map(); + private vaults = new Map(); private next(): Attestation { this.block += 1; @@ -148,11 +224,22 @@ class SimulatedChain implements ChainPort { amountKes: number ): Promise { const attestation = this.next(); + const existing = this.vaults.get(eventId); + if (existing) { + // Idempotent create: the factory would revert, the simulation returns + // the existing vault so crash-replay paths stay drivable. + return { ...attestation, contractAddress: existing.address }; + } const contractAddress = `0x${createHash("sha256") .update(`vault:${eventId}`) .digest("hex") .slice(0, 40)}`; - this.vaults.set(eventId, contractAddress); + this.vaults.set(eventId, { + address: contractAddress, + amountKes, + state: "AWAITING", + releasedKes: 0, + }); console.log( `[chain:simulation] createVault ${eventId} (${amountKes} KES) -> ${contractAddress}` ); @@ -160,13 +247,67 @@ class SimulatedChain implements ChainPort { } async attest(eventId: string, action: AttestationAction): Promise { + const vault = this.vaultForAction(eventId, action); + switch (action.kind) { + case "lock": + if (vault.state !== "AWAITING") throw new Error("Vault: not awaiting deposit"); + vault.state = "LOCKED"; + break; + case "instantPayout": + if (vault.state !== "LOCKED") throw new Error("Vault: not locked"); + vault.state = "HALF_RELEASED"; + vault.releasedKes += action.amountKes; + break; + case "milestonePayout": + if (vault.state !== "HALF_RELEASED") throw new Error("Vault: not half released"); + vault.releasedKes += action.amountKes; + if (vault.amountKes === 0 || vault.releasedKes >= vault.amountKes) { + vault.state = "SETTLED"; + } + break; + case "refund": + if (vault.state !== "LOCKED" && vault.state !== "HALF_RELEASED") { + throw new Error("Vault: not refundable"); + } + vault.state = "REFUNDED"; + break; + default: + throw new Error("SimulatedChain: unsupported action"); + } const attestation = this.next(); - console.log(`[chain:simulation] ${action.kind} on ${eventId}`); + console.log(`[chain:simulation] ${action.kind} on ${eventId} -> ${vault.state}`); return attestation; } + /** + * Resolve the vault, auto-vivifying an unknown one into the exact state the + * requested action needs. Pre-vault flows (tests that drive payouts without + * a chain create) keep the old always-succeeds simulation contract, while + * known vaults enforce the same transition rules as the real contract. + */ + private vaultForAction(eventId: string, action: AttestationAction): SimVault { + let vault = this.vaults.get(eventId); + if (vault) return vault; + const address = `0x${createHash("sha256").update(`vault:${eventId}`).digest("hex").slice(0, 40)}`; + const vivifiedState: ChainStateName = + action.kind === "lock" + ? "AWAITING" + : action.kind === "instantPayout" || action.kind === "refund" + ? "LOCKED" + : "HALF_RELEASED"; + vault = { address, amountKes: 0, state: vivifiedState, releasedKes: 0 }; + this.vaults.set(eventId, vault); + return vault; + } + async vaultAddressFor(eventId: string): Promise { - return this.vaults.get(eventId) ?? null; + return this.vaults.get(eventId)?.address ?? null; + } + + async readVaultState(eventId: string): Promise { + const vault = this.vaults.get(eventId); + if (!vault) return null; + return { state: vault.state, releasedKes: vault.releasedKes }; } } diff --git a/lib/ports/paystack.ts b/lib/ports/paystack.ts index 2505b50..79c8a74 100644 --- a/lib/ports/paystack.ts +++ b/lib/ports/paystack.ts @@ -65,6 +65,30 @@ export interface TransferResult { simulated: boolean; } +/** Provider-truth lookup for the stuck-PROCESSING recovery sweep (P4). */ +export interface TransferStatusResult { + status: "success" | "failed" | "pending" | "reversed" | "unknown"; +} + +/** Maps Paystack transfer statuses onto the platform's recovery vocabulary. */ +export function mapTransferStatus(raw: string | undefined | null): TransferStatusResult["status"] { + switch ((raw ?? "").toLowerCase()) { + case "success": + return "success"; + case "failed": + return "failed"; + case "reversed": + return "reversed"; + case "pending": + case "otp": + case "processing": + case "queued": + return "pending"; + default: + return "unknown"; + } +} + export interface PaystackPort { mode: "live" | "simulation"; initializeCheckout(input: CheckoutInput): Promise; @@ -73,6 +97,12 @@ export interface PaystackPort { verifyWebhookSignature(rawBody: string, signature: string | null): boolean; createTransferRecipient(input: RecipientInput): Promise; initiateTransfer(input: TransferInput): Promise; + /** + * Resolve a transfer's terminal truth from the provider. Accepts either the + * per-attempt reference or the transfer code. "unknown" means Paystack has + * no record (or it cannot be verified) — callers must stay fail-closed. + */ + transferStatus(referenceOrCode: string): Promise; } const PAYSTACK_BASE = "https://api.paystack.co"; @@ -201,9 +231,33 @@ export class PaystackLive implements PaystackPort { simulated: false, }; } + + async transferStatus(referenceOrCode: string): Promise { + // Verify-by-reference first (works with the per-attempt reference we + // persist), then fetch-by-code (works with the transfer_code). A 404 on + // both means Paystack has no record of the transfer at all. + const endpoints = [ + `${PAYSTACK_BASE}/transfer/verify/${encodeURIComponent(referenceOrCode)}`, + `${PAYSTACK_BASE}/transfer/${encodeURIComponent(referenceOrCode)}`, + ]; + for (const url of endpoints) { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${this.secretKey}` }, + }).catch(() => null); + if (!response || !response.ok) continue; + const payload = (await response.json().catch(() => null)) as { + status?: boolean; + data?: { status?: string } | null; + } | null; + if (!payload?.status || !payload.data) continue; + return { status: mapTransferStatus(payload.data.status) }; + } + return { status: "unknown" }; + } } -class PaystackSimulation implements PaystackPort { +/** Exported for the recovery-matrix unit test — not part of the service surface. */ +export class PaystackSimulation implements PaystackPort { mode = "simulation" as const; async initializeCheckout(input: CheckoutInput): Promise { @@ -248,24 +302,58 @@ class PaystackSimulation implements PaystackPort { if (input.reference.includes("-simreverse")) { return { transferCode: `TRF_SIM_${input.reference}`, status: "reversed", simulated: true }; } + if (input.reference.includes("-simpending")) { + return { transferCode: `TRF_SIM_${input.reference}`, status: "pending", simulated: true }; + } return { transferCode: `TRF_SIM_${input.reference}`, status: "success", simulated: true }; } + + async transferStatus(referenceOrCode: string): Promise { + // Derive from the same dev reference conventions as initiateTransfer — + // both the per-attempt reference and the TRF_SIM_ code form resolve. + const reference = referenceOrCode.replace(/^TRF_SIM_/, ""); + if (reference.includes("-simfail")) return { status: "failed" }; + if (reference.includes("-simreverse")) return { status: "reversed" }; + if (reference.includes("-simpending")) return { status: "pending" }; + if (reference.includes("-simunknown")) return { status: "unknown" }; + return { status: "success" }; + } } let cached: PaystackPort | null = null; export function getPaystackPort(): PaystackPort { if (cached) return cached; - const secret = getEnv().PAYSTACK_SECRET_KEY; - if (secret && process.env.NODE_ENV === "production") { - cached = new PaystackLive(secret); - } else if (secret) { + const env = getEnv(); + const secret = env.PAYSTACK_SECRET_KEY; + if (secret) { cached = new PaystackLive(secret); } else { - console.warn( - "[paystack] PAYSTACK_SECRET_KEY not set — running in SIMULATION mode. " + - "Deposits complete via the dev checkout. Never enable in production." - ); + // Fail closed: a keyless production boot must never silently simulate + // money movement. Pre-go-live deploys opt in explicitly via + // PAYSTACK_ALLOW_SIMULATION_IN_PROD=true (see .env.example). + if ( + process.env.NODE_ENV === "production" && + !env.PAYSTACK_ALLOW_SIMULATION_IN_PROD + ) { + throw new Error( + "[paystack] PAYSTACK_SECRET_KEY is required in production. " + + "To run the simulation port in production deliberately (pre-go-live), " + + "set PAYSTACK_ALLOW_SIMULATION_IN_PROD=true." + ); + } + if (process.env.NODE_ENV === "production") { + console.error( + "[paystack] WARNING: running the SIMULATION payment port in production " + + "(PAYSTACK_ALLOW_SIMULATION_IN_PROD=true). No real money moves. " + + "Unset it the moment PAYSTACK_SECRET_KEY is configured." + ); + } else { + console.warn( + "[paystack] PAYSTACK_SECRET_KEY not set — running in SIMULATION mode. " + + "Deposits complete via the dev checkout. Never enable in production." + ); + } cached = new PaystackSimulation(); } return cached; diff --git a/lib/ports/storage.ts b/lib/ports/storage.ts index b9b3a80..8437343 100644 --- a/lib/ports/storage.ts +++ b/lib/ports/storage.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { access, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { getEnv } from "@/lib/env"; @@ -29,8 +29,6 @@ export interface StoragePort { contentType: string; sizeBytes: number; }): Promise; - /** Dev mode: persist an uploaded file. No-op in R2 mode. */ - saveLocal(key: string, body: Buffer): Promise; publicUrlFor(key: string): string; /** * Upload target at an exact key (covers). Local mode points the browser at @@ -45,6 +43,11 @@ export interface StoragePort { saveLocalAt(key: string, body: Buffer): Promise; /** Public URL for a keyed upload. */ urlForKey(key: string): string; + /** + * Did the upload actually land? R2: HEAD the public URL. Local: the file + * exists under public/uploads. Confirms gate on this — no phantom rows. + */ + verifyUploaded(key: string): Promise; } const MAX_BYTES = 15 * 1024 * 1024; // 15MB per asset (photos/short clips) @@ -66,6 +69,16 @@ function keyFor(eventId: string, filename: string): string { return `events/${eventId}/${digest}-${safeName}`; } +/** Absolute path under public/uploads — the traversal guard every write goes through. */ +function localTargetFor(key: string): string { + const root = path.join(process.cwd(), "public", "uploads"); + const target = path.join(root, key); + if (!target.startsWith(root + path.sep)) { + throw new Error("Storage key escapes the uploads folder."); + } + return target; +} + class LocalStorage implements StoragePort { mode = "local" as const; @@ -80,20 +93,13 @@ class LocalStorage implements StoragePort { return { uploadUrl: `${base}/api/dev/media/upload/${encodeURIComponent(key)}`, headers: { "Content-Type": input.contentType }, - publicUrl: `/uploads/${key.replace("events/", "")}`, + publicUrl: this.publicUrlFor(key), key, }; } - async saveLocal(key: string, body: Buffer): Promise { - const target = path.join(process.cwd(), "public", key); - await mkdir(path.dirname(target), { recursive: true }); - await writeFile(target, body); - return `/uploads/${key.replace("events/", "")}`; - } - publicUrlFor(key: string): string { - return `/uploads/${key.replace("events/", "")}`; + return `/uploads/${key}`; } async createUploadTargetForKey(input: { @@ -111,10 +117,7 @@ class LocalStorage implements StoragePort { } async saveLocalAt(key: string, body: Buffer): Promise { - const root = path.join(process.cwd(), "public", "uploads"); - const target = path.join(root, key); - if (!target.startsWith(root + path.sep)) - throw new Error("Storage key escapes the uploads folder."); + const target = localTargetFor(key); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, body); return this.urlForKey(key); @@ -123,6 +126,15 @@ class LocalStorage implements StoragePort { urlForKey(key: string): string { return `/uploads/${key}`; } + + async verifyUploaded(key: string): Promise { + try { + await access(localTargetFor(key)); + return true; + } catch { + return false; + } + } } class R2Storage implements StoragePort { @@ -163,10 +175,6 @@ class R2Storage implements StoragePort { }; } - async saveLocal(): Promise { - throw new Error("R2Storage cannot save locally."); - } - publicUrlFor(key: string): string { return `${this.config.publicBase}/${key}`; } @@ -174,6 +182,7 @@ class R2Storage implements StoragePort { async createUploadTargetForKey(input: { key: string; contentType: string; + devUploadPath: string; }): Promise { const { createPresignedUrl } = await import("@/lib/ports/r2-sign"); const { url, headers } = await createPresignedUrl({ @@ -192,6 +201,15 @@ class R2Storage implements StoragePort { urlForKey(key: string): string { return `${this.config.publicBase}/${key}`; } + + async verifyUploaded(key: string): Promise { + try { + const response = await fetch(this.urlForKey(key), { method: "HEAD" }); + return response.ok; + } catch { + return false; + } + } } let cached: StoragePort | null = null; @@ -215,3 +233,8 @@ export function getStoragePort(): StoragePort { } return cached; } + +/** Test helper — re-resolve the port after env changes. */ +export function resetStoragePortForTests(): void { + cached = null; +} diff --git a/lib/queue.ts b/lib/queue.ts index dd070ad..f1eb27f 100644 --- a/lib/queue.ts +++ b/lib/queue.ts @@ -58,10 +58,19 @@ export const JOB_NAMES = { * never allowed to break a money-path response: failures are logged and the * recovery sweep re-drives anything stuck. */ +export interface EnqueueOptions { + singletonKey?: string; + delaySeconds?: number; + /** Defaults to 5. Attestation jobs pass 10 — the chain outlives RPC blips. */ + retryLimit?: number; + /** Defaults to 300s. Attestation jobs pass 3600s for slow RPC confirms. */ + expireInSeconds?: number; +} + export async function enqueue( name: string, data: Record, - options?: { singletonKey?: string; delaySeconds?: number } + options?: EnqueueOptions ): Promise { try { const boss = await getQueue(); @@ -75,9 +84,9 @@ export async function enqueue( options: { singletonKey: options?.singletonKey, startAfter: options?.delaySeconds ? options.delaySeconds : undefined, - retryLimit: 5, + retryLimit: options?.retryLimit ?? 5, retryBackoff: true, - expireInSeconds: 300, + expireInSeconds: options?.expireInSeconds ?? 300, }, }); } catch (error) { diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index c11859a..7014a27 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -1,31 +1,39 @@ +import { prisma } from "@/lib/db"; + /** - * Minimal in-memory fixed-window rate limiter for auth actions (Phase 1). - * Per-process only — the single-instance deployment makes this adequate for - * v1; swap for a shared store (e.g. Upstash) when the app scales out. + * Fixed-window rate limiter backed by the RateLimitBucket table, so limits + * hold across serverless instances and restarts (audit: the in-memory map let + * every instance hand out a fresh allowance). The database row is updated + * with a single atomic upsert — concurrent calls serialize on the row lock. + * + * Availability over strictness: on ANY database error the limiter falls back + * to the per-process in-memory map (and logs). A flaky database must never + * take sign-in down with it; the window math below is identical in both + * stores, so behavior only degrades to per-process scope. */ -interface WindowState { - count: number; - resetAt: number; -} - -const windows = new Map(); - export interface RateLimitResult { ok: boolean; remaining: number; retryAfterSeconds: number; } -export function rateLimit( +interface WindowState { + count: number; + resetAt: number; +} + +const fallbackWindows = new Map(); + +function rateLimitInMemory( key: string, limit: number, windowMs: number, - now: number = Date.now() + now: number ): RateLimitResult { - const state = windows.get(key); + const state = fallbackWindows.get(key); if (!state || state.resetAt <= now) { - windows.set(key, { count: 1, resetAt: now + windowMs }); + fallbackWindows.set(key, { count: 1, resetAt: now + windowMs }); return { ok: true, remaining: limit - 1, retryAfterSeconds: 0 }; } if (state.count >= limit) { @@ -39,7 +47,63 @@ export function rateLimit( return { ok: true, remaining: limit - state.count, retryAfterSeconds: 0 }; } -/** Test helper — clears all windows. */ -export function resetRateLimits(): void { - windows.clear(); +interface BucketRow { + count: number; + resetAt: Date; +} + +async function rateLimitInDb( + key: string, + limit: number, + windowMs: number, + now: number +): Promise { + const rows = await prisma.$queryRaw` + INSERT INTO "RateLimitBucket" ("key", "count", "resetAt") + VALUES (${key}, 1, ${new Date(now + windowMs)}) + ON CONFLICT ("key") DO UPDATE SET + "count" = CASE WHEN "RateLimitBucket"."resetAt" <= ${new Date(now)} + THEN 1 ELSE "RateLimitBucket"."count" + 1 END, + "resetAt" = CASE WHEN "RateLimitBucket"."resetAt" <= ${new Date(now)} + THEN ${new Date(now + windowMs)} ELSE "RateLimitBucket"."resetAt" END + RETURNING "count", "resetAt" + `; + const bucket = rows[0]; + if (!bucket) { + // RETURNING never yields zero rows on Postgres; guard anyway and treat as + // a fresh window rather than blocking a legitimate caller. + return { ok: true, remaining: limit - 1, retryAfterSeconds: 0 }; + } + if (bucket.count > limit) { + return { + ok: false, + remaining: 0, + retryAfterSeconds: Math.max(0, Math.ceil((bucket.resetAt.getTime() - now) / 1000)), + }; + } + return { ok: true, remaining: limit - bucket.count, retryAfterSeconds: 0 }; +} + +export async function rateLimit( + key: string, + limit: number, + windowMs: number, + now: number = Date.now() +): Promise { + try { + return await rateLimitInDb(key, limit, windowMs, now); + } catch (error) { + console.error("[rate-limit] DB unavailable — falling back to in-memory window", error); + return rateLimitInMemory(key, limit, windowMs, now); + } +} + +/** Test helper — clears both the durable buckets and the fallback windows. */ +export async function resetRateLimits(): Promise { + fallbackWindows.clear(); + try { + await prisma.rateLimitBucket.deleteMany(); + } catch (error) { + console.error("[rate-limit] DB unavailable — cleared in-memory windows only", error); + } } diff --git a/lib/teams/actions.ts b/lib/teams/actions.ts index 6a8e237..c58d67e 100644 --- a/lib/teams/actions.ts +++ b/lib/teams/actions.ts @@ -17,6 +17,7 @@ import { } from "@/lib/notifications/templates/teams"; import { appUrl } from "@/lib/url"; import { canJoinTeam, MAX_TEAM_MEMBERS, validTeamName, type TeamSnapshot } from "@/lib/teams/policy"; +import { assertNotJudgeForEvent, JudgingError } from "@/services/judging/service"; export interface TeamActionState { error?: string; @@ -66,6 +67,14 @@ export async function createTeamAction( }); if (existingTeam) return { error: "You're already on a team for this hackathon." }; + // Mutual exclusion: judges (invited or active) can never participate. + try { + await assertNotJudgeForEvent(event.id, user.id); + } catch (error) { + if (error instanceof JudgingError) return { error: error.message }; + throw error; + } + const teamCount = await prisma.team.count({ where: { eventId: event.id, status: { not: "DISBANDED" } }, }); @@ -170,6 +179,14 @@ export async function joinTeamByCodeAction( }); if (alreadyInEvent) return { error: "You're already on a team for this hackathon." }; + // Mutual exclusion: judges (invited or active) can never participate. + try { + await assertNotJudgeForEvent(team.event.id, user.id); + } catch (error) { + if (error instanceof JudgingError) return { error: error.message }; + throw error; + } + const membership = team.members.find((m) => m.userId === user.id); const decision = canJoinTeam( snapshotOf(team), @@ -201,6 +218,15 @@ export async function acceptTeamInviteAction(teamId: string): Promise { if (!membership) redirect("/dashboard/teams"); const team = membership.team; + + // Mutual exclusion: judges (invited or active) can never participate. + try { + await assertNotJudgeForEvent(team.event.id, user.id); + } catch (error) { + const message = error instanceof JudgingError ? error.message : "You can't join that team."; + redirect(`/dashboard/teams?join=${encodeURIComponent(message)}`); + } + const decision = canJoinTeam(snapshotOf(team), false, registrationOpen(team.event)); if (!decision.ok) { redirect(`/dashboard/teams?join=${encodeURIComponent(decision.reason!)}`); diff --git a/next.config.ts b/next.config.ts index a010382..8a0507b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,5 @@ import type { NextConfig } from "next"; +import { withSentryConfig } from "@sentry/nextjs/config"; import { RENAMED_IMAGES } from "./lib/seo/renamed-images"; @@ -62,19 +63,79 @@ const HTML_LIMITED_BOTS = new RegExp( "i", ); +/** + * Media lives on a Cloudflare R2 public URL (lib/ports/storage.ts): either + * R2_PUBLIC_BASE or the derived pub-.r2.dev host. Only the origin is + * allow-listed — paths stay unrestricted so bucket layout can change. + */ +function r2PublicOrigin(): string | null { + const explicit = process.env.R2_PUBLIC_BASE; + const derived = process.env.R2_ACCOUNT_ID + ? `https://pub-${process.env.R2_ACCOUNT_ID}.r2.dev` + : null; + const candidate = explicit ?? derived; + if (!candidate) return null; + try { + return new URL(candidate).origin; + } catch { + return null; + } +} + +/** + * Baseline security headers applied to every route. CSP note: script-src + * keeps 'unsafe-inline' because the App Router streams its RSC payload in + * inline