Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@ ADMIN_PASSWORD=
# Set a separate long random string here to isolate captain session signing.
CAPTAIN_SESSION_SECRET=

# Required host match-review cookie signing key. Keep it separate from the
# admin session key so private stat-correction sessions have their own boundary.
MATCH_REPORT_HOST_SESSION_SECRET=

# Shared secret for lab-salbot's server-to-server calls back into this site's
# admin API (currently: triggering a standings recalculation after a
# Discord-approved match result — audit F-01). Set the same random value here
# internal API (standings recalculation and one-time host review links). Set the
# same random value here
# and as lab-salbot's SAL_SITE_INTERNAL_TOKEN. Not tied to admin identity;
# unaffected by retiring ADMIN_PASSWORD (F-05/D-3).
INTERNAL_SERVICE_TOKEN=
# Alias accepted by the match-report host-token endpoint. Use the same value
# as lab-salbot's SAL_SITE_INTERNAL_TOKEN (or keep INTERNAL_SERVICE_TOKEN set).
SAL_SITE_INTERNAL_TOKEN=

# Discord OAuth (admin login)
DISCORD_ADMIN_CLIENT_ID=
Expand Down
4 changes: 4 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

# Admin panel
ADMIN_SESSION_SECRET=a-long-random-string-min-32-chars
MATCH_REPORT_HOST_SESSION_SECRET=a-separate-long-random-string
SAL_SITE_INTERNAL_TOKEN=a-shared-random-bearer-secret
ADMIN_PASSWORD=your-admin-password

# Twitch (optional — /watch page works without this, shows offline state)
Expand All @@ -44,6 +46,8 @@ TWITCH_CLIENT_SECRET=
**Where to find these:**
- `NEXT_PUBLIC_SUPABASE_URL` and keys: Supabase dashboard → Settings → API
- `ADMIN_SESSION_SECRET`: generate with `openssl rand -hex 32`
- `MATCH_REPORT_HOST_SESSION_SECRET`: generate separately; signs only private host match-review sessions
- `SAL_SITE_INTERNAL_TOKEN`: use the same value in lab-salbot so it can mint one-time host review links
- Twitch credentials: Twitch Developer Console → your application

> Local development and E2E runs can use mock data without Supabase configured.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ npm run dev
| `ADMIN_SESSION_SECRET` | Yes | Long random string used to sign admin session cookies |
| `ADMIN_PASSWORD` | Optional | Password-based admin login fallback (used when Discord OAuth is not configured) |
| `CAPTAIN_SESSION_SECRET` | Recommended | Separate signing key for captain session cookies; falls back to `ADMIN_SESSION_SECRET` |
| `MATCH_REPORT_HOST_SESSION_SECRET` | Required for match capture | Dedicated signing key for private host match-review cookies; no admin-key fallback |
| `SAL_SITE_INTERNAL_TOKEN` | Required for match capture | Shared bearer secret used by lab-salbot to mint host review links; `INTERNAL_SERVICE_TOKEN` is accepted as a compatibility alias |
| `DISCORD_ADMIN_CLIENT_ID` | Optional | Discord OAuth app client ID for admin login |
| `DISCORD_ADMIN_CLIENT_SECRET` | Optional | Discord OAuth app client secret |
| `DISCORD_ADMIN_REDIRECT_URI` | Optional | Discord OAuth redirect URI |
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

131 changes: 8 additions & 123 deletions src/app/api/admin/match-reports/[id]/extract/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,8 @@ import { isAdminRequest } from "@/lib/admin-auth";
import { getSupabaseServerClient } from "@/lib/supabase-server";
import { toDatabaseJson } from "@/lib/database-json";
import { getAdminLeagueData, LeagueDataUnavailableError } from "@/lib/league-data";
import type { ExtractedGame } from "@/types/match-report";
import { errorMessage } from "@/lib/error-monitor";
import { callOpenRouterVision } from "@/lib/openrouter-vision";

const SMITE_ROLES = ["Solo", "Jungle", "Mid", "Carry", "Support"] as const;

const EXTRACTION_PROMPT = (
homeOrgName: string,
homeIgns: string[],
awayOrgName: string,
awayIgns: string[],
) => `
You are analyzing a SMITE 2 end-of-match DETAILS tab screenshot. Extract the scoreboard data.

Home team: ${homeOrgName}
Known home players: ${homeIgns.length > 0 ? homeIgns.join(", ") : "(unknown roster)"}

Away team: ${awayOrgName}
Known away players: ${awayIgns.length > 0 ? awayIgns.join(", ") : "(unknown roster)"}

Return ONLY valid JSON in this exact format, no other text:
{
"winner": "home" | "away" | "unknown",
"players": [
{
"ign": "string",
"side": "home" | "away",
"god": "string or null",
"role": "Solo" | "Jungle" | "Mid" | "Carry" | "Support" | null,
"kills": number,
"deaths": number,
"assists": number,
"damageDealt": number or null,
"damageMitigated": number or null
}
]
}

Instructions:
- Match each player to home or away using the known rosters above
- If a player is not in either roster, assign based on which column they appear in (left vs right)
- Extract kills, deaths, assists exactly as shown (integers)
- Extract damage numbers without commas (integers)
- "winner" is "home" if the home team won, "away" if away team won
- Look for VICTORY/DEFEAT text or trophy icons to determine winner
- Include all 10 players (5 per side) if visible
`.trim();
import { extractMatchReportGames } from "@/lib/match-report-extraction";

export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!isAdminRequest(request)) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
Expand Down Expand Up @@ -87,83 +42,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const homePlayers = leagueData.players.filter((p) => p.orgId === match?.homeOrgId).map((p) => p.ign);
const awayPlayers = leagueData.players.filter((p) => p.orgId === match?.awayOrgId).map((p) => p.ign);

const games: ExtractedGame[] = [];

for (let i = 0; i < r.screenshot_urls.length; i++) {
const url = r.screenshot_urls[i];

// Fetch screenshot and convert to base64 data URL
let dataUrl: string;
try {
const imgRes = await fetch(url);
if (!imgRes.ok) throw new Error(`HTTP ${imgRes.status}`);
const buffer = await imgRes.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
const ct = imgRes.headers.get("content-type") ?? "image/jpeg";
const mimeType = ct.includes("png") ? "image/png" : ct.includes("webp") ? "image/webp" : "image/jpeg";
dataUrl = `data:${mimeType};base64,${base64}`;
} catch (err) {
console.error(`Failed to fetch screenshot ${i + 1}:`, err);
games.push({ gameNumber: i + 1, winningSide: "unknown", players: [] });
continue;
}

try {
const text = await callOpenRouterVision([
{
role: "user",
content: [
{ type: "image_url", image_url: { url: dataUrl } },
{
type: "text",
text: EXTRACTION_PROMPT(
homeOrg?.name ?? "Home Team",
homePlayers,
awayOrg?.name ?? "Away Team",
awayPlayers,
),
},
],
},
], { maxTokens: 2048, title: "SAL Match Report" });

// Strip markdown code fences if present
const jsonText = text.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "").trim();
const parsed = JSON.parse(jsonText) as {
winner?: string;
players?: Array<{
ign?: string;
side?: string;
god?: string | null;
role?: string | null;
kills?: number;
deaths?: number;
assists?: number;
damageDealt?: number | null;
damageMitigated?: number | null;
}>;
};

games.push({
gameNumber: i + 1,
winningSide: parsed.winner === "home" ? "home" : parsed.winner === "away" ? "away" : "unknown",
players: (parsed.players ?? []).map((p) => ({
ign: p.ign ?? "",
side: p.side === "away" ? "away" : "home",
god: p.god ?? undefined,
role: SMITE_ROLES.includes(p.role as (typeof SMITE_ROLES)[number]) ? (p.role as string) : undefined,
kills: Number(p.kills ?? 0),
deaths: Number(p.deaths ?? 0),
assists: Number(p.assists ?? 0),
damageDealt: p.damageDealt != null ? Number(p.damageDealt) : undefined,
damageMitigated: p.damageMitigated != null ? Number(p.damageMitigated) : undefined,
})),
});
} catch (err) {
console.error(`AI extraction failed for game ${i + 1}:`, err);
games.push({ gameNumber: i + 1, winningSide: "unknown", players: [] });
}
}
const games = await extractMatchReportGames({
screenshotUrls: r.screenshot_urls,
homeOrgName: homeOrg?.name ?? "Home Team",
homeIgns: homePlayers,
awayOrgName: awayOrg?.name ?? "Away Team",
awayIgns: awayPlayers,
});

// Store extracted data and mark as review
await supabase
Expand Down
9 changes: 9 additions & 0 deletions src/app/api/admin/match-reports/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export async function GET(request: NextRequest) {
const matchMap = new Map(leagueData.matches.map((m) => [m.id, m]));

const reports: MatchReportWithMatch[] = (data ?? []).map((row) => {
// Host-review columns land with db-v1.18.0. Keep the draft site branch
// type-safe against the currently released v1.17.0 generated contract;
// the contract pin follows only after the protected DB release exists.
const hostReviewRow = row as typeof row & {
revision?: number;
host_submitted_at?: string | null;
};
const match = matchMap.get(row.match_id as string);
const homeOrg = orgMap.get(match?.homeOrgId ?? "");
const awayOrg = orgMap.get(match?.awayOrgId ?? "");
Expand All @@ -54,6 +61,8 @@ export async function GET(request: NextRequest) {
createdAt: row.created_at as string,
reviewedAt: row.reviewed_at as string | undefined,
reviewedBy: row.reviewed_by as string | undefined,
revision: hostReviewRow.revision ?? 1,
hostSubmittedAt: hostReviewRow.host_submitted_at ?? undefined,
homeOrgId: match?.homeOrgId ?? "",
homeOrgName: homeOrg?.name ?? match?.homeOrgId ?? "",
homeOrgTag: homeOrg?.tag ?? "",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createHash } from "crypto";
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { createIssueHostTokenHandler } from "./route";

const reportId = "11111111-1111-4111-8111-111111111111";
const rawToken = "t".repeat(43);
const context = { params: Promise.resolve({ id: reportId }) };

describe("internal match-report host token issuance", () => {
it("stores only a token hash and returns a fragment review URL", async () => {
const issueToken = vi.fn().mockResolvedValue(undefined);
const handler = createIssueHostTokenHandler({
canonicalSiteOrigin: "https://sal.example",
internalToken: "internal-secret",
generateToken: () => rawToken,
now: () => new Date("2026-08-18T12:00:00.000Z"),
issueToken,
});
const request = new NextRequest(
`https://sal.example/api/internal/match-reports/${reportId}/host-token`,
{
method: "POST",
headers: {
authorization: "Bearer internal-secret",
"content-type": "application/json",
},
body: JSON.stringify({ host_discord_id: "1234567890" }),
},
);

const response = await handler(request, context);
const body = await response.json();

expect(response.status).toBe(201);
expect(issueToken).toHaveBeenCalledWith({
matchReportId: reportId,
hostDiscordId: "1234567890",
tokenHash: createHash("sha256").update(rawToken).digest("hex"),
expiresAt: "2026-08-18T12:15:00.000Z",
});
expect(body).toEqual({
review_url: `https://sal.example/match-reports/${reportId}/review#access=${rawToken}`,
expires_at: "2026-08-18T12:15:00.000Z",
});
expect(JSON.stringify(issueToken.mock.calls)).not.toContain(rawToken);
});

it("does not mint or persist a token without the internal bearer secret", async () => {
const issueToken = vi.fn();
const generateToken = vi.fn(() => rawToken);
const handler = createIssueHostTokenHandler({
canonicalSiteOrigin: "https://sal.example",
internalToken: "internal-secret",
generateToken,
now: () => new Date(),
issueToken,
});
const request = new NextRequest(
`https://sal.example/api/internal/match-reports/${reportId}/host-token`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ host_discord_id: "1234567890" }),
},
);

const response = await handler(request, context);

expect(response.status).toBe(401);
expect(generateToken).not.toHaveBeenCalled();
expect(issueToken).not.toHaveBeenCalled();
});
});
Loading
Loading