Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: npm run format:check
- run: npm run lint
- run: npm run typecheck
- run: npm test
Expand Down
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.next
node_modules
coverage
package-lock.json
*.md
docs
6 changes: 6 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ AI incident command copilot for stadium/tournament operations. Solo entry for **
- `/api/emergency` — Gemini evacuation plan (PA announcement + command summary + zone orders) AND `[EVAC]` staff tasks added server-side to the store (origin "emergency"). Ops UI: guarded red button → confirm → banner + plan overlay; gates render EGRESS.
- `src/components/dashboard.tsx` — ops control room UI (`/`). `src/components/staff-dashboard.tsx` — staff console (`/staff`), a second persona: weather + AI demand plan + role-grouped task board. Both share `src/lib/useMatchStream.ts` (SSE→state hook), `src/lib/theme.ts` (CVD/contrast-validated dark tokens — status colors always paired with a text label), and `src/components/primitives.tsx` (Panel/Tag/StatRow).
- `src/lib/weather/client.ts` — live weather via Open-Meteo (keyless) with a deterministic simulated fallback; `/api/weather` exposes it. `src/lib/traffic/model.ts` — deterministic approach-traffic/parking model (`trafficAt(minute, phase)`), computed client-side; geo fixtures pinned near `VENUE_LOCATION` (Indore).
- `src/components/venue-map.tsx` — Google Maps + live TrafficLayer when `MAPS_API_KEY` is set (fetched at runtime from `/api/config`, not build-inlined), SVG schematic fallback otherwise. Google objects are locally `any` (no `@types/google.maps` dep).
- `src/components/venue-map.tsx` — Google Maps + live TrafficLayer when `MAPS_API_KEY` is set (fetched at runtime from `/api/config`, not build-inlined), SVG schematic fallback otherwise. Google Maps is typed via a minimal local ambient surface (`src/types/google-maps.d.ts`), avoiding the full `@types/google.maps` dep.
- `/api/demand` — Gemini demand forecast: weather + attendance + phase → stocking plan (`DemandPlan`) + prep `StaffTask`s. `/api/config` — runtime bootstrap (maps key presence). All AI routes stay stateless; the client posts accumulated state. Gemini errors go through `geminiErrorMessage()` for UI-friendly text.
- `/api/copilot` — agentic chat: history + snapshot in; text and/or `toolCalls` out (open_incident / dispatch_task / generate_briefing). Tool calls execute CLIENT-side in `dashboard.tsx#runToolCall`; the system prompt enforces "act only on instruction, answer questions with text".
- `/api/tasks` (GET/POST) + `src/lib/store.ts` — in-memory dispatch queue; staff console polls every 15s. Single-instance by design.
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ cp .env.example .env.local # add your Gemini API key (free at aistudio.google.
npm run dev # http://localhost:3000
```

Useful checks: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`.
Useful checks: `npm run lint`, `npm run format`, `npm run typecheck`, `npm test`, `npm run build`.

## Testing

Expand All @@ -79,13 +79,22 @@ Unit tests ([Vitest](https://vitest.dev)) cover the deterministic core — the l
- **Client state** — the SSE reducer's dedupe / reset / zone-gate projection logic
- **Dispatch store** — id assignment, status defaults, queue cap

- **Routes & security** — API request-body validation (400s) and per-client rate limiting (429s)

```bash
npm test # run once
npm run test:watch # watch mode
npm run test:coverage
npm run test:coverage # coverage floor enforced (~77%)
```

**Accessibility** is audited with Playwright + [axe-core](https://github.com/dequelabs/axe-core) against the running app — static pages plus interactive states (copilot, modals) — and passes with **zero WCAG 2.1 AA violations**:

```bash
npm run dev # in one terminal
npm run audit:a11y # in another
```

CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs lint, typecheck, the full test suite, and a production build on every push and pull request.
CI ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs Prettier check, ESLint, typecheck, the full test suite (with coverage floor), and a production build on every push and pull request. Security posture is documented in [SECURITY.md](SECURITY.md).

## Deploy to Cloud Run

Expand Down
21 changes: 21 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Security

MatchDay Command is a demonstration app for the Google PromptWars competition. All match-day data is **simulated** — there is no real user data, no authentication, and no personal information by design. Still, the app follows sensible web-security practice.

## Reporting a vulnerability

Open a private security advisory on the GitHub repository, or email the maintainer. Please do not file public issues for security reports.

## Measures in place

- **Security headers** (`next.config.ts`): `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`, and a production `Content-Security-Policy`.
- **Input validation**: every API route validates its request body and returns a `400` on malformed input rather than throwing; free-text fields are length-bounded.
- **Rate limiting** (`src/lib/rate-limit.ts`): the AI and mutating routes are rate-limited per client to prevent a single caller from exhausting Gemini quota or disrupting the shared demo (`429` with `Retry-After`).
- **Constrained AI actions**: the copilot can only invoke a fixed allowlist of tools (open incident, dispatch task, generate briefing), executed client-side through an exhaustive `switch` — the model cannot induce arbitrary actions.
- **Secret handling**: `GEMINI_API_KEY` is read server-side only and never sent to the client. The Google Maps browser key is delivered at runtime and must be HTTP-referrer + API restricted.
- **No injection sinks**: no `dangerouslySetInnerHTML`, `eval`, or `new Function`; React escapes all rendered content. The only outbound `fetch` targets fixed, non-user-controlled hosts (Gemini, Open-Meteo).

## Scope notes

- The app is deployed as a **single Cloud Run instance** (`--max-instances 1`); match-day state and rate-limit windows are in-memory and reset on restart, which is intentional for the demo.
- Simulation controls (`/api/sim`) are intentionally global and unauthenticated — the demo is a shared control room, not a per-viewer session.
6 changes: 6 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";

export default tseslint.config(
{ ignores: [".next/**", "node_modules/**", "coverage/**", "next-env.d.ts"] },
Expand All @@ -14,4 +15,9 @@ export default tseslint.config(
"react-hooks/exhaustive-deps": "warn",
},
},
{
// Node scripts / config files (plain JS) — provide Node globals.
files: ["scripts/**", "*.{js,mjs,cjs}"],
languageOptions: { globals: globals.node },
},
);
109 changes: 106 additions & 3 deletions package-lock.json

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

7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
"build": "next build",
"start": "next start",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"audit:a11y": "node scripts/a11y-audit.mjs",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
Expand All @@ -20,6 +23,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@eslint/js": "^9.39.5",
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22",
Expand All @@ -28,6 +32,9 @@
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0",
"playwright": "^1.61.1",
"prettier": "^3.9.5",
"tailwindcss": "^4.0.0",
"typescript": "^5",
"typescript-eslint": "^8.64.0",
Expand Down
53 changes: 53 additions & 0 deletions scripts/a11y-audit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { chromium } from "playwright";
import AxeBuilder from "@axe-core/playwright";

const BASE = process.env.BASE_URL || "http://localhost:3000";
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"];

const browser = await chromium.launch();
const context = await browser.newContext();
let total = 0;

function report(label, violations) {
total += violations.length;
console.log(`\n=== ${label} — ${violations.length} violation(s) ===`);
for (const v of violations) {
console.log(`[${v.impact}] ${v.id}: ${v.help}`);
for (const n of v.nodes.slice(0, 6)) console.log(` ${n.target.join(" ")}`);
}
}

async function audit(page, label) {
const { violations } = await new AxeBuilder({ page }).withTags(TAGS).analyze();
report(label, violations);
}

// Static pages
for (const path of ["/", "/staff", "/tournament"]) {
const page = await context.newPage();
await page.goto(BASE + path, { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(2000);
await audit(page, path);
await page.close();
}

// Interactive states on the ops page (modals + copilot are where a11y hides)
const page = await context.newPage();
await page.goto(BASE + "/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(1500);

await page.getByRole("button", { name: /copilot/i }).click();
await page.waitForTimeout(400);
await audit(page, "/ (copilot open)");
await page
.getByRole("button", { name: /close copilot/i })
.click()
.catch(() => {});

await page.getByRole("button", { name: /emergency/i }).click();
await page.waitForTimeout(400);
await audit(page, "/ (emergency confirm dialog)");

await browser.close();
console.log(`\nTOTAL violations: ${total}`);
process.exit(total > 0 ? 1 : 0);
8 changes: 7 additions & 1 deletion src/app/api/briefing/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { Type } from "@google/genai";
import { geminiErrorMessage, generateJson } from "@/lib/gemini";
import { rateLimitResponse } from "@/lib/rate-limit";
import { DEMO_VENUE } from "@/shared/constants";
import type { HandoverReport, Incident, OpsBriefing, StadiumEvent } from "@/shared/models";

Expand Down Expand Up @@ -54,6 +55,9 @@ function contextBlock(body: BriefingRequest): string {
* The client sends its accumulated state; the server stays stateless.
*/
export async function POST(req: NextRequest) {
const limited = rateLimitResponse(req, "briefing", { limit: 30, windowMs: 60_000 });
if (limited) return limited;

let body: BriefingRequest;
try {
body = (await req.json()) as BriefingRequest;
Expand All @@ -68,7 +72,9 @@ export async function POST(req: NextRequest) {

try {
if (isHandover) {
const generated = await generateJson<Pick<HandoverReport, "narrative" | "actionsForNextShift">>({
const generated = await generateJson<
Pick<HandoverReport, "narrative" | "actionsForNextShift">
>({
system: SYSTEM,
prompt: `Write the shift-handover report for "${body.shiftLabel ?? "current shift"}". Summarize what happened, what was decided and why, and what the incoming shift must pick up.\n\n${contextBlock(body)}`,
schema: HANDOVER_SCHEMA,
Expand Down
Loading
Loading