Game Features
ci / frontend (push) Successful in 9m13s
ci / backend (push) Failing after 6m40s

This commit is contained in:
khodowany
2026-10-04 23:20:06 -05:00
parent 2759337d16
commit 78b2c383f1
65 changed files with 12650 additions and 563 deletions
+2
View File
@@ -0,0 +1,2 @@
# Hostname only, e.g. play.your-domain.example. DNS must point at the host.
FPOL_DOMAIN=localhost
+17
View File
@@ -56,3 +56,20 @@ jobs:
run: python3 modulus.py build --all
- name: Test
run: python3 modulus.py test --all
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Build frontend for browser checks
run: |
npm --prefix frontend ci
npm --prefix frontend run build
- uses: browser-actions/setup-chrome@v2
id: chrome
with:
chrome-version: stable
install-dependencies: true
no-sudo: true
- name: Browser smoke with real C++ server
env:
FPOL_CHROMIUM: ${{ steps.chrome.outputs.chrome-path }}
run: node tests/browser.mjs build/build_x86_debug/fpol_server
+21 -7
View File
@@ -12,28 +12,42 @@ find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl sqlite)
find_package(ligo CONFIG REQUIRED)
find_package(noto CONFIG REQUIRED)
find_package(Threads REQUIRED)
include(CheckCXXSourceCompiles)
function(fpol_check_ligo)
set(CMAKE_REQUIRED_LIBRARIES ligo::ligo)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_TRY_COMPILE_CONFIGURATION "${CMAKE_BUILD_TYPE}")
check_cxx_source_compiles("#include <ligo/http.hpp>
int main() { ligo::http::request request; request.headers[\"Accept\"] = \"application/json\"; decltype(&ligo::http::client::exchange) method = nullptr; (void)method; }" FPOL_HAS_LIGO_RAW_HTTP)
endfunction()
fpol_check_ligo()
if(NOT FPOL_HAS_LIGO_RAW_HTTP)
message(FATAL_ERROR "This app needs the updated Ligo HTTP API. Build with the sibling checkout using tools/build_workspace.py, or install the matching Ligo package revision.")
endif()
add_library(fpol_core src/domain.cpp src/store.cpp)
target_link_libraries(fpol_core PUBLIC modulus::modulus modulus::boost_json noto::noto Threads::Threads)
add_library(fpol_core src/domain.cpp src/store.cpp src/database.cpp src/crypto.cpp src/application.cpp src/game.cpp src/results.cpp)
target_link_libraries(fpol_core PUBLIC modulus::modulus modulus::boost_json noto::noto modulus::openssl Threads::Threads)
add_executable(fpol_admin src/admin.cpp)
target_link_libraries(fpol_admin PRIVATE fpol_core)
add_executable(fpol_server src/main.cpp src/server.cpp)
target_link_libraries(fpol_server PRIVATE fpol_core ligo::ligo)
foreach(target fpol_core fpol_admin fpol_server)
add_executable(fpol_ingest src/ingest_main.cpp src/ingestion.cpp)
target_link_libraries(fpol_ingest PRIVATE fpol_core ligo::ligo)
foreach(target fpol_core fpol_admin fpol_server fpol_ingest)
target_compile_options(${target} PRIVATE $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wpedantic>)
endforeach()
if(BUILD_TESTING)
find_package(Python3 REQUIRED COMPONENTS Interpreter)
add_test(NAME product_fpol
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_all.py $<TARGET_FILE:fpol_admin> $<TARGET_FILE:fpol_server>
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_all.py $<TARGET_FILE:fpol_admin> $<TARGET_FILE:fpol_server> $<TARGET_FILE:fpol_ingest>
)
set_tests_properties(product_fpol PROPERTIES TIMEOUT 180)
set_tests_properties(product_fpol PROPERTIES TIMEOUT 240)
add_custom_target(check
COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} -R ^product_fpol$ --output-on-failure
DEPENDS fpol_admin fpol_server
DEPENDS fpol_admin fpol_server fpol_ingest
USES_TERMINAL
)
endif()
install(TARGETS fpol_server fpol_admin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(TARGETS fpol_server fpol_admin fpol_ingest RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
+72 -84
View File
@@ -1,109 +1,97 @@
# Fantasy Politics
`product-fpol` is a private, noncommercial fantasy politics game for friends around the 2026 US midterms. This first playable development slice lets you browse candidates, build a 12-slot party, check league capacity and replay fictional election-night scoring.
A private fantasy election game for friends, built in C++23 with the shared **Modulus**, **Ligo**, and **Noto/SQLite** libraries. The frontend uses TypeScript and Vite.
The backend is **C++23**, with **Modulus** for builds, **Ligo** for HTTP, and **Noto/SQLite** for immutable catalog, rating and results snapshots. The frontend is TypeScript/Vite. Accounts, live drafts, cross-team ownership, trades, automatic data ingestion and deployment are later milestones.
The app now has username/password accounts, invitation-based registration, private leagues, timed snake drafts, private queues, rosters, and provisional standings. The main navigation is **Leagues / Candidates / Results**. The earlier scoring simulator lives separately at `/lab.html`, with its APIs enabled only by `--allow-demo`.
## Start developing
**Real drafts remain locked.** The included official catalog has 115 candidates in 30 Michigan/North Carolina races. National candidate coverage, sourced ratings, and live count/outcome verification are still incomplete. League creation and invitations work before those data gates clear. There is no deployed site yet.
Prerequisites: Linux, Docker available to your user, Python 3, and Node.js 22.12+ with npm (CI uses Node 24). Initial setup requires network access for public Conan packages, the build image and npm dependencies. Sibling library checkouts are not required.
```sh
python3 modulus.py configure
python3 modulus.py build
```
Seed the development database with explicitly fictional candidates, ratings and results. Run the built admin executable from the repo root in the build environment, or directly on a compatible Linux host:
```sh
build/build_x86_debug/fpol_admin import \
data/demo/catalog.json data/demo/ratings.json \
data/demo/results-0.json data/demo/results-1.json \
data/demo/results-2.json data/demo/results-3.json data/demo/results-4.json
# Optional: a separate, partial real catalog. It has no real ratings/results.
build/build_x86_debug/fpol_admin import data/real/mi-2026-10-04.json
python3 modulus.py run
```
In another terminal:
```sh
cd frontend
npm ci
npm run dev
```
Open **http://127.0.0.1:5173**. Select the fictional catalog, click **Try an example roster**, then **Play replay**. Search/filter the field, change picks and compare points with/without race multipliers. Vite proxies `/api` to port 8080. Restart the backend after C++ changes; refresh the browser after imports. The roster is an in-page sandbox and is cleared on refresh or catalog change.
Data is stored at `var/fpol.sqlite` (ignored by Git), relative to the working directory. Both executables accept `--database PATH`; use the same path when importing and serving. Importing the same document again is harmless. Corrections require new snapshot IDs, preserving the earlier result for replay. `python3 tools/generate_demo.py` deterministically regenerates only fictional fixtures.
The standard Modulus runner launches `fpol_server` inside its container, publishes port 8080 only on host loopback, and mounts the repository so the development database persists. For local direct builds, launch the built server executable instead. There is no hosted environment or authentication yet.
## Scoring to try
Every office — House, Senate, Governor — has a Democrat, Republican, Other and Flex slot. Other includes independent and third-party candidates; Flex takes any affiliation in that office. Each candidate can appear only once in a roster, and opposing candidates may both be picked.
`(vote share percentage + 50 if called/certified winner) × frozen race multiplier`
| Rating | Multiplier | Example |
| --- | --- | --- |
| Safe | 1.00 | 80% winner → 130 points |
| Likely | 1.10 | 60% winner → 121 points |
| Lean | 1.25 | 54% winner → 130 points |
| Toss-up | 1.50 | 51% winner → 151.5; 49% loser → 73.5 |
Candidates are rounded half up to two decimals before summing. Reporting shares score without a win bonus; the current-leader preview is hypothetical and displayed separately. A missing count/rating is unknown, never an invented zero. Only a filled, fully scored roster gets a complete total. Corrections can retract a call. The replay demonstrates unreported votes, early totals, changing leads, calls and corrections. All five frames are synthetic, not a historical backtest.
## Admin and API
```sh
build/build_x86_debug/fpol_admin --help
build/build_x86_debug/fpol_admin capacity data/demo/capacity.json
build/build_x86_debug/fpol_admin score-preview data/demo/preview.json
build/build_x86_debug/fpol_admin catalog
```
The last three commands use the same C++ handlers as `POST /api/capacity`, `POST /api/score-preview` and `GET /api/catalog`. `GET /api/rules` describes the slots and scoring. `GET /api/health` is process liveness. Ligo returns `{ "ok": true, "payload": ... }`, or an error envelope. No admin write endpoint is exposed.
See [the data/API contract](docs/data-contract.md) for schemas, immutable import behavior, input bounds and round handling. The capacity report checks the per-party and total candidate supply for 2–12 players; it does not certify ratings or results coverage.
## Data readiness
**The real league is not ready to draft.** The included Michigan snapshot contains 73 candidates in 15 races; the remaining states, real ratings and live result/call coverage are unresolved. Switching to that catalog shows those gaps rather than fictional replacements. An official candidate list may change and must be reconciled before use.
Read the dated [source feasibility report](docs/data-coverage.md) and [office/state coverage matrix](data/coverage_2026.csv). Free automatic updates remain a launch requirement. No provider transport or national coverage is claimed verified, and no background scraper/poller has been enabled. Runoff/ranked-choice resolution also requires a settled policy before affected races can launch.
## Serve the built frontend
## Start locally
Use the sibling `modulus`, `noto`, and **updated `ligo`** checkouts in this workspace. This change extends Ligo's HTTP API; an older published `ligo/latest` package cannot build this app. The workspace helper exports the local recipes into your Conan cache and builds against those revisions, without publishing anything.
Prerequisites: a C++23 compiler (the configured target is GCC 12), CMake 3.25+, Ninja, Python 3, Conan 2.26.2, Node 22.12+, and network access for initial dependencies. CI uses Node 24.
```sh
conan profile detect # once, if you do not already have a profile
python3 tools/build_workspace.py
npm --prefix frontend ci
npm --prefix frontend run build
python3 modulus.py run -- --web-root frontend/dist
build/workspace_debug/fpol_admin import data/real/us-2026-10-04-partial.json
build/workspace_debug/fpol_admin create-user YOUR_USERNAME
# Password is prompted privately; use at least 12 characters.
build/workspace_debug/fpol_server --web-root frontend/dist
```
Open **http://127.0.0.1:8080**. Assets load at startup, so restart after rebuilding. Node is not needed to serve built assets. The server accepts `--host`, `--port`, `--web-root`, `--database` and `--help`; defaults are `127.0.0.1:8080`, API-only, and `var/fpol.sqlite`. Port 0 selects an available port. SIGINT/SIGTERM stop it cleanly. Exact registered static paths are served; hidden files, symlinks and `/api` asset collisions are excluded. Use only Vite output as the web root. `npm --prefix frontend run preview` is available on port 4173 with the same API proxy.
Open **http://127.0.0.1:8080**. The first account created through the local CLI is an administrator. Friends create accounts through private league invitations. All commands default to `var/fpol.sqlite`; use `--database PATH` consistently to select another database.
## Verify changes
For Vite development, start the backend with `--origin http://127.0.0.1:5173`, then run `npm --prefix frontend run dev`. Use the exact configured origin, including hostname and port. For Vite preview use origin `http://127.0.0.1:4173`. Cookie security defaults suit loopback development; production uses HTTPS and `--secure-cookies`.
The existing Modulus workflow remains available once the matching Ligo package is in its Conan cache/remote:
```sh
python3 modulus.py configure --all
python3 modulus.py build --all
python3 modulus.py test --all
python3 modulus.py format --check
npm --prefix frontend run build
```
CTest's `product_fpol` suite runs the domain/persistence tests through the production admin executable, then HTTP process tests for API envelopes, validation, static assets, startup errors, occupied ports and shutdown. The frontend build includes strict TypeScript checking. For socket-restricted environments, core checks can also run independently:
`modulus.py` remains byte-identical to the canonical bootstrap. Do not use `configure --update` to replace the local Ligo revision with an older published API. The [deployment guide](docs/deployment.md) describes a source-based Compose build that includes all four checkouts.
## Try a complete practice draft
```sh
python3 tests/test_core.py build/build_x86_debug/fpol_admin
build/workspace_debug/fpol_admin import data/demo/catalog.json data/demo/ratings.json \
data/demo/results-0.json data/demo/results-1.json data/demo/results-2.json \
data/demo/results-3.json data/demo/results-4.json
build/workspace_debug/fpol_server --web-root frontend/dist --allow-demo
```
Passing core checks does not replace the HTTP suite. See [the local verification record](docs/verification.md) for the compiler and environmental limitations of this implementation session. CI retains the shared GCC 12 debug/release builds and full test suite.
Create a league and choose the **Practice** candidate pool. Invite a second player, then start from the draft room. Demo data stays explicitly separate from real data. The legacy simulator replays its five synthetic snapshots; those old snapshots are not automatically published as live league results.
Configure generates ignored editor support; customize `modulus.json` and refresh with `python3 modulus.py lsp`. C++ and Python follow shared Modulus formatting; run `python3 modulus.py format` before committing. The checked-in bootstrap remains byte-identical to Modulus's canonical bootstrap. `configure --update` refreshes tooling/packages; `--no-remote` needs an already-populated project cache. CMake installs both executables; distribute frontend assets separately.
## Rules
## Next milestone
Leagues have 2–12 teams and 12 snake-draft rounds. Every team has Democrat, Republican, Other, and Flex slots for each of House, Senate, and Governor. A pick fills its party slot before Flex. Candidates are exclusive within a league; legal-pick checks preserve enough candidates for every remaining roster slot.
Clear the data gates first. Then add the private league and invitations, persist team ownership, freeze the chosen catalog/ratings before a live snake draft, add trades/locks, and connect verified polling to the existing snapshot/import layer. Election-night standings can reuse the deterministic scorer and correction history. Keep product rules here and reusable infrastructure in the shared library repos.
The server owns the 120-second clock. A timeout takes the first legal private-queue candidate, otherwise a uniformly random legal candidate. Commissioners can pause/resume. A process restart pauses active drafts; resuming gives the current picker a fresh 120 seconds. Browser draft views poll every 2 seconds; results and standings poll every 15 seconds.
`(November first-round / first-choice vote share percentage + 50 for the eventual called/certified winner) × frozen race rating`
Safe ×1, Likely ×1.1, Lean ×1.25, Toss-up ×1.5. Each candidate is rounded half up to two decimals before summing. Counts and outcomes are independent: a runoff changes the winner bonus while November share stays intact. Call retractions remove the bonus; corrections can lower points. Missing counts/ratings are unknown, not zero. Provisional rankings show reporting coverage beside known subtotals. Catalog, ratings, order, and rules freeze at draft start. Trades and waivers remain a later milestone.
## Election data and operations
The C++ `fpol_ingest` worker supports reviewed Open America count mappings, a normalized official-source JSON contract, candidate-source change capture, and a civicAPI capture/probe path. **civicAPI normalization is not implemented until a real response/schema has been validated.** No feed is configured or enabled automatically.
Changed raw payloads and normalized observations are immutable. Failed/ambiguous responses are quarantined; the last good counts remain visible with stale provenance. Replaying a file cannot verify a source or publish to a real catalog. Operators must obtain a successful live capture, review mappings, and explicitly activate coverage.
See [ingestion and source contracts](docs/ingestion.md), [source coverage](docs/data-coverage.md), the [506-contest inventory](data/contests_2026.json), and the [state/office coverage matrix](data/coverage_2026.csv). Official lists can change; review again before freezing. `tools/generate_real_catalog.py` reproduces the reviewed NC import and combined catalog; it does not fetch fresh evidence or claim a new review date.
Password recovery is operator assisted:
```sh
build/workspace_debug/fpol_admin reset-link USERNAME
```
Give that user `https://YOUR_HOST/#reset/TOKEN` using the returned token. It expires in 30 minutes, works once, and revokes all existing sessions on use. There is no email service. See [the API contract](docs/application-api.md) for session, CSRF, and request details.
## Verify
```sh
python3 tools/build_workspace.py --test
npm --prefix frontend run build
node tests/browser.mjs build/workspace_debug/fpol_server
python3 modulus.py format --check
```
The browser smoke test uses installed Chromium (`FPOL_CHROMIUM` can select a binary), Node's standard library, two isolated browser sessions, and the real C++ server. Screenshots go to ignored `build/browser-artifacts/`.
Socket-independent checks can be run separately:
```sh
python3 tests/test_core.py build/workspace_debug/fpol_admin
python3 tests/test_application.py build/workspace_debug/fpol_admin build/workspace_debug/fpol_ingest
```
They do not replace HTTP/browser checks. [The verification record](docs/verification.md) states exactly what ran here and what remains blocked. [Deployment and backup instructions](docs/deployment.md) cover persistent storage, Caddy HTTPS, bootstrap accounts, online backups, and restore.
+44
View File
@@ -0,0 +1,44 @@
name: product-fpol
services:
app:
image: product-fpol:local
build:
context: ..
dockerfile: product-fpol/deploy/Dockerfile
command: [fpol_server, --host, 0.0.0.0, --database, /data/fpol.sqlite, --web-root, /app/web, --origin, 'https://${FPOL_DOMAIN:?Set FPOL_DOMAIN in .env}', --secure-cookies]
volumes: [fpol_data:/data]
restart: unless-stopped
init: true
healthcheck:
test: [CMD, curl, --fail, --silent, http://127.0.0.1:8080/api/health]
interval: 20s
timeout: 5s
retries: 3
networks: [private]
ingest:
image: product-fpol:local
command: [fpol_ingest, --database, /data/fpol.sqlite, run]
volumes: [fpol_data:/data]
restart: unless-stopped
init: true
depends_on:
app:
condition: service_healthy
networks: [private]
caddy:
image: caddy:2
environment:
FPOL_DOMAIN: '${FPOL_DOMAIN:?Set FPOL_DOMAIN in .env}'
ports: ['80:80', '443:443', '443:443/udp']
volumes: ['./deploy/Caddyfile:/etc/caddy/Caddyfile:ro', 'caddy_data:/data', 'caddy_config:/config']
restart: unless-stopped
depends_on:
app:
condition: service_healthy
networks: [private]
networks:
private:
volumes:
fpol_data:
caddy_data:
caddy_config:
+2 -1
View File
@@ -5,7 +5,7 @@ import conan.tools.cmake
class product_fpol_conan(conan.ConanFile):
name = "product_fpol"
version = "latest"
description = "Fantasy Politics catalog and scoring prototype"
description = "Private fantasy election leagues with C++ services"
package_type = "application"
settings = "os", "arch", "compiler", "build_type"
options = {"with_tests": [True, False]}
@@ -18,6 +18,7 @@ class product_fpol_conan(conan.ConanFile):
"src/*.hpp",
"tests/*.py",
"data/demo/*.json",
"data/real/*.json",
)
def requirements(self):
self.requires(str(self.python_requires["modulus"].ref), transitive_headers=True, transitive_libs=True, options={"with_openssl": True, "with_sqlite": True})
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -95,8 +95,8 @@ NM,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NY,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NY,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
NY,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NC,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
NC,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
NC,house,yes,official_partial,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
NC,senate,yes,official_partial,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
NC,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
ND,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
ND,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
1 state office scheduled_2026 catalog ratings civicapi_results civicapi_calls openamerica_results openamerica_calls checked_date
95 NY house yes missing missing unverified unverified missing missing 2026-10-04
96 NY senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
97 NY governor yes missing missing unverified unverified missing missing 2026-10-04
98 NC house yes missing official_partial missing unverified unverified documented_state_unverified_office missing 2026-10-04
99 NC senate yes missing official_partial missing unverified unverified documented_state_unverified_office missing 2026-10-04
100 NC governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
101 ND house yes missing missing unverified unverified missing missing 2026-10-04
102 ND senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
+838
View File
@@ -0,0 +1,838 @@
{
"schema_version": 1,
"kind": "catalog",
"id": "nc-2026-10-04",
"mode": "real",
"label": "North Carolina official list · partial US catalog",
"as_of": "2026-10-04T00:00:00Z",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"complete": false,
"races": [
{
"id": "2026-nc-senate-general",
"state": "NC",
"office": "senate",
"round": "general",
"name": "North Carolina Senate"
},
{
"id": "2026-nc-house-01-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 01"
},
{
"id": "2026-nc-house-02-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 02"
},
{
"id": "2026-nc-house-03-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 03"
},
{
"id": "2026-nc-house-04-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 04"
},
{
"id": "2026-nc-house-05-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 05"
},
{
"id": "2026-nc-house-06-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 06"
},
{
"id": "2026-nc-house-07-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 07"
},
{
"id": "2026-nc-house-08-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 08"
},
{
"id": "2026-nc-house-09-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 09"
},
{
"id": "2026-nc-house-10-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 10"
},
{
"id": "2026-nc-house-11-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 11"
},
{
"id": "2026-nc-house-12-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 12"
},
{
"id": "2026-nc-house-13-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 13"
},
{
"id": "2026-nc-house-14-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 14"
}
],
"candidates": [
{
"id": "2026-nc-senate-general-michael-dublin",
"race_id": "2026-nc-senate-general",
"name": "Michael Dublin",
"party": "GRE",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"GRE"
],
"provider_ids": {}
},
{
"id": "2026-nc-senate-general-roy-cooper",
"race_id": "2026-nc-senate-general",
"name": "Roy Cooper",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-senate-general-shannon-w-bray",
"race_id": "2026-nc-senate-general",
"name": "Shannon W. Bray",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-senate-general-michael-whatley",
"race_id": "2026-nc-senate-general",
"name": "Michael Whatley",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-01-general-don-davis",
"race_id": "2026-nc-house-01-general",
"name": "Don Davis",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-01-general-laurie-buckhout",
"race_id": "2026-nc-house-01-general",
"name": "Laurie Buckhout",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-01-general-tom-bailey",
"race_id": "2026-nc-house-01-general",
"name": "Tom Bailey",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-02-general-eugene-f-douglass",
"race_id": "2026-nc-house-02-general",
"name": "Eugene F. Douglass",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-02-general-deborah-k-ross",
"race_id": "2026-nc-house-02-general",
"name": "Deborah K. Ross",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-02-general-matthew-laszacs",
"race_id": "2026-nc-house-02-general",
"name": "Matthew Laszacs",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-03-general-daniel-cavender",
"race_id": "2026-nc-house-03-general",
"name": "Daniel Cavender",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-03-general-raymond-smith",
"race_id": "2026-nc-house-03-general",
"name": "Raymond Smith",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-03-general-greg-murphy",
"race_id": "2026-nc-house-03-general",
"name": "Greg Murphy",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-04-general-valerie-p-foushee",
"race_id": "2026-nc-house-04-general",
"name": "Valerie P. Foushee",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-04-general-guy-meilleur",
"race_id": "2026-nc-house-04-general",
"name": "Guy Meilleur",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-04-general-mahesh-max-ganorkar",
"race_id": "2026-nc-house-04-general",
"name": "Mahesh (Max) Ganorkar",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-05-general-virginia-foxx",
"race_id": "2026-nc-house-05-general",
"name": "Virginia Foxx",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-05-general-robert-b-luffman",
"race_id": "2026-nc-house-05-general",
"name": "Robert B. Luffman",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-05-general-chuck-hubbard",
"race_id": "2026-nc-house-05-general",
"name": "Chuck Hubbard",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-06-general-addison-mcdowell",
"race_id": "2026-nc-house-06-general",
"name": "Addison McDowell",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-06-general-cyril-jefferson",
"race_id": "2026-nc-house-06-general",
"name": "Cyril Jefferson",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-07-general-maad-abu-ghazalah",
"race_id": "2026-nc-house-07-general",
"name": "Maad Abu-Ghazalah",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-07-general-david-rouzer",
"race_id": "2026-nc-house-07-general",
"name": "David Rouzer",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-07-general-kimberly-hardy",
"race_id": "2026-nc-house-07-general",
"name": "Kimberly Hardy",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-08-general-bo-whitehead",
"race_id": "2026-nc-house-08-general",
"name": "Bo Whitehead",
"party": "GRE",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"GRE"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-08-general-colby-watson",
"race_id": "2026-nc-house-08-general",
"name": "Colby Watson",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-08-general-mark-harris",
"race_id": "2026-nc-house-08-general",
"name": "Mark Harris",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-09-general-richard-n-ojeda-ii",
"race_id": "2026-nc-house-09-general",
"name": "Richard N. Ojeda II",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-09-general-richard-hudson",
"race_id": "2026-nc-house-09-general",
"name": "Richard Hudson",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-10-general-steven-feldman",
"race_id": "2026-nc-house-10-general",
"name": "Steven Feldman",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-10-general-ashley-bell",
"race_id": "2026-nc-house-10-general",
"name": "Ashley Bell",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-10-general-pat-harrigan",
"race_id": "2026-nc-house-10-general",
"name": "Pat Harrigan",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-11-general-jennifer-capps-balkcom",
"race_id": "2026-nc-house-11-general",
"name": "Jennifer Capps Balkcom",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-11-general-jamie-ager",
"race_id": "2026-nc-house-11-general",
"name": "Jamie Ager",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-11-general-travis-groo",
"race_id": "2026-nc-house-11-general",
"name": "Travis Groo",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-12-general-jack-codiga",
"race_id": "2026-nc-house-12-general",
"name": "Jack Codiga",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-12-general-alma-s-adams",
"race_id": "2026-nc-house-12-general",
"name": "Alma S. Adams",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-13-general-paul-barringer",
"race_id": "2026-nc-house-13-general",
"name": "Paul Barringer",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-13-general-steven-swinton",
"race_id": "2026-nc-house-13-general",
"name": "Steven Swinton",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-13-general-brad-knott",
"race_id": "2026-nc-house-13-general",
"name": "Brad Knott",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-14-general-lakesha-womack",
"race_id": "2026-nc-house-14-general",
"name": "Lakesha Womack",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-14-general-tim-moore",
"race_id": "2026-nc-house-14-general",
"name": "Tim Moore",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
}
],
"notes": "Reviewed from official PDF pages 1–3, report dated 2026-09-21. Retrieval has day precision; midnight is a date placeholder. Federal contests only; NC has no scheduled 2026 governor race. Refresh against official changes before freezing."
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"id": "civicapi-status",
"kind": "civicapi-probe",
"catalog_id": "us-2026-10-04-partial",
"url": "https://civicapi.org/api/v2/status",
"races": []
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "nc-candidate-watch",
"kind": "candidate-watch",
"catalog_id": "us-2026-10-04-partial",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"races": []
}
+147
View File
@@ -0,0 +1,147 @@
{
"id": "openamerica-nc-house",
"kind": "openamerica",
"catalog_id": "us-2026-10-04-partial",
"url": "https://openamerica.io/elections/results.json?year=2026&office=House",
"office": "House",
"counts_until": "2026-11-20T00:00:00Z",
"review_note": "PROPOSED mappings from official ballot spellings, not a captured provider response. Probe, compare exact spellings/party codes and first-round semantics, then activate only after every configured race is observed.",
"races": [
{
"race_id": "2026-nc-house-01-general",
"role": "counts",
"provider_id": "NC-01",
"candidates": {
"Don Davis|D": "2026-nc-house-01-general-don-davis",
"Laurie Buckhout|R": "2026-nc-house-01-general-laurie-buckhout",
"Tom Bailey|L": "2026-nc-house-01-general-tom-bailey"
}
},
{
"race_id": "2026-nc-house-02-general",
"role": "counts",
"provider_id": "NC-02",
"candidates": {
"Eugene F. Douglass|R": "2026-nc-house-02-general-eugene-f-douglass",
"Deborah K. Ross|D": "2026-nc-house-02-general-deborah-k-ross",
"Matthew Laszacs|L": "2026-nc-house-02-general-matthew-laszacs"
}
},
{
"race_id": "2026-nc-house-03-general",
"role": "counts",
"provider_id": "NC-03",
"candidates": {
"Daniel Cavender|L": "2026-nc-house-03-general-daniel-cavender",
"Raymond Smith|D": "2026-nc-house-03-general-raymond-smith",
"Greg Murphy|R": "2026-nc-house-03-general-greg-murphy"
}
},
{
"race_id": "2026-nc-house-04-general",
"role": "counts",
"provider_id": "NC-04",
"candidates": {
"Valerie P. Foushee|D": "2026-nc-house-04-general-valerie-p-foushee",
"Guy Meilleur|L": "2026-nc-house-04-general-guy-meilleur",
"Mahesh (Max) Ganorkar|R": "2026-nc-house-04-general-mahesh-max-ganorkar"
}
},
{
"race_id": "2026-nc-house-05-general",
"role": "counts",
"provider_id": "NC-05",
"candidates": {
"Virginia Foxx|R": "2026-nc-house-05-general-virginia-foxx",
"Robert B. Luffman|L": "2026-nc-house-05-general-robert-b-luffman",
"Chuck Hubbard|D": "2026-nc-house-05-general-chuck-hubbard"
}
},
{
"race_id": "2026-nc-house-06-general",
"role": "counts",
"provider_id": "NC-06",
"candidates": {
"Addison McDowell|R": "2026-nc-house-06-general-addison-mcdowell",
"Cyril Jefferson|D": "2026-nc-house-06-general-cyril-jefferson"
}
},
{
"race_id": "2026-nc-house-07-general",
"role": "counts",
"provider_id": "NC-07",
"candidates": {
"Maad Abu-Ghazalah|L": "2026-nc-house-07-general-maad-abu-ghazalah",
"David Rouzer|R": "2026-nc-house-07-general-david-rouzer",
"Kimberly Hardy|D": "2026-nc-house-07-general-kimberly-hardy"
}
},
{
"race_id": "2026-nc-house-08-general",
"role": "counts",
"provider_id": "NC-08",
"candidates": {
"Bo Whitehead|G": "2026-nc-house-08-general-bo-whitehead",
"Colby Watson|D": "2026-nc-house-08-general-colby-watson",
"Mark Harris|R": "2026-nc-house-08-general-mark-harris"
}
},
{
"race_id": "2026-nc-house-09-general",
"role": "counts",
"provider_id": "NC-09",
"candidates": {
"Richard N. Ojeda II|D": "2026-nc-house-09-general-richard-n-ojeda-ii",
"Richard Hudson|R": "2026-nc-house-09-general-richard-hudson"
}
},
{
"race_id": "2026-nc-house-10-general",
"role": "counts",
"provider_id": "NC-10",
"candidates": {
"Steven Feldman|L": "2026-nc-house-10-general-steven-feldman",
"Ashley Bell|D": "2026-nc-house-10-general-ashley-bell",
"Pat Harrigan|R": "2026-nc-house-10-general-pat-harrigan"
}
},
{
"race_id": "2026-nc-house-11-general",
"role": "counts",
"provider_id": "NC-11",
"candidates": {
"Jennifer Capps Balkcom|R": "2026-nc-house-11-general-jennifer-capps-balkcom",
"Jamie Ager|D": "2026-nc-house-11-general-jamie-ager",
"Travis Groo|L": "2026-nc-house-11-general-travis-groo"
}
},
{
"race_id": "2026-nc-house-12-general",
"role": "counts",
"provider_id": "NC-12",
"candidates": {
"Jack Codiga|R": "2026-nc-house-12-general-jack-codiga",
"Alma S. Adams|D": "2026-nc-house-12-general-alma-s-adams"
}
},
{
"race_id": "2026-nc-house-13-general",
"role": "counts",
"provider_id": "NC-13",
"candidates": {
"Paul Barringer|D": "2026-nc-house-13-general-paul-barringer",
"Steven Swinton|L": "2026-nc-house-13-general-steven-swinton",
"Brad Knott|R": "2026-nc-house-13-general-brad-knott"
}
},
{
"race_id": "2026-nc-house-14-general",
"role": "counts",
"provider_id": "NC-14",
"candidates": {
"Lakesha Womack|D": "2026-nc-house-14-general-lakesha-womack",
"Tim Moore|R": "2026-nc-house-14-general-tim-moore"
}
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"id": "openamerica-nc-senate",
"kind": "openamerica",
"catalog_id": "us-2026-10-04-partial",
"url": "https://openamerica.io/elections/results.json?year=2026&office=Senate",
"office": "Senate",
"counts_until": "2026-11-20T00:00:00Z",
"review_note": "PROPOSED mappings from official ballot spellings, not a captured provider response. Probe, compare exact spellings/party codes and first-round semantics, then activate only after every configured race is observed.",
"races": [
{
"race_id": "2026-nc-senate-general",
"role": "counts",
"provider_id": "NC",
"candidates": {
"Michael Dublin|G": "2026-nc-senate-general-michael-dublin",
"Roy Cooper|D": "2026-nc-senate-general-roy-cooper",
"Shannon W. Bray|L": "2026-nc-senate-general-shannon-w-bray",
"Michael Whatley|R": "2026-nc-senate-general-michael-whatley"
}
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{$FPOL_DOMAIN} {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy no-referrer
X-Frame-Options DENY
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
}
reverse_proxy app:8080
}
+33
View File
@@ -0,0 +1,33 @@
# Build context is the parent workspace containing product-fpol, modulus, noto and ligo.
FROM node:24-bookworm-slim AS frontend
WORKDIR /frontend
COPY product-fpol/frontend/package*.json ./
RUN npm ci
COPY product-fpol/frontend/index.html product-fpol/frontend/lab.html product-fpol/frontend/tsconfig.json product-fpol/frontend/vite.config.ts ./
COPY product-fpol/frontend/src ./src
RUN npm run build
FROM ubuntu:24.04 AS backend
ENV DEBIAN_FRONTEND=noninteractive CC=gcc-12 CXX=g++-12
RUN apt-get update && apt-get install -y --no-install-recommends gcc-12 g++-12 cmake ninja-build make pkg-config git ca-certificates python3 python3-venv perl && rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/build-python && /opt/build-python/bin/pip install conan==2.26.2
ENV PATH="/opt/build-python/bin:${PATH}"
WORKDIR /workspace
COPY modulus ./modulus
COPY noto ./noto
COPY ligo ./ligo
COPY product-fpol ./product-fpol
RUN conan profile detect && python product-fpol/tools/build_workspace.py --build-type Release
FROM ubuntu:24.04 AS runtime
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libstdc++6 curl && rm -rf /var/lib/apt/lists/* && useradd --uid 10001 --create-home fpol && mkdir -p /data/backups && chown -R fpol:fpol /data && chmod 700 /data
COPY --from=backend /workspace/product-fpol/build/workspace_release/fpol_server /workspace/product-fpol/build/workspace_release/fpol_admin /workspace/product-fpol/build/workspace_release/fpol_ingest /usr/local/bin/
COPY --from=frontend /frontend/dist /app/web
COPY product-fpol/data /app/data
COPY product-fpol/deploy/entrypoint.sh /usr/local/bin/fpol-entrypoint
USER fpol
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["fpol-entrypoint"]
CMD ["fpol_server", "--host", "0.0.0.0", "--database", "/data/fpol.sqlite", "--web-root", "/app/web"]
+19
View File
@@ -0,0 +1,19 @@
*
!product-fpol
!product-fpol/**
!modulus
!modulus/**
!noto
!noto/**
!ligo
!ligo/**
**/.git
**/build
**/node_modules
**/dist
**/var
**/.env*
**/__pycache__
**/.aws
**/.codex
**/.agents
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
umask 077
exec "$@"
+58
View File
@@ -0,0 +1,58 @@
# Application API
All application responses are JSON `{ "ok": true, "status": 200, "payload": ... }`, or `{ "ok": false, "status": ..., "code": ..., "message": ... }`. The HTTP status agrees with the application status. Health and legacy simulator routes keep Ligo's original envelope. Player APIs return current projections; the development simulator alone exposes snapshot history.
## Authentication
`fpol_session` is a random opaque cookie, HttpOnly, SameSite=Lax, Path=/, with a 30-day lifetime. Only its SHA-256 digest is stored. Production requires `--secure-cookies` and an HTTPS `--origin`. Tokens never appear in HTTP JSON. Every POST needs `Origin` exactly matching `--origin` and `X-Fpol-Request: 1`; authenticated POSTs also need `X-CSRF-Token` from login or `GET /api/auth/me`. There is no cross-origin API/CORS access.
- `POST /api/auth/login`: `username, password`. Returns the user (`id, username, admin, csrf`) and sets the cookie.
- `POST /api/auth/register`: `username, password, invite, team_name`. A valid invitation and available seat are checked again transactionally after hashing; a failed join does not create an orphan account.
- `GET /api/auth/me`: current user and CSRF token.
- `POST /api/auth/logout`: invalidates the session and clears the cookie.
- `POST /api/auth/password`: `current_password, password`. Rotates all sessions, returning a new one.
- `POST /api/auth/reset`: `reset_token, password`. Single-use operator token; revokes all sessions and reset tokens for that account. Sign in afterward.
Usernames have 3–32 ASCII letters, numbers or underscores, case-insensitive. Passwords have 12–256 UTF-8 bytes. Hashes use OpenSSL scrypt N=131072/r=8/p=1, an independent 128-bit salt, and a 512-bit result. Two hashing workers serve at most eight in-flight jobs, off the HTTP loop. Login attempts are transactionally limited to 10 per username and 100 per transport peer in 15 minutes; behind Caddy that peer allowance is shared by all players. Password recovery is CLI-only, with a 30-minute expiry. Database files/backups contain secrets and need private storage.
## Leagues
All routes require a session. League-scoped reads and writes require membership. An administrator has no implicit access to other players' leagues.
| Method / path | Input |
| --- | --- |
| GET `leagues` | none; lists own memberships |
| POST `league/create` | `name, team_name, max_teams`; optional `catalog_id` (latest real catalog by default) |
| POST `league/join` | `invite, team_name` |
| GET `league` | query `league_id`; includes own queue, teams, picks, timer, readiness and recent events |
| POST `team/name` | `league_id, name` |
| POST `league/invite` | commissioner; `league_id`; returns a seven-day bearer invitation; replaces prior invitation |
| POST `league/revoke-invites` | commissioner; `league_id` |
| POST `league/remove` | commissioner; `league_id, team_id`; before draft only; also revokes existing invitations |
| POST `league/name` | commissioner; `league_id, name`; rename without changing frozen rules |
| POST `league/ratings` | commissioner; `league_id, document`; immutable sourced ratings JSON while forming |
| POST `league/settings` | commissioner; `league_id, name, catalog_id, ratings_id` (nullable); before draft only; clears queues |
No public registration, public league directory, or email invitation service exists. An invitation is reusable until the league fills, it expires, it is revoked, or drafting starts. One account owns at most one team per league. The commissioner cannot remove their own team.
## Drafts
- `POST draft/start`, `draft/pause`, `draft/resume`: commissioner, `league_id`.
- `POST draft/queue`: `league_id, candidates` (ordered unique candidate IDs, maximum 200). Other members never receive this queue.
- `POST draft/pick`: `league_id, candidate_id, pick_number, request_id`. Use a unique request ID per attempted pick. Retrying the same body/ID returns the original pick; reusing an ID for another body fails with 409. Candidate and slot exclusivity are database constraints. The draft clock and human picks use the same serialized, transactional path; a pick at or after deadline fails and the timer advances the turn.
The draft starts only with 2+ teams, enough global/per-party supply, ratings for every race, and a complete national catalog plus enabled verified count/outcome coverage for real races. The compiled inventory includes 435 House seats, 35 Senate contests (including FL/OH specials), and 36 governor races. The practice pool bypasses real-data gates only under `--allow-demo`.
## Candidates and standings
- `GET catalogs`: catalog/rating summaries, no snapshot history. Demo summaries require `--allow-demo`.
- `GET candidates`: optional `league_id` (pins frozen catalog/ratings/ownership), `catalog_id`, `search`, `office`, `state`, `affiliation`, `available=true`, `offset`, `limit` (1–100). `items` includes source provenance and rating/owner when applicable.
- `GET results`: optional `league_id` or `catalog_id`; current race counts and separate outcomes. Includes received/source/check timestamps and stale status. No observations are invented for missing races.
- `GET standings`: required `league_id`; team rosters, candidate score components, known subtotal, full total only when all 12 scores are known, reporting coverage, provisional flag, and tied competition ranks. Teams with no known scores are unranked.
- `GET admin/sources`: administrator only; current source health, assigned coverage, 50 recent quarantines.
GET parameters are percent-decoded, `+` becomes a space, and duplicate/malformed parameters are rejected by the opted-in Ligo query parser. HTTP requests are capped at 64 KiB. There is no remote admin import/reset/SQL endpoint. `fpol_admin app-request FILE|-` is a **trusted local test/operator interface** that may return session secrets and accepts an injected clock; it is never an HTTP route.
## Persistence
Migration `001_documents` stays unchanged. `002_leagues` adds accounts, sessions, invites, leagues, teams, draft clocks, picks, idempotency commands, events, source configuration, raw payloads, observations, current projections, coverage and quarantine. SQLite WAL, foreign keys and a busy timeout are enabled. One application DB worker owns each short `BEGIN IMMEDIATE` transaction. Hashing and network polling happen outside these transactions. The ingestion process uses its own connection to the same volume.
+6
View File
@@ -61,3 +61,9 @@ All responses use Ligo's `{ok: true, payload: ...}` or `{ok: false, code, messag
| `POST /api/score-preview` | `{catalog_id, ratings_id, results_id, roster: [{slot, candidate_id}]}` | Validation, breakdown, baseline and leader preview |
The complete document response is intentionally suitable for this small prototype; pagination/indexed views will be needed as real replay history grows. Exact routes have no query-string normalization; the browser filters locally. Imported data becomes visible on the next request without restarting. The browser loads its catalog snapshot on page load; refresh after imports. No authentication is implemented yet; use local development until the multiplayer access controls exist.
## Compatibility with league scoring
The APIs and immutable documents above are the original **v1 development simulator**. Their `pending_round` preview behavior remains unchanged for replay compatibility. They are available over HTTP only with `--allow-demo`; CLI imports/previews remain available to the local operator.
Private leagues use the [application API](application-api.md) and separate [count/outcome observations](ingestion.md). Their frozen rule version is `v2-november-share`: November first-round/first-choice share continues to score during a pending runoff, and only the eventual winner bonus follows later-round outcomes. Do not use a v1 preview total as a v2 league standing. Commissioners can import a sourced ratings document through `POST /api/league/ratings` while a league is forming; it uses the existing immutable ratings schema and must reference that league's selected catalog.
+6
View File
@@ -43,3 +43,9 @@ The fictional catalog supports every allowed league size. Michigan alone cannot
5. Settle runoff and ranked-choice scoring policy. The current importer rejects a different round for the same catalog race and `pending_round` remains unscored. A later round must not be added to an earlier count. Do not launch affected races until provider semantics and final-round handling are specified and tested.
Commissioner JSON imports make the foundation reviewable; they are not the planned election-night automation. If free sources cannot meet these gates, bring that concrete limitation back for a product decision rather than introducing a paid dependency or silently narrowing the game.
## League milestone update — October 4, 2026
The combined `data/real/us-2026-10-04-partial.json` now includes **115 candidates / 30 contests**: Michigan's existing 73/15 plus North Carolina's 42 candidates across 14 House districts and one Senate contest. NC facts were reviewed from [the official state-only November candidate list](https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf), pages 1–3, report dated September 21. No residential/contact details were copied. Retrieval timestamps have day precision; midnight is a date placeholder. The remaining 48 states still need candidate imports; real ratings and live coverage remain incomplete.
A national inventory now records 506 scheduled November contests (435 House, 35 Senate including the FL/OH specials, 36 governor). It supplements the 150-row state/office matrix and guards draft readiness. Candidate source watches, Open America normalization, and a reviewed official JSON integration contract are implemented, but **no provider capture is verified**. civicAPI remains capture-only pending an actual response contract. See [ingestion](ingestion.md) for activation requirements and the settled November first-choice share policy.
+73
View File
@@ -0,0 +1,73 @@
# Hosting, backup and recovery
The repository contains a deployment configuration; no host has been selected or deployed. HTTP/browser/container tests must pass on a normal host before using it with friends. Real drafting remains data-gated separately.
## Build and bootstrap
Keep `product-fpol`, `modulus`, `noto`, and the updated `ligo` repositories beside each other. Compose uses the parent directory as its build context, excludes caches/secrets, exports those local Conan recipes, and builds a release C++ image with GCC 12. The frontend uses a separate Node build stage. First build requires internet access for base images and dependencies.
```sh
cp .env.example .env
# Set FPOL_DOMAIN to a hostname whose DNS points at your host; no scheme/path.
docker compose build
# Bootstrap migrations, the partial catalog, and an administrator before serving.
docker compose run --rm --no-deps app fpol_admin --database /data/fpol.sqlite \
import /app/data/real/us-2026-10-04-partial.json
docker compose run --rm --no-deps app fpol_admin --database /data/fpol.sqlite create-user YOUR_USERNAME
docker compose up -d
```
Caddy owns public ports 80/443 and obtains HTTPS certificates. The app's 8080 port is internal only. Both app and worker use UID 10001 and the persistent `fpol_data` volume. Caddy certificates/config have their own volumes. The entrypoint uses umask 077. Health checks call `/api/health`; the worker starts after the app is healthy. No demo mode is enabled. Source activation is explicit; an idle ingestion worker invents no data.
Log in, create a league, and share its private invitation link. To reset a password:
```sh
docker compose exec app fpol_admin --database /data/fpol.sqlite reset-link USERNAME
```
Privately give that user `https://YOUR_HOST/#reset/TOKEN`. The link expires in 30 minutes and works once. Do not paste passwords, invitations, session tokens or reset links into public logs/issues.
## Source setup
Use [the ingestion guide](ingestion.md). Copy reviewed configuration files into the container or mount an operator-controlled directory. Configure, probe, inspect source status and archived payload/mappings, then activate. Production startup never auto-activates sample mappings. Both coverage and the candidate inventory must be complete before real draft start.
## Online backup
Use SQLite's online backup API through Noto while the service is running. Choose a new filename each time; the command refuses to overwrite one.
```sh
docker compose exec app fpol_admin --database /data/fpol.sqlite \
backup /data/backups/fpol-2026-10-04.sqlite
docker compose cp app:/data/backups/fpol-2026-10-04.sqlite ./fpol-2026-10-04.sqlite
chmod 600 ./fpol-2026-10-04.sqlite
```
Store a protected off-host copy. Include the deployed source revisions and source configuration files. The database backup contains accounts, sessions, invites, frozen league data, picks, raw source payloads and observation history. Do not copy only the live SQLite main file while WAL writes are active.
## Restore
Stop **both** writers. Keep the original database and sidecars as a rollback set. Restore to a stopped app container; adapt the filename to your chosen backup. Do not restore a backup over a running database.
```sh
docker compose stop app ingest
# The existing container can receive files while stopped.
docker compose cp ./fpol-2026-10-04.sqlite app:/data/restore.sqlite
docker compose run --rm --no-deps --user root app sh -c 'chown 10001:10001 /data/restore.sqlite && chmod 600 /data/restore.sqlite'
# Use the image's unprivileged account, backed by the same volume.
docker compose run --rm --no-deps app sh -c '
set -eu
test -f /data/restore.sqlite
mkdir /data/before-restore
for name in fpol.sqlite fpol.sqlite-wal fpol.sqlite-shm; do
if test -f "/data/$name"; then mv "/data/$name" /data/before-restore/; fi
done
cp /data/restore.sqlite /data/fpol.sqlite
chmod 600 /data/fpol.sqlite
fpol_admin --database /data/fpol.sqlite rules >/dev/null
'
docker compose up -d app ingest
```
The ownership step affects only the staged backup, so UID 10001 can read it. Verify SQLite integrity on the offline backup (`PRAGMA integrity_check`), source health, memberships, and the last draft pick. Restored active drafts pause automatically; commissioners resume with a fresh 120-second turn. Restoring an older backup also restores old session/reset state; consider revoking sessions and reset tokens via trusted local SQLite administration after recovery.
Migrations are forward-only. Back up before upgrades; reverting application binaries after a schema change requires restoring a compatible database backup too. Never use `docker compose down -v` to restart this app: it deletes persistent volumes.
+88
View File
@@ -0,0 +1,88 @@
# Election ingestion
`fpol_ingest` is a separate C++ process using Ligo's raw HTTPS client and Noto/SQLite. It supports conditional ETag/Last-Modified requests, a 25-second network deadline, a 16 MiB response limit, exponential failure backoff up to one hour, immutable changed-payload archives, immutable observations, quarantine, and current projections. Enabled count/outcome feeds are polled approximately every 60 seconds; candidate watches daily. Responses request identity encoding. No browser scrapes providers directly.
## Honest readiness
As of the implementation session, **no live provider is verified**. The environment cannot open sockets or resolve outbound DNS. Open America's published schema informed a tested adapter; synthetic replay tests are not transport validation. civicAPI has only a capture-only probe: its race/candidate contract still needs a real successful response before a normalizer can be written. The `official-json` adapter is a normalized integration contract, not a claim that arbitrary state websites already emit it. Additional state-specific CSV/XML/JSON adapters and reviewed PDF imports are still required for national coverage.
Open America documents counts from 22 states, no pre-election candidate catalog, and no race calls. Its `final`, leader, precinct percentage, and candidate `w` fields never award a winner bonus. Documentation: [Open America election API](https://openamerica.io/elections/api/). Attribution in the UI should retain the feed URL and timestamps; the source is official state election offices via Open America.
## Configure, capture, review, activate
```sh
fpol_admin --database var/fpol.sqlite import data/real/us-2026-10-04-partial.json
fpol_ingest --database var/fpol.sqlite configure data/sources/openamerica-nc-house.json
fpol_ingest --database var/fpol.sqlite probe openamerica-nc-house
fpol_ingest --database var/fpol.sqlite status
# Inspect the archived raw response and exact candidate/round mappings first.
fpol_ingest --database var/fpol.sqlite activate openamerica-nc-house
fpol_ingest --database var/fpol.sqlite run
```
Configuration starts disabled and unverified. The sample NC mappings use official ballot spellings as **proposals**; they have not been matched to an actual provider capture. Update exact aliases after review. A configuration change disables the source and clears its per-race verification checks. Activation requires a validated live observation for **every configured race/role**, not merely an HTTP 200 or one race in the response. Before election night, an empty results payload cannot satisfy this requirement; real draft readiness may need a provider that offers verifiable race coverage before counts arrive.
`probe` fetches even a disabled source. `once` polls only enabled sources whose next check is due. `disable ID` stops scheduled polling. `activate ID` selects this provider for each configured role/race, replacing that assignment; do this deliberately, with reviewed data. Existing selected counts are never added to another provider's counts.
Raw changed responses are stored in `source_payloads` keyed by source plus content hash. Use the local SQLite client or a backup to inspect/export them; they are not served through player APIs. `source_checks` records only successful live validations. `observations` retains normalized records, `current_observations` selects the current record, `coverage` assigns one count source and one outcome source per contest, and `source_quarantine` records rejected responses and their archive ID.
## Adapter configuration
A configuration has `id`, `kind`, HTTPS `url`, `catalog_id`, and `races`. Each assignment has an internal `race_id` and `role` (`counts` or `outcome`). Standard HTTPS hostnames on port 443 are supported; redirects are reported as HTTP errors for operator review.
For `openamerica`, also supply `office` (`House`, `Senate`, `Governor`), `counts_until` in UTC, and each assignment's `provider_id` plus a `candidates` object mapping exact `name|party-code` to stable candidacy IDs. Explicit `null` mappings can exclude an aggregate write-in row; unknown rows quarantine the response. Separate fusion ballot lines can map to the same candidacy and their votes are summed once. Duplicate provider rows, wrong-race IDs, negative counts and impossible totals are rejected. Do not map different people together or assign aggregate write-ins a roster slot.
Open America does not expose a round identifier. The configured cutoff prevents a later election from silently overwriting November counts. Review each state's feed semantics, including ranked-choice reporting; do not activate an adapter if it cannot supply first-choice counts. After the cutoff, reviewed first-round corrections need an official-source adapter with explicit round metadata. Sample NC cutoffs are conservative operational bounds, not a national runoff calendar.
The `official-json` endpoint returns:
```json
{
"schema_version": 1,
"observations": [
{
"race_id": "2026-nc-senate-general",
"kind": "counts",
"round": "november_first",
"source_time": "2026-11-04T03:00:00Z",
"total_votes": 1000,
"votes": [
{ "candidate_id": "2026-nc-senate-general-roy-cooper", "votes": 480 }
]
},
{
"race_id": "2026-nc-senate-general",
"kind": "outcome",
"round": "general",
"source_time": "2026-11-04T04:00:00Z",
"status": "reporting",
"winner_id": null
}
]
}
```
**The numbers above are schema examples, not election results.** `source_time` may be null when unknown; use UTC `Z` or `+00:00` otherwise. Source timestamps and receipt/check times remain separate. Counts may contain zero values, but zero total or a missing candidate produces an unknown score. Candidate totals cannot exceed the reported race total; the total may include explicitly excluded write-ins. Only `november_first` counts are accepted. A later runoff/RCV outcome may use its own round identifier without altering those counts.
Outcome status is `reporting`, `pending_round`, `called`, or `certified`. Only called/certified outcomes have a `winner_id`. A newer reporting/pending record retracts a provisional call. A certified outcome takes precedence over subsequent provisional records; a newer certified correction may change the winner. Older source timestamps are archived without moving the current projection backward. Missing races do not erase previous data. Failed or ambiguous responses preserve the last good observation and mark source health stale.
## Candidates and reviews
```sh
fpol_ingest configure data/sources/nc-candidate-watch.json
fpol_ingest watch nc-candidate-watch
fpol_ingest configure data/sources/civicapi-status.json
fpol_ingest probe civicapi-status
```
`candidate-watch` captures changed public source files daily for human review; it does not blindly parse PDF names or silently change a frozen draft pool. Import a new validated catalog ID after reviewing changes. Preserve stable candidacy IDs across spelling/party-line updates; merge fusion lines; exclude aggregate write-ins. Individually qualified write-in candidates need their own reviewed identity and eligibility evidence. Old catalog documents and draft snapshots remain immutable.
[The inventory](../data/contests_2026.json) covers the 50 states' scheduled November House/Senate/governor contests, including safe and uncontested seats, with FL/OH special Senate contests distinct. Round data stays separate from contest identity. House at-large seats use internal district `01`; map providers' `00` explicitly. The inventory is a scope checklist, not evidence that candidate or result coverage is complete.
## Replay without overstating verification
```sh
fpol_ingest replay SOURCE_ID captured-or-fixture.json
```
Replay archives and validates the file but never grants live verification. It can publish observations only into an explicitly fictional `mode: demo` catalog; real catalogs require network observations. Demo replays can assign empty demo coverage slots so practice standings can be exercised. Replaying an old file never unlocks real drafting. The first milestone's immutable `results` snapshots and `score-preview` remain a separate v1 lab contract; the league scorer uses `v2-november-share`.
+31 -22
View File
@@ -1,39 +1,48 @@
# Local verification — 2026-10-04
# Verification — league milestone, 2026-10-04
## Passed
## Passed locally
- Both `fpol_server` and `fpol_admin` compile in Debug and Release using the available **GCC 16.2.1** host compiler and C++23.
- 25 core tests pass against the production admin executable in both configurations. These cover Noto migrations, reopening SQLite, import idempotency, immutable IDs, corrections, chronological replay, mode isolation, roster eligibility, rounding, capacity, missing data, ambiguous leader previews and round mismatches.
- The real Michigan snapshot imports successfully; candidate counts match the coverage report. Demo and real data are seeded in the ignored local `var/fpol.sqlite`.
- Strict TypeScript checking and the Vite production build pass.
- Shared Modulus formatting and `git diff --check` pass. The bootstrap is unchanged from the canonical shared file.
- Server help and invalid-startup tests pass without sockets.
- C++23 Debug and Release builds of `fpol_server`, `fpol_admin`, and `fpol_ingest` with the available **GCC 16.2.1** host compiler.
- 25 original domain/persistence tests: immutable imports, reopening SQLite, migrations, capacity, eligibility, score rounding, mode isolation, missing data, replay corrections and original round behavior.
- 19 application/ingestion tests against the production C++ executables and real SQLite databases: authentication/CSRF/rate limits, invite registration/capacity/expiry/revocation, private league permissions, password change/recovery/session revocation, partial real catalog paging, private queues, snake order, timeouts, pause/restart, simultaneous idempotent picks, cross-league ownership, complete 12-team/144-pick drafts, global party-slot capacity, first-round/runoff separation, retractions/corrections/older observations, quarantine, fusion-line aggregation, unverified-feed refusal, per-race activation gates, sourced ratings permissions/immutability/freeze, online backups, and candidate-watch content change detection.
- Strict TypeScript checking and Vite production build, including the separate lab entry point.
- Shared Modulus C++/Python formatting, `git diff --check`, and canonical-bootstrap comparison.
- Updated Ligo library and its test executable compile. The new socket-independent response/header metadata test passes.
- Compose configuration parses successfully with `FPOL_DOMAIN=game.example docker compose config --quiet`.
- The combined real candidate import succeeds: 115 candidates, 30 contests, explicitly partial. The national inventory has 506 contests.
## Environment limits
The full Debug CTest run passed 25 domain plus the then-current 18 application tests. After the final candidate-watch regression was added, all 19 application tests passed separately in Debug. Release passed the 25 domain and 18 application tests, then the added watch regression separately after rebuilding. These are local fallback checks, not a successful run of the configured GCC 12 pipeline.
The full Debug CTest run executes all 25 core tests successfully, then runs eight server tests. Two server tests pass; six cannot execute their socket-dependent paths because the sandbox rejects socket creation with `Operation not permitted`. The suite is deliberately still failing here, not skipped or reported green. HTTP success/error envelopes, real request transport, static delivery and shutdown need the normal development/CI environment. A browser interaction/visual check also remains outstanding.
## Blocked checks — not passed or skipped
Docker's socket is inaccessible in this environment, and GCC 12 is not installed on the host. Consequently, the configured Modulus GCC 12/container workflow could not be verified here. The fallback builds local Modulus/Noto sources into ignored directories under this repository and uses already-cached Boost/OpenSSL/SQLite plus the existing local Ligo build. Release application compilation maps cached dependency configurations to Debug; this is an optimized application check, not a fully optimized dependency build or performance measurement. No shared library sources or dependency caches were modified.
The full Debug CTest suite remains **red**: two server tests (help and invalid startup) pass, while seven HTTP/socket checks cannot execute because socket creation is rejected with `Operation not permitted`. These include the new cookie/Secure/HttpOnly/origin/CSRF/query checks. Ligo's new raw HTTP wire test also stops when its server cannot bind.
The committed Gitea workflow still runs the intended GCC 12 debug/release builds and the full HTTP suite. Run it, or the README's normal Modulus checks, before merging.
The real-browser smoke test was attempted and stops at Node's `listen EPERM` before Chromium can exercise the app. Therefore no interactive/visual browser pass or screenshots are claimed. The checked-in Node/CDP test uses two isolated browser sessions and the actual C++ server; CI now invokes it after frontend/backend builds.
## Reproduce these local fallback checks
A real Open America probe was attempted. It failed DNS resolution (`Host not found … try again later`) and left the source **disabled, unverified, with no successful capture**. No live election results or national feed coverage are claimed. civicAPI remains a capture-only probe pending an actual response contract. Provider fixtures used by tests are synthetic and never count as verification.
These paths are local artifacts, not portable prerequisites:
Docker's daemon/socket is inaccessible and GCC 12 is not installed on this host. The Docker image, Caddy startup/certificate issuance, source-based Conan helper on a fresh networked machine, and the full GCC 12 debug/release/HTTP/browser pipeline still need execution outside this environment. Compose parsing is not a container deployment test.
## Build context and dependency handoff
Fallback builds use ignored `build/kickoff_debug`, `build/kickoff_release`, and `build/verification_*` directories, with locally built shared libraries and cached Boost/OpenSSL/SQLite. Release application compilation maps cached dependency configurations to Debug; it is an optimized application check, not a fully optimized dependency build or benchmark.
**The sibling Ligo repository has required source changes** in its service/HTTP interfaces and implementation, plus tests. They add optional query parsing, request headers, custom response status/headers, and raw HTTP client exchange. Default query handling and existing aggregate initialization remain compatible. Carry those library changes with this application change. The package-based CI needs that matching Ligo revision published/available before it can pass; `tools/build_workspace.py` and the Docker source build use the local sibling revision without publishing.
No deployment, library publishing, Git commit, or push was performed. Existing local accounts/passwords were not created or replaced. Test users live only in temporary databases; the failed provider probe is in ignored `build/source-probe.sqlite`.
## Reproduce
On a normal networked host, use the [README](../README.md) and [deployment guide](deployment.md). In this existing offline workspace, the configured fallback paths are:
```sh
cmake --build build/kickoff_debug
cmake --build build/kickoff_release
python3 tests/test_core.py build/kickoff_debug/fpol_admin
python3 tests/test_core.py build/kickoff_release/fpol_admin
python3 tests/test_application.py build/kickoff_debug/fpol_admin build/kickoff_debug/fpol_ingest
ctest --test-dir build/kickoff_debug --output-on-failure
npm --prefix frontend run build
node tests/browser.mjs build/kickoff_debug/fpol_server
```
The local database and frontend assets are already prepared. Outside the socket-restricted sandbox, the locally built prototype can be started from this repo root:
```sh
build/kickoff_debug/fpol_server --web-root frontend/dist
```
Open `http://127.0.0.1:8080`. Provider JSON transport and automatic election-night coverage are separate outstanding checks listed in [the data report](data-coverage.md).
Do not interpret the socket-independent successes as substitutes for the blocked HTTP/browser tests or real source verification.
+2 -12
View File
@@ -8,18 +8,8 @@
<title>Fantasy Politics</title>
</head>
<body>
<header class="site-header">
<a class="wordmark" href="/" aria-label="Fantasy Politics home">
<span class="monogram" aria-hidden="true">fp.</span>
<span>Fantasy Politics</span>
</a>
<span class="edition">The 2026 edition</span>
</header>
<main id="app"><p class="loading" role="status">Loading the field…</p></main>
<noscript>This prototype needs JavaScript to browse candidates and calculate scores.</noscript>
<footer><span>A game among friends.</span><span>2026 US midterm elections</span></footer>
<div id="app"><p role="status">Loading Fantasy Politics…</p></div>
<noscript>Enable JavaScript to play with your league.</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>FPOL · Development lab</title></head><body><div id="app"></div><script type="module" src="/src/lab.ts"></script></body></html>
+25
View File
@@ -0,0 +1,25 @@
import { api } from './api';
import { element, escape, time } from './ui';
import type { Catalogs } from './types';
interface Source {
id: string;
kind: string;
enabled: number;
verified: number;
last_checked: number | null;
last_success: number | null;
last_error: string | null;
}
export async function adminPage(signal: AbortSignal) {
const [data, catalogs] = await Promise.all([api<{
sources: Source[];
quarantine: {
source_id: string;
reason: string;
retrieved_at: number;
}[];
}>('admin/sources', undefined, signal), api<Catalogs>('catalogs', undefined, signal)]);
if (signal.aborted)
return;
element('#page').innerHTML = `<h1>Source diagnostics</h1><p class="muted">Operator tools. Catalog imports, source configuration, and password recovery run through the local C++ command-line tools.</p>${catalogs.allow_demo ? '<p><a href="/lab.html">Open the development simulator ↗</a></p>' : ''}<section class="panel"><h2>Feeds</h2>${data.sources.map(s => `<article class="candidate"><strong>${escape(s.id)}</strong><p>${escape(s.kind)} · ${s.verified ? 'verified' : 'unverified'} · ${s.enabled ? 'enabled' : 'disabled'}</p><p>Checked ${time(s.last_checked)} · succeeded ${time(s.last_success)}</p>${s.last_error ? `<p class="error">${escape(s.last_error)}</p>` : ''}</article>`).join('') || '<p>No feeds are configured. Live coverage is not yet verified.</p>'}</section><details class="panel"><summary>Quarantined responses (${data.quarantine.length} recent)</summary>${data.quarantine.map(q => `<p>${escape(q.source_id)} · ${time(q.retrieved_at)}<br>${escape(q.reason)}</p>`).join('') || '<p>No quarantined responses.</p>'}</details>`;
}
+28
View File
@@ -0,0 +1,28 @@
export interface User {
id: string;
username: string;
admin: number;
csrf: string;
}
export let user: User | null = null;
export function setUser(value: User | null) { user = value; }
export class ApiError extends Error {
constructor(message: string, public status: number) { super(message); }
}
let controller = new AbortController();
export function beginPage() { controller.abort(); controller = new AbortController(); return controller.signal; }
export async function api<T>(action: string, input?: object, signal?: AbortSignal): Promise<T> {
const response = await fetch(`/api/${action}`, { method: input ? 'POST' : 'GET', credentials: 'same-origin', signal,
headers: input ? { 'Content-Type': 'application/json', 'X-Fpol-Request': '1', 'X-CSRF-Token': user?.csrf || '' } : {}, body: input ? JSON.stringify(input) : undefined });
const body = await response.json();
if (!response.ok || body.ok === false)
throw new ApiError(body.message || 'The request could not be completed.', response.status);
return body.payload as T;
}
export function query(action: string, values: Record<string, string | number | null | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values))
if (value != null && value !== '')
params.set(key, String(value));
return `${action}?${params}`;
}
+25
View File
@@ -0,0 +1,25 @@
import { api, setUser, user } from './api';
import type { User } from './api';
import { element, escape, field, go, submit } from './ui';
export function authPage(path: string[], refresh: () => void) {
const invite = path[0] === 'join' ? path[1] || '' : '';
const reset = path[0] === 'reset' ? path[1] || '' : '';
element('#page').innerHTML = `<section class="auth"><p class="eyebrow">A little friendly competition</p><h1>${reset ? 'Reset your password' : invite ? 'You’re invited.' : 'Election night, together.'}</h1><p>Draft a fantasy political party. Follow the races. Play with your friends.</p><div class="panel"><h2>${reset ? 'Choose a new password' : 'Sign in'}</h2><form id="signin">${reset ? '' : field('Username', 'username', '', 'autocomplete="username" pattern="[a-zA-Z0-9_]{3,32}"')}${field('Password', 'password', '', 'type="password" autocomplete="' + (reset ? 'new-password' : 'current-password') + '" minlength="12" maxlength="256"')}<button type="submit">${reset ? 'Reset password' : 'Sign in'}</button></form><p class="muted">${reset ? 'Your reset link can be used once.' : 'Need a password reset? Ask the person hosting your league for a reset link.'}</p></div>${invite ? `<details class="panel" open><summary>New here? Create an account</summary><form id="register">${field('Username', 'username', '', 'autocomplete="username" pattern="[a-zA-Z0-9_]{3,32}" minlength="3" maxlength="32"')}${field('Password · at least 12 characters', 'password', '', 'type="password" autocomplete="new-password" minlength="12" maxlength="256"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<button type="submit">Join your friends</button></form></details>` : !reset ? '<p class="muted">New players join through a league invitation.</p>' : ''}</section>`;
submit('#signin', async (data) => {
if (reset) {
await api('auth/reset', { reset_token: reset, password: data.get('password') });
setUser(null);
go('leagues');
refresh();
return;
}
setUser(await api<User>('auth/login', Object.fromEntries(data)));
refresh();
});
if (invite)
submit('#register', async (data) => { setUser(await api<User>('auth/register', { ...Object.fromEntries(data), invite })); go('leagues'); refresh(); });
}
export function accountPage(refresh: () => void) {
element('#page').innerHTML = `<h1>Your account</h1><p>Signed in as ${escape(user?.username)}</p><section class="panel narrow"><h2>Change password</h2><form id="password">${field('Current password', 'current_password', '', 'type="password" autocomplete="current-password"')}${field('New password', 'password', '', 'type="password" autocomplete="new-password" minlength="12" maxlength="256"')}<button type="submit">Update password</button></form></section>`;
submit('#password', async (data) => { setUser(await api<User>('auth/password', Object.fromEntries(data))); go('leagues'); refresh(); });
}
+45
View File
@@ -0,0 +1,45 @@
import { api, query } from './api';
import { click, element, escape, message, sourceLink } from './ui';
import type { Candidate, CandidatePage } from './types';
export interface CandidateActions {
legal: () => Set<string>;
queued: () => Set<string>;
pick: (candidate: Candidate) => Promise<void>;
queue: (candidate: Candidate) => Promise<void>;
}
export function candidateBrowser(root: HTMLElement, signal: AbortSignal, league?: string, actions?: CandidateActions) {
let offset = 0, sequence = 0;
const pageSize = 30;
root.innerHTML = `<form class="search" role="search"><label class="sr-only" for="candidate-search">Search candidates</label><input id="candidate-search" name="search" type="search" placeholder="Search by name, state, or district" autocomplete="off"><details><summary>Filters</summary><div class="filters"><label>Office<select name="office"><option value="">All offices</option><option value="house">House</option><option value="senate">Senate</option><option value="governor">Governor</option></select></label><label>Party slot<select name="affiliation"><option value="">Any party</option><option value="democrat">Democrat</option><option value="republican">Republican</option><option value="other">Other</option></select></label><label>State<input name="state" maxlength="2" placeholder="e.g. MI"></label>${league ? '<label class="checkbox"><input type="checkbox" name="available" value="true"> Available only</label>' : ''}</div></details></form><p class="muted catalog-note"></p><div class="candidate-list" aria-live="polite"></div><div class="pagination"></div>`;
const form = element<HTMLFormElement>('form', root);
let timer: number | undefined;
async function refresh() {
const ticket = ++sequence;
const values = Object.fromEntries(new FormData(form)) as Record<string, string>;
values.state = values.state?.toUpperCase();
try {
const page = await api<CandidatePage>(query('candidates', { ...values, league_id: league, offset, limit: pageSize }), undefined, signal);
if (signal.aborted || ticket !== sequence)
return;
element('.catalog-note', root).textContent = page.catalog ? `${page.total} candidates · ${page.catalog.mode === 'demo' ? 'Fictional practice pool' : page.catalog.complete ? 'Complete catalog' : 'Partial official catalog · more states to come'}` : 'Candidate data is being prepared.';
element('.candidate-list', root).innerHTML = page.items.length ? page.items.map(c => `<article class="candidate"><div class="candidate-main"><div><strong>${escape(c.name)}</strong><span class="candidate-meta">${escape(c.state)} · ${escape(c.office)} · ${escape(c.affiliation)}${c.owner ? ' · ' + escape(c.owner) : ''}</span></div>${actions ? `<div class="actions"><button class="quiet queue-candidate" data-id="${escape(c.id)}" ${c.owner || actions.queued().has(c.id) ? 'disabled' : ''}>${actions.queued().has(c.id) ? 'Queued' : '+ Queue'}</button><button class="pick-candidate" data-id="${escape(c.id)}" ${!actions.legal().has(c.id) ? 'disabled' : ''}>Draft</button></div>` : `<span class="badge">${c.rating ? escape(c.rating.replace('_', ' ')) : 'Unrated'}</span>`}</div><details><summary>Race & source</summary><p>${escape(c.race_id)} · ${escape(c.party || c.affiliation)}</p><p>Rating: ${c.rating ? escape(c.rating.replace('_', ' ')) : 'Awaiting a sourced rating'}. November first-round share plus a 50-point winner bonus, multiplied by the frozen race rating.</p><p>${sourceLink(c.source.url, c.source.name)} · retrieved ${escape(c.source.retrieved_at)}</p></details></article>`).join('') : '<p class="empty">No candidates match this search.</p>';
element('.pagination', root).innerHTML = page.total ? `<button class="secondary previous" ${offset === 0 ? 'disabled' : ''}>Previous</button><span>${offset + 1}–${Math.min(offset + pageSize, page.total)} of ${page.total}</span><button class="secondary next" ${offset + pageSize >= page.total ? 'disabled' : ''}>Next</button>` : '';
click('.previous', async () => { offset = Math.max(0, offset - pageSize); await refresh(); }, root);
click('.next', async () => { offset += pageSize; await refresh(); }, root);
for (const candidate of page.items)
if (actions) {
click(`.pick-candidate[data-id="${candidate.id}"]`, async () => { await actions.pick(candidate); await refresh(); }, root);
click(`.queue-candidate[data-id="${candidate.id}"]`, async () => { await actions.queue(candidate); await refresh(); }, root);
}
}
catch (error) {
message(error);
}
}
form.addEventListener('submit', event => { event.preventDefault(); offset = 0; void refresh(); });
form.addEventListener('input', () => { offset = 0; clearTimeout(timer); timer = window.setTimeout(() => void refresh(), 250); });
signal.addEventListener('abort', () => clearTimeout(timer));
void refresh();
return refresh;
}
export function candidatesPage(signal: AbortSignal, league?: string) { element('#page').innerHTML = '<p class="eyebrow">Meet the field</p><h1>Candidates</h1><div id="browser"></div>'; candidateBrowser(element('#browser'), signal, league); }
+58
View File
@@ -0,0 +1,58 @@
import { api, query, user } from './api';
import { candidateBrowser } from './candidates';
import { click, element, escape, message } from './ui';
import type { LeagueView } from './types';
export async function draftPage(id: string, signal: AbortSignal) {
let view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a class="back" href="#league/${id}">← ${escape(view.league.name)}</a><h1>Draft room</h1>${view.league.catalog_mode === 'demo' ? '<p class="badge">Practice · fictional candidates</p>' : ''}<section class="turn panel" aria-live="polite"><div id="turn-label"></div><div class="clock" id="clock"></div><div id="draft-controls"></div></section><div class="draft-layout"><section><div id="browser"></div></section><aside><section class="panel"><h2>Your private queue</h2><p class="muted">On timeout, the first legal candidate here is picked. Otherwise, a legal candidate is chosen at random.</p><ol id="queue"></ol></section><section class="panel"><h2>Recent picks</h2><ol id="recent"></ol></section><details class="panel"><summary>All rosters</summary><div id="rosters"></div></details></aside></div>`;
let received = Date.now(), poll: number | undefined;
let revision = '';
let drawing = '';
const refreshCandidates = candidateBrowser(element('#browser'), signal, id, { legal: () => new Set(view.league.status === 'drafting' ? view.legal_candidate_ids : []), queued: () => new Set(view.my_team.queue), pick: async (c) => { await api('draft/pick', { league_id: id, candidate_id: c.id, pick_number: view.draft.pick_number, request_id: crypto.randomUUID() }); await refresh(); }, queue: async (c) => { await api('draft/queue', { league_id: id, candidates: [...view.my_team.queue, c.id] }); await refresh(); } });
function draw() {
const status = view.league.status;
const commissioner = view.league.commissioner_id === user?.id;
const picker = view.picker;
element('#turn-label').innerHTML = status === 'forming' ? `<h2>Before the first pick</h2><p>${view.readiness.ready ? 'Everyone is ready. Your commissioner can start the draft.' : view.readiness.problems.map(escape).join(' · ')}</p>` : status === 'drafted' ? '<h2>Draft complete</h2><p>Your party is ready for election night.</p>' : `<p class="eyebrow">Round ${Math.floor(view.draft.pick_number / view.teams.length) + 1} of 12 · Pick ${view.draft.pick_number + 1}</p><h2>${picker?.user_id === user?.id ? 'You’re on the clock' : escape(picker?.name) + ' is picking'}</h2>${status === 'paused' ? '<p>Draft paused. Your commissioner can resume it.</p>' : ''}`;
element('#draft-controls').innerHTML = commissioner ? (status === 'forming' ? `<button id="start" ${view.readiness.ready ? '' : 'disabled'}>Start draft</button>` : status === 'drafting' ? '<button class="secondary" id="pause">Pause draft</button>' : status === 'paused' ? '<button id="resume">Resume draft</button>' : '') : '';
for (const action of ['start', 'pause', 'resume'])
click(`#${action}`, async () => { await api(`draft/${action}`, { league_id: id }); await refresh(); });
element('#queue').innerHTML = view.my_team.queue.length ? view.my_team.queue.map((candidate, index) => `<li><span>${escape(view.candidate_names[candidate] || candidate)}</span><div><button class="quiet up" data-index="${index}" ${index === 0 ? 'disabled' : ''} aria-label="Move ${escape(view.candidate_names[candidate] || candidate)} earlier">↑</button><button class="quiet unqueue" data-index="${index}" aria-label="Remove ${escape(view.candidate_names[candidate] || candidate)} from queue">×</button></div></li>`).join('') : '<li class="muted">Add candidates from the list.</li>';
view.my_team.queue.forEach((_, index) => { click(`.up[data-index="${index}"]`, async () => { const queue = [...view.my_team.queue]; [queue[index - 1], queue[index]] = [queue[index], queue[index - 1]]; await api('draft/queue', { league_id: id, candidates: queue }); await refresh(); }); click(`.unqueue[data-index="${index}"]`, async () => { await api('draft/queue', { league_id: id, candidates: view.my_team.queue.filter((_, i) => i !== index) }); await refresh(); }); });
element('#recent').innerHTML = view.picks.slice(-6).reverse().map(p => `<li><strong>${escape(view.candidate_names[p.candidate_id] || p.candidate_id)}</strong><small>${escape(view.teams.find(t => t.id === p.team_id)?.name)} · ${escape(p.slot)}${p.automatic ? ' · auto' : ''}</small></li>`).join('') || '<li class="muted">The first pick is still ahead.</li>';
element('#rosters').innerHTML = view.teams.map(t => `<details><summary>${escape(t.name)}</summary><ul>${view.picks.filter(p => p.team_id === t.id).map(p => `<li>${escape(view.candidate_names[p.candidate_id] || p.candidate_id)}<small>${escape(p.slot)}</small></li>`).join('') || '<li>No picks yet.</li>'}</ul></details>`).join('');
}
async function refresh() {
const previousQueue = JSON.stringify(view.my_team.queue);
const next = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
view = next;
received = Date.now();
const state = JSON.stringify({ ...view, server_time: 0 });
if (state !== drawing) {
drawing = state;
draw();
}
if (revision !== view.league.status + view.picks.length || previousQueue !== JSON.stringify(view.my_team.queue)) {
revision = view.league.status + view.picks.length;
await refreshCandidates();
}
}
function clock() { if (signal.aborted)
return; const remaining = view.league.status === 'drafting' && view.draft.deadline ? Math.max(0, view.draft.deadline - view.server_time - Math.floor((Date.now() - received) / 1000)) : view.league.status === 'paused' ? view.draft.remaining : null; element('#clock').textContent = remaining == null ? '' : `${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`; }
async function loop() { try {
await refresh();
}
catch (error) {
message(error);
} if (!signal.aborted)
poll = window.setTimeout(() => void loop(), 2000); }
draw();
clock();
const ticker = window.setInterval(clock, 500);
poll = window.setTimeout(() => void loop(), 2000);
signal.addEventListener('abort', () => { clearTimeout(poll); clearInterval(ticker); });
}
+73
View File
@@ -0,0 +1,73 @@
:root { font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif; color: #213632; background: #f6f4ee; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; --muted: #596760; --line: #d6dbd2; }
* { box-sizing: border-box; }
body { margin: 0; }
a { color: inherit; text-underline-offset: 3px; }
:focus-visible { outline: 3px solid #98602c; outline-offset: 3px; }
.site-header, main, footer { width: min(1200px, calc(100% - 64px)); margin-inline: auto; }
.site-header { min-height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -.03em; }
.monogram { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid #213632; border-radius: 50%; font-family: Georgia, serif; font-size: 25px; font-weight: 400; padding-bottom: 4px; }
.edition, .eyebrow { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .13em; }
.intro { padding: 56px 0 36px; }
.eyebrow { margin: 0 0 14px; font-weight: 650; }
h1 { margin: 0; font-family: Georgia, serif; font-size: clamp(38px, 5.5vw, 70px); font-weight: 400; line-height: 1.08; letter-spacing: -.05em; }
h1 em { font-weight: 400; color: #67765e; }
.description { max-width: 600px; margin: 22px 0 0; font-size: 17px; line-height: 1.7; color: var(--muted); }
h2 { font: 400 32px Georgia, serif; margin: 0; letter-spacing: -.02em; }
p { line-height: 1.65; }
.small, .muted, small { font-size: 12px; color: var(--muted); }
.workspace { padding: 24px; background: #e9ece3; border: 1px solid var(--line); }
.workspace > label { max-width: 510px; }
label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; font-weight: 600; }
select, input { font: inherit; font-size: 14px; background: #fffefa; border: 1px solid #b3bdb0; border-radius: 4px; color: #213632; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 42px; }
select { width: 100%; }
button { font: inherit; font-size: 13px; font-weight: 600; color: #fffefa; background: #294d41; padding: 11px 17px; border: 1px solid #294d41; border-radius: 4px; cursor: pointer; min-height: 42px; }
button:hover { background: #3f6150; }
button.secondary { background: transparent; color: #294d41; }
button:disabled { opacity: .5; cursor: default; }
.notice { border-left: 3px solid #8d784f; padding: 10px 14px; background: #f6f1e4; font-size: 13px; }
.notice.real { border-color: #9b5d2d; }
.notice strong { margin-right: 6px; }
.section-nav { display: flex; flex-wrap: wrap; gap: 30px; padding: 25px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
.section-nav a { text-decoration: none; }
.panel { padding: 38px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 24px; }
.filters { display: grid; grid-template-columns: 2fr repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
.table-scroll { overflow: auto; max-height: 440px; border: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: #e9ece3; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); }
tbody tr:last-child td { border: 0; }
td strong { font-weight: 600; }
.cell-note { display: block; font-size: 11px; color: var(--muted); margin-top: 5px; }
.tag { display: inline-block; border: 1px solid var(--line); padding: 4px 7px; font-size: 11px; white-space: nowrap; border-radius: 3px; background: #eeefe7; }
details { margin-top: 22px; }
summary { cursor: pointer; font-size: 14px; padding: 8px 0; }
.capacity-controls { display: flex; align-items: end; gap: 22px; margin-top: 15px; }
.capacity-controls label { min-width: 145px; }
.warning { color: #914518; font-weight: 600; }
.actions { display: flex; gap: 10px; }
.roster-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 25px; }
fieldset { margin: 0; padding: 22px; border: 1px solid var(--line); background: #fdfcf7; min-width: 0; }
legend { font: 23px Georgia, serif; padding: 0 8px; }
fieldset label + label { margin-top: 15px; }
#roster-status { font-size: 13px; }
.formula { font: 23px Georgia, serif; line-height: 1.6; }
.rating-key { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; }
.rating-key strong { margin-left: 6px; }
.replay-controls { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: start; gap: 24px; margin-top: 25px; }
.replay-controls button { margin-top: 21px; }
input[type=range] { width: 100%; accent-color: #294d41; padding: 0; border: 0; background: transparent; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); margin: 25px 0; }
.metrics > div { background: #e9ece3; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
.metrics span { font-size: 12px; }
.metrics strong { font: 42px Georgia, serif; font-variant-numeric: tabular-nums; }
.metrics small { line-height: 1.5; }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 20px 0; font-size: 12px; }
.comparison i { display: block; height: 8px; min-width: 2px; background: #8a987b; margin-top: 10px; transition: width .3s; }
.comparison > div:last-child i { background: #294d41; }
footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; font-size: 11px; color: var(--muted); }
.loading { padding: 60px 0; }
@media (max-width: 800px) { .filters { grid-template-columns: 1fr 1fr; } .search-label { grid-column: 1 / -1; } .roster-grid { grid-template-columns: 1fr; } .replay-controls { grid-template-columns: 1fr; gap: 16px; } .replay-controls button { margin: 0; } .section-heading { align-items: start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } }
@media (max-width: 500px) { .site-header, main, footer { width: calc(100% - 32px); } .edition { max-width: 90px; text-align: right; line-height: 1.6; } .wordmark { gap: 9px; font-size: 14px; } .workspace { padding: 16px; } .section-nav { gap: 15px; font-size: 12px; } .capacity-controls { align-items: stretch; flex-direction: column; gap: 0; } footer { flex-direction: column; gap: 8px; } }
@media (prefers-reduced-motion: reduce) { .comparison i { transition: none; } }
+172
View File
@@ -0,0 +1,172 @@
import './lab.css';
type Source = { name: string; url: string; retrieved_at: string };
type Race = { id: string; office: string; state: string; name: string; round: string };
type Candidate = { id: string; race_id: string; name: string; affiliation: string; ballot_lines: string[]; source: Source };
type Document = { id: string; kind: string; mode: 'demo' | 'real'; label: string; as_of: string; source: Source; catalog_id?: string };
type Catalog = Document & { complete: boolean; races: Race[]; candidates: Candidate[] };
type Ratings = Document & { entries: { race_id: string; rating: string }[] };
type Slot = { id: string; office: string; affiliation: string };
type Rules = { slots: Slot[]; formula: string; rounding: string; multipliers: Record<string, number> };
type Capacity = { offices: { office: string; requirements: { group: string; available: number; required: number; shortfall: number }[] }[]; sufficient_candidates: boolean; catalog_complete: boolean };
type Score = { slot: string; candidate_id: string; points: number | null; reason?: string; vote_share?: number; win_bonus?: number; multiplier?: number; baseline_points?: number; leader_preview_points?: number | null; status?: string };
type Preview = { valid: boolean; complete_roster: boolean; errors: string[]; scores: Score[]; missing_scores?: number; unavailable_leader_previews?: number; known_subtotal?: number; baseline_subtotal?: number; leader_preview_subtotal?: number; total: number | null };
const root = document.querySelector<HTMLElement>('#app')!;
let documents: Document[] = [];
let rules: Rules;
let catalog: Catalog;
let ratingId = '';
let roster: Record<string, string> = {};
let step = 0;
let playing: ReturnType<typeof setInterval> | undefined;
let previewVersion = 0;
let capacityVersion = 0;
const labels: Record<string, string> = { house: 'House', senate: 'Senate', governor: 'Governor', democrat: 'Democrat', republican: 'Republican', other: 'Other', flex: 'Flex', safe: 'Safe', likely: 'Likely', lean: 'Lean', toss_up: 'Toss-up', total: 'All candidates' };
const escape = (value: unknown) => String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
const label = (value: string) => labels[value] ?? value;
const money = (value: number | null | undefined) => value == null ? '—' : value.toFixed(2);
const element = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const ratings = () => documents.filter(document => document.kind === 'ratings' && document.catalog_id === catalog.id) as Ratings[];
const results = () => documents.filter(document => document.kind === 'results' && document.catalog_id === catalog.id);
const selectedRatings = () => ratings().find(rating => rating.id === ratingId);
const raceFor = (candidate: Candidate) => catalog.races.find(race => race.id === candidate.race_id)!;
const fits = (candidate: Candidate, slot: Slot) => raceFor(candidate).office === slot.office && (slot.affiliation === 'flex' || candidate.affiliation === slot.affiliation);
const options = (items: { id: string; name: string }[], selected = '') => items.map(item => `<option value="${escape(item.id)}" ${item.id === selected ? 'selected' : ''}>${escape(item.name)}</option>`).join('');
function sourceLink(source: Source): string {
if (!/^https?:\/\//.test(source.url)) return '<span class="muted">Fictional example</span>';
return `<a href="${escape(source.url)}" target="_blank" rel="noopener noreferrer">Source ↗</a> <span class="muted">${escape(source.retrieved_at.slice(0, 10))}</span>`;
}
async function api<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`/api/${path}`, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const envelope = await response.json();
if (!response.ok || !envelope.ok) throw new Error(envelope.message ?? `Request failed (${response.status})`);
return envelope.payload as T;
}
function stopReplay() {
clearInterval(playing);
playing = undefined;
const button = document.getElementById('play');
if (button) button.textContent = 'Play replay';
}
function render() {
stopReplay();
++previewVersion;
++capacityVersion;
const snapshots = results();
root.innerHTML = `
<section class="intro"><p class="eyebrow">The draft starts with a little homework.</p><h1>Build your party.<br><em>Make every race count.</em></h1><p class="description">Try your 12 picks, explore the field, and replay a fictional election night. A game among friends, taking shape for 2026.</p></section>
<section class="workspace" aria-label="Data selection"><label>Candidate catalog<select id="dataset">${options(documents.filter(item => item.kind === 'catalog').map(item => ({ id: item.id, name: item.label })), catalog.id)}</select></label><p class="notice ${catalog.mode}"><strong>${catalog.mode === 'demo' ? 'SYNTHETIC DEMO' : catalog.complete ? 'REAL CATALOG' : 'PARTIAL REAL CATALOG'}</strong> ${catalog.mode === 'demo' ? 'Every candidate, rating, vote and call in this dataset is fictional.' : catalog.complete ? 'Candidate coverage is marked complete by its curator. Ratings and live coverage still need verification before a draft.' : 'Candidate coverage is incomplete. Verify the remaining candidates, real ratings and live results before a draft.'}</p><p class="small">Catalog dated ${escape(catalog.as_of)} · ${sourceLink(catalog.source)}</p></section>
<nav class="section-nav" aria-label="Prototype sections"><a href="#field">01 / The field</a><a href="#party">02 / Your party</a><a href="#night">03 / Election night</a></nav>
<section id="field" class="panel"><div class="section-heading"><div><p class="eyebrow">01 / Scout your picks</p><h2>The field</h2></div><span id="candidate-count" class="small"></span></div>
<div class="filters"><label class="search-label">Search candidates<input id="search" type="search" placeholder="Name or race"></label>${['office', 'affiliation', 'state', 'rating'].map(filter => `<label>${filter === 'affiliation' ? 'Party' : label(filter)}<select id="filter-${filter}"><option value="">All ${filter === 'affiliation' ? 'parties' : filter + 's'}</option>${filter === 'state' ? options([...new Set(catalog.races.map(race => race.state))].sort().map(state => ({ id: state, name: state }))) : options((filter === 'office' ? ['house', 'senate', 'governor'] : filter === 'affiliation' ? ['democrat', 'republican', 'other'] : ['safe', 'likely', 'lean', 'toss_up', 'unrated']).map(id => ({ id, name: label(id) })))}</select></label>`).join('')}</div>
<div class="table-scroll"><table><thead><tr><th>Candidate</th><th>Race</th><th>Party / ballot lines</th><th>Frozen rating</th><th>Provenance</th></tr></thead><tbody id="candidates"></tbody></table></div>
<details><summary>Can everyone fill a roster?</summary><div class="capacity-controls"><label>League size<select id="players">${options(Array.from({ length: 11 }, (_, index) => ({ id: String(index + 2), name: `${index + 2} players` })), '12')}</select></label><p class="small">Each office needs one D, one R, one Other and one Flex per player. Counts include all race ratings.</p></div><div id="capacity" aria-live="polite"></div></details>
</section>
<section id="party" class="panel"><div class="section-heading"><div><p class="eyebrow">02 / Twelve picks. Three offices.</p><h2>Your party</h2></div><div class="actions">${catalog.mode === 'demo' ? '<button id="fill">Try an example roster</button>' : ''}<button id="clear" class="secondary">Clear picks</button></div></div><p class="small">One Democrat, one Republican, one Other and one Flex in each office. Other includes independent and third-party candidates. Flex accepts any party in that office. These picks stay in this page only.</p><div class="roster-grid">${['house', 'senate', 'governor'].map(office => `<fieldset><legend>${label(office)}</legend>${rules.slots.filter(slot => slot.office === office).map(slot => `<label>${label(slot.affiliation)}<select data-slot="${escape(slot.id)}" aria-label="${label(office)} ${label(slot.affiliation)}"></select></label>`).join('')}</fieldset>`).join('')}</div><p id="roster-status" role="status"></p></section>
<section id="night" class="panel"><div class="section-heading"><div><p class="eyebrow">03 / Follow the swings</p><h2>Election night, on replay.</h2></div><span class="tag">${catalog.mode === 'demo' ? 'Fictional results' : 'No verified live feed'}</span></div><p class="formula">${escape(rules.formula)}</p><div class="rating-key">${Object.entries(rules.multipliers).map(([rating, factor]) => `<span>${label(rating)} <strong>×${factor.toFixed(2)}</strong></span>`).join('')}</div><p class="small">${escape(rules.rounding)} Ratings are selected as a single snapshot here; a real league will freeze one before the draft. A lead is not a win. Corrections can reverse awarded points.</p><div class="replay-controls"><label>Rating snapshot<select id="rating-snapshot" ${ratings().length ? '' : 'disabled'}>${ratings().length ? options(ratings().map(item => ({ id: item.id, name: item.label })), ratingId) : '<option>No ratings imported</option>'}</select></label><label class="timeline">Result snapshot<input id="timeline" type="range" min="0" max="${Math.max(0, snapshots.length - 1)}" value="${step}" ${snapshots.length ? '' : 'disabled'}><span id="snapshot-label" class="small"></span></label><button id="play" ${snapshots.length > 1 ? '' : 'disabled'}>Play replay</button></div><p id="snapshot-source" class="small"></p><div id="preview" aria-live="polite"></div></section>`;
element<HTMLSelectElement>('dataset').onchange = event => {
catalog = documents.find(document => document.id === (event.target as HTMLSelectElement).value) as Catalog;
roster = {};
ratingId = ratings()[0]?.id ?? '';
step = 0;
render();
};
for (const id of ['search', 'filter-office', 'filter-affiliation', 'filter-state', 'filter-rating']) element(id).oninput = renderCandidates;
element('players').onchange = () => void renderCapacity();
element('clear').onclick = () => { roster = {}; renderRoster(); void renderPreview(); };
const fill = document.getElementById('fill');
if (fill) fill.onclick = () => {
roster = {};
for (const slot of rules.slots) {
const eligible = catalog.candidates.filter(candidate => fits(candidate, slot) && !Object.values(roster).includes(candidate.id));
const preferredRatings: Record<string, string> = { democrat: 'toss_up', republican: 'safe', other: 'likely', flex: 'lean' };
const preferredRating = preferredRatings[slot.affiliation];
const match = eligible.find(candidate => selectedRatings()?.entries.some(entry => entry.race_id === candidate.race_id && entry.rating === preferredRating)) ?? eligible[0];
if (match) roster[slot.id] = match.id;
}
renderRoster();
void renderPreview();
};
element('rating-snapshot').onchange = event => { ratingId = (event.target as HTMLSelectElement).value; renderCandidates(); void renderPreview(); };
element('timeline').oninput = event => { stopReplay(); step = Number((event.target as HTMLInputElement).value); void renderPreview(); };
element('play').onclick = () => {
if (playing) { stopReplay(); return; }
if (step === snapshots.length - 1) step = 0;
element('play').textContent = 'Pause replay';
void renderPreview();
playing = setInterval(() => {
step += 1;
void renderPreview();
if (step === snapshots.length - 1) stopReplay();
}, 1800);
};
renderCandidates();
renderRoster();
void renderCapacity();
void renderPreview();
}
function renderCandidates() {
const search = element<HTMLInputElement>('search').value.toLowerCase();
const filters = ['office', 'affiliation', 'state', 'rating'].map(filter => element<HTMLSelectElement>(`filter-${filter}`).value);
const ratingEntries = selectedRatings()?.entries ?? [];
const candidates = catalog.candidates.filter(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating ?? 'unrated';
return `${candidate.name} ${race.name}`.toLowerCase().includes(search) && [race.office, candidate.affiliation, race.state, rating].every((value, index) => !filters[index] || filters[index] === value);
});
element('candidate-count').textContent = `${candidates.length} of ${catalog.candidates.length} candidates`;
element('candidates').innerHTML = candidates.map(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating;
return `<tr><td><strong>${escape(candidate.name)}</strong></td><td>${escape(race.name)}<span class="cell-note">${escape(race.state)} · ${escape(label(race.office))}</span></td><td>${escape(label(candidate.affiliation))}<span class="cell-note">${escape(candidate.ballot_lines.join(' / '))}</span></td><td><span class="tag">${rating ? `${escape(label(rating))} ×${rules.multipliers[rating].toFixed(2)}` : 'Missing rating'}</span></td><td class="small">${sourceLink(candidate.source)}</td></tr>`;
}).join('') || '<tr><td colspan="5">No candidates match these filters.</td></tr>';
}
function renderRoster() {
for (const slot of rules.slots) {
const select = root.querySelector<HTMLSelectElement>(`[data-slot="${slot.id}"]`)!;
select.innerHTML = '<option value="">Choose a candidate</option>' + catalog.candidates.filter(candidate => fits(candidate, slot)).map(candidate => `<option value="${escape(candidate.id)}" ${roster[slot.id] === candidate.id ? 'selected' : ''} ${Object.entries(roster).some(([id, pick]) => id !== slot.id && pick === candidate.id) ? 'disabled' : ''}>${escape(candidate.name)} · ${escape(raceFor(candidate).state)}</option>`).join('');
select.onchange = () => { roster[slot.id] = select.value; renderRoster(); void renderPreview(); };
}
const filled = Object.values(roster).filter(Boolean).length;
element('roster-status').textContent = `${filled} / 12 slots filled${filled === 12 ? ' · Ready to simulate.' : ' · Fill each slot to calculate a complete total.'}`;
}
async function renderCapacity() {
const version = ++capacityVersion;
try {
const result = await api<Capacity>('capacity', { catalog_id: catalog.id, players: Number(element<HTMLSelectElement>('players').value) });
if (version !== capacityVersion) return;
element('capacity').innerHTML = `<p class="notice"><strong>${result.sufficient_candidates ? 'Enough candidates in this catalog.' : 'Not enough candidates in this catalog.'}</strong> ${!result.catalog_complete ? 'This is a partial catalog; these counts do not establish nationwide feasibility.' : catalog.mode === 'demo' ? 'Synthetic capacity only; this does not establish real-world feasibility.' : 'Ratings and results coverage still require separate verification.'}</p><div class="table-scroll"><table><thead><tr><th>Office</th><th>Group</th><th>Available</th><th>Needed</th><th>Shortfall</th></tr></thead><tbody>${result.offices.flatMap(office => office.requirements.map(group => `<tr><td>${label(office.office)}</td><td>${label(group.group)}</td><td>${group.available}</td><td>${group.required}</td><td class="${group.shortfall ? 'warning' : ''}">${group.shortfall}</td></tr>`)).join('')}</tbody></table></div>`;
} catch (error) { if (version === capacityVersion) element('capacity').textContent = String(error); }
}
async function renderPreview() {
const version = ++previewVersion;
const snapshot = results()[step];
element<HTMLInputElement>('timeline').value = String(step);
element('snapshot-label').textContent = snapshot ? `${snapshot.label} · ${snapshot.as_of}` : 'No result snapshots imported';
element('snapshot-source').innerHTML = snapshot ? `Results: ${sourceLink(snapshot.source)}${selectedRatings() ? ` · Ratings: ${sourceLink(selectedRatings()!.source)}` : ''}` : '';
if (!snapshot || !ratingId) { element('preview').innerHTML = '<p class="notice">Scoring is unavailable until both ratings and results are imported for this catalog. Missing data is not treated as zero.</p>'; return; }
element('preview').innerHTML = '<p role="status">Calculating…</p>';
try {
const preview = await api<Preview>('score-preview', { catalog_id: catalog.id, ratings_id: ratingId, results_id: snapshot.id, roster: Object.entries(roster).filter(([, id]) => id).map(([slot, candidate_id]) => ({ slot, candidate_id })) });
if (version !== previewVersion) return;
if (!preview.valid) { element('preview').innerHTML = `<p class="notice warning">${preview.errors.map(escape).join('<br>')}</p>`; return; }
const max = Math.max(preview.known_subtotal ?? 0, preview.baseline_subtotal ?? 0, 1);
element('preview').innerHTML = `<div class="metrics"><div><span>Complete roster total</span><strong>${money(preview.total)}</strong><small>${preview.total === null ? 'Needs 12 picks with available scores' : 'Counted shares + awarded bonuses'}</small></div><div><span>Known subtotal</span><strong>${money(preview.known_subtotal)}</strong><small>${preview.missing_scores ?? 0} selected scores unavailable</small></div><div><span>If current leaders win</span><strong>${money(preview.leader_preview_subtotal)}</strong><small>${preview.unavailable_leader_previews ?? 0} previews unavailable · not awarded points</small></div></div><div class="comparison" aria-label="Scoring comparison"><div><span>No multipliers ${money(preview.baseline_subtotal)}</span><i style="width:${(preview.baseline_subtotal ?? 0) / max * 100}%"></i></div><div><span>Race-weighted ${money(preview.known_subtotal)}</span><i style="width:${(preview.known_subtotal ?? 0) / max * 100}%"></i></div></div><div class="table-scroll"><table><thead><tr><th>Pick</th><th>Vote share</th><th>Win bonus</th><th>Multiplier</th><th>Points</th><th>If leader wins</th></tr></thead><tbody>${preview.scores.map(score => `<tr><td><strong>${escape(catalog.candidates.find(candidate => candidate.id === score.candidate_id)?.name)}</strong><span class="cell-note">${escape(score.slot.split(':').map(label).join(' / '))} · ${escape(score.reason ?? score.status)}</span></td><td>${score.vote_share === undefined ? '—' : `${score.vote_share.toFixed(2)}%`}</td><td>${score.win_bonus ?? '—'}</td><td>${score.multiplier === undefined ? '—' : `×${score.multiplier.toFixed(2)}`}</td><td><strong>${money(score.points)}</strong></td><td>${money(score.leader_preview_points)}</td></tr>`).join('') || '<tr><td colspan="6">Choose candidates above to see their points.</td></tr>'}</tbody></table></div>`;
} catch (error) { if (version === previewVersion) element('preview').textContent = String(error); }
}
async function start() {
try {
const [ruleData, catalogData] = await Promise.all([api<Rules>('rules'), api<{ documents: Document[] }>('catalog')]);
rules = ruleData;
documents = catalogData.documents;
catalog = documents.find(document => document.kind === 'catalog' && document.mode === 'demo') as Catalog ?? documents.find(document => document.kind === 'catalog') as Catalog;
if (!catalog) { root.innerHTML = '<section class="intro"><h1>The field is empty.</h1><p>Import a catalog with the local admin tool to start exploring. Setup instructions are in the repository README.</p></section>'; return; }
ratingId = ratings()[0]?.id ?? '';
render();
} catch (error) {
root.innerHTML = `<section class="intro"><h1>Unable to load the league.</h1><p role="alert">${escape(error)}</p><p>Check that the C++ backend is running, then try again.</p><button id="retry">Retry</button></section>`;
element('retry').onclick = () => void start();
}
}
void start();
+55
View File
@@ -0,0 +1,55 @@
import { api, query, user } from './api';
import { click, element, escape, field, go, submit } from './ui';
import type { Catalogs, League, LeagueView } from './types';
export async function leaguesPage(signal: AbortSignal) {
const leagues = await api<League[]>('leagues', undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<div class="page-heading"><div><p class="eyebrow">2026 midterms</p><h1>My leagues</h1></div><div class="actions"><a class="button secondary" href="#join">Join a league</a><a class="button" href="#create">Create a league</a></div></div>${leagues.length ? `<div class="league-grid">${leagues.map(l => `<a class="panel league-card" href="#league/${l.id}"><span class="badge">${l.catalog_mode === 'demo' ? 'Practice · ' : ''}${escape(l.status)}</span><h2>${escape(l.name)}</h2><p>${escape(l.team_name)}</p><span class="muted">${l.members} / ${l.max_teams} teams</span><span class="arrow" aria-hidden="true">↗</span></a>`).join('')}</div>` : '<section class="empty"><h2>Start with your people.</h2><p>Create a private league, then invite your friends. You can get everyone together while election data is being prepared.</p></section>'}`;
}
export async function createPage(signal: AbortSignal) {
const catalogs = await api<Catalogs>('catalogs', undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><h1>Create a league</h1><section class="panel narrow"><form id="create">${field('League name', 'name', '', 'maxlength="80"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<label>Maximum teams<select name="max_teams">${Array.from({ length: 11 }, (_, i) => `<option ${i === 6 ? 'selected' : ''}>${i + 2}</option>`).join('')}</select></label>${catalogs.allow_demo ? '<label>Candidate pool<select name="catalog_id"><option value="">Latest real candidates</option>' + catalogs.catalogs.filter(c => c.mode === 'demo').map(c => `<option value="${escape(c.id)}">Practice · ${escape(c.label)}</option>`).join('') + '</select></label>' : ''}<p class="muted">Private by default. Twelve picks per team. The draft opens when the candidate pool, ratings, and result sources are ready.</p><button type="submit">Create league</button></form></section>`;
submit('#create', async (data) => { const result = await api<{
league_id: string;
}>('league/create', { name: data.get('name'), team_name: data.get('team_name'), max_teams: Number(data.get('max_teams')), catalog_id: data.get('catalog_id') || null }); go(`league/${result.league_id}`); });
}
export function joinPage(invite = '') {
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><h1>Join a league</h1><section class="panel narrow"><form id="join">${field('Invitation link or code', 'invite', invite, 'autocomplete="off"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<button type="submit">Join league</button></form></section>`;
submit('#join', async (data) => { const code = String(data.get('invite')).split('#join/').pop()!.trim(); const result = await api<{
league_id: string;
}>('league/join', { invite: code, team_name: data.get('team_name') }); go(`league/${result.league_id}`); });
}
export async function leaguePage(id: string, signal: AbortSignal) {
const view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
const l = view.league, commissioner = l.commissioner_id === user?.id;
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><div class="page-heading"><div><p class="eyebrow">${l.catalog_mode === 'demo' ? 'Practice · fictional candidates · ' : ''}${escape(l.status)}</p><h1>${escape(l.name)}</h1></div><a class="button" href="#draft/${id}">${l.status === 'forming' ? 'Draft room' : l.status === 'drafted' ? 'View draft' : 'Enter draft'}</a></div><nav class="subnav"><a href="#roster/${id}">My roster</a><a href="#standings/${id}">Standings</a><a href="#results/${id}">Race results</a></nav>${l.status === 'forming' && !view.readiness.ready ? `<aside class="notice"><strong>Getting ready to draft</strong><ul>${view.readiness.problems.map(p => `<li>${escape(p)}</li>`).join('')}</ul></aside>` : ''}<section class="panel"><div class="page-heading"><h2>Teams <span class="muted">${view.teams.length} / ${l.max_teams}</span></h2>${commissioner && l.status === 'forming' ? '<button id="invite">Invite friends</button>' : ''}</div><div id="invite-link"></div><ul class="team-list">${view.teams.map(t => `<li><span><strong>${escape(t.name)}</strong><small>${escape(t.username)}${t.user_id === l.commissioner_id ? ' · commissioner' : ''}</small></span>${commissioner && t.user_id !== user?.id && l.status === 'forming' ? `<button class="quiet remove" data-team="${t.id}">Remove</button>` : ''}</li>`).join('')}</ul></section><details class="panel"><summary>Team settings</summary><form id="rename">${field('Team name', 'name', view.my_team.name, 'maxlength="60"')}<button type="submit">Save team name</button></form></details>${commissioner ? `<details class="panel"><summary>League name</summary><form id="league-name">${field('League name','name',l.name,'maxlength="80"')}<button type="submit">Save league name</button></form></details>` : ''}${commissioner && l.status === 'forming' ? '<details class="panel" id="commissioner"><summary>Commissioner settings</summary><div id="settings">Loading settings…</div><form id="ratings-import"><label>Import sourced ratings (JSON)<input name="ratings" type="file" accept=".json,application/json" required></label><p class="muted">Use the ratings schema in the data contract. Include a source URL, retrieval date, and a new snapshot ID.</p><button type="submit">Import ratings</button></form></details>' : ''}`;
const reload = () => leaguePage(id, signal);
if (commissioner) submit('#league-name', async data => { await api('league/name', {league_id:id, name:data.get('name')}); await reload(); });
submit('#rename', async (data) => { await api('team/name', { league_id: id, name: data.get('name') }); await reload(); });
click('#invite', async () => { const result = await api<{
invite: string;
}>('league/invite', { league_id: id }); const url = `${location.origin}${location.pathname}#join/${result.invite}`; element('#invite-link').innerHTML = `<label>Share this private link · expires in 7 days<input readonly value="${escape(url)}" aria-label="Invitation link"></label><p class="muted">Creating another invitation replaces this one.</p>`; });
document.querySelectorAll<HTMLButtonElement>('.remove').forEach(button => click(`[data-team="${button.dataset.team}"]`, async () => { await api('league/remove', { league_id: id, team_id: button.dataset.team }); await reload(); }));
if (commissioner && l.status === 'forming') {
submit('#ratings-import', async (form) => { const file = form.get('ratings'); if (!(file instanceof File) || file.size > 60000)
throw new Error('Choose a ratings JSON file under 60 KB.'); const document = JSON.parse(await file.text()); await api('league/ratings', { league_id: id, document }); await reload(); });
const data = await api<Catalogs>('catalogs', undefined, signal);
if (signal.aborted)
return;
element('#settings').innerHTML = `<form id="settings-form">${field('League name', 'name', l.name, 'maxlength="80"')}<label>Candidate catalog<select name="catalog_id" required>${data.catalogs.map(c => `<option value="${escape(c.id)}" ${c.id === l.catalog_id ? 'selected' : ''}>${escape(c.label)}</option>`).join('')}</select></label><label>Race ratings<select name="ratings_id"><option value="">Awaiting sourced ratings</option>${data.ratings.map(r => `<option value="${escape(r.id)}" data-catalog="${escape(r.catalog_id)}" ${r.id === l.ratings_id ? 'selected' : ''}>${escape(r.label)}</option>`).join('')}</select></label><p class="muted">Catalog and ratings freeze when the draft starts. Changing these settings clears player queues.</p><button type="submit">Save league settings</button></form><button class="quiet" id="revoke">Revoke invitations</button>`;
const filterRatings = () => { const catalog = element<HTMLSelectElement>('[name=catalog_id]').value; const ratings = element<HTMLSelectElement>('[name=ratings_id]'); for (const option of ratings.options) {
option.hidden = !!option.dataset.catalog && option.dataset.catalog !== catalog;
if (option.selected && option.hidden)
ratings.value = '';
} };
element('[name=catalog_id]').addEventListener('change', filterRatings);
filterRatings();
submit('#settings-form', async (form) => { await api('league/settings', { league_id: id, ...Object.fromEntries(form), ratings_id: form.get('ratings_id') || null }); await reload(); });
click('#revoke', async () => { await api('league/revoke-invites', { league_id: id }); element('#invite-link').textContent = 'Invitations revoked.'; });
}
}
+50 -165
View File
@@ -1,172 +1,57 @@
import './style.css';
type Source = { name: string; url: string; retrieved_at: string };
type Race = { id: string; office: string; state: string; name: string; round: string };
type Candidate = { id: string; race_id: string; name: string; affiliation: string; ballot_lines: string[]; source: Source };
type Document = { id: string; kind: string; mode: 'demo' | 'real'; label: string; as_of: string; source: Source; catalog_id?: string };
type Catalog = Document & { complete: boolean; races: Race[]; candidates: Candidate[] };
type Ratings = Document & { entries: { race_id: string; rating: string }[] };
type Slot = { id: string; office: string; affiliation: string };
type Rules = { slots: Slot[]; formula: string; rounding: string; multipliers: Record<string, number> };
type Capacity = { offices: { office: string; requirements: { group: string; available: number; required: number; shortfall: number }[] }[]; sufficient_candidates: boolean; catalog_complete: boolean };
type Score = { slot: string; candidate_id: string; points: number | null; reason?: string; vote_share?: number; win_bonus?: number; multiplier?: number; baseline_points?: number; leader_preview_points?: number | null; status?: string };
type Preview = { valid: boolean; complete_roster: boolean; errors: string[]; scores: Score[]; missing_scores?: number; unavailable_leader_previews?: number; known_subtotal?: number; baseline_subtotal?: number; leader_preview_subtotal?: number; total: number | null };
const root = document.querySelector<HTMLElement>('#app')!;
let documents: Document[] = [];
let rules: Rules;
let catalog: Catalog;
let ratingId = '';
let roster: Record<string, string> = {};
let step = 0;
let playing: ReturnType<typeof setInterval> | undefined;
let previewVersion = 0;
let capacityVersion = 0;
const labels: Record<string, string> = { house: 'House', senate: 'Senate', governor: 'Governor', democrat: 'Democrat', republican: 'Republican', other: 'Other', flex: 'Flex', safe: 'Safe', likely: 'Likely', lean: 'Lean', toss_up: 'Toss-up', total: 'All candidates' };
const escape = (value: unknown) => String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
const label = (value: string) => labels[value] ?? value;
const money = (value: number | null | undefined) => value == null ? '—' : value.toFixed(2);
const element = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const ratings = () => documents.filter(document => document.kind === 'ratings' && document.catalog_id === catalog.id) as Ratings[];
const results = () => documents.filter(document => document.kind === 'results' && document.catalog_id === catalog.id);
const selectedRatings = () => ratings().find(rating => rating.id === ratingId);
const raceFor = (candidate: Candidate) => catalog.races.find(race => race.id === candidate.race_id)!;
const fits = (candidate: Candidate, slot: Slot) => raceFor(candidate).office === slot.office && (slot.affiliation === 'flex' || candidate.affiliation === slot.affiliation);
const options = (items: { id: string; name: string }[], selected = '') => items.map(item => `<option value="${escape(item.id)}" ${item.id === selected ? 'selected' : ''}>${escape(item.name)}</option>`).join('');
function sourceLink(source: Source): string {
if (!/^https?:\/\//.test(source.url)) return '<span class="muted">Fictional example</span>';
return `<a href="${escape(source.url)}" target="_blank" rel="noopener noreferrer">Source ↗</a> <span class="muted">${escape(source.retrieved_at.slice(0, 10))}</span>`;
}
async function api<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`/api/${path}`, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const envelope = await response.json();
if (!response.ok || !envelope.ok) throw new Error(envelope.message ?? `Request failed (${response.status})`);
return envelope.payload as T;
}
function stopReplay() {
clearInterval(playing);
playing = undefined;
const button = document.getElementById('play');
if (button) button.textContent = 'Play replay';
}
function render() {
stopReplay();
++previewVersion;
++capacityVersion;
const snapshots = results();
root.innerHTML = `
<section class="intro"><p class="eyebrow">The draft starts with a little homework.</p><h1>Build your party.<br><em>Make every race count.</em></h1><p class="description">Try your 12 picks, explore the field, and replay a fictional election night. A game among friends, taking shape for 2026.</p></section>
<section class="workspace" aria-label="Data selection"><label>Candidate catalog<select id="dataset">${options(documents.filter(item => item.kind === 'catalog').map(item => ({ id: item.id, name: item.label })), catalog.id)}</select></label><p class="notice ${catalog.mode}"><strong>${catalog.mode === 'demo' ? 'SYNTHETIC DEMO' : catalog.complete ? 'REAL CATALOG' : 'PARTIAL REAL CATALOG'}</strong> ${catalog.mode === 'demo' ? 'Every candidate, rating, vote and call in this dataset is fictional.' : catalog.complete ? 'Candidate coverage is marked complete by its curator. Ratings and live coverage still need verification before a draft.' : 'Candidate coverage is incomplete. Verify the remaining candidates, real ratings and live results before a draft.'}</p><p class="small">Catalog dated ${escape(catalog.as_of)} · ${sourceLink(catalog.source)}</p></section>
<nav class="section-nav" aria-label="Prototype sections"><a href="#field">01 / The field</a><a href="#party">02 / Your party</a><a href="#night">03 / Election night</a></nav>
<section id="field" class="panel"><div class="section-heading"><div><p class="eyebrow">01 / Scout your picks</p><h2>The field</h2></div><span id="candidate-count" class="small"></span></div>
<div class="filters"><label class="search-label">Search candidates<input id="search" type="search" placeholder="Name or race"></label>${['office', 'affiliation', 'state', 'rating'].map(filter => `<label>${filter === 'affiliation' ? 'Party' : label(filter)}<select id="filter-${filter}"><option value="">All ${filter === 'affiliation' ? 'parties' : filter + 's'}</option>${filter === 'state' ? options([...new Set(catalog.races.map(race => race.state))].sort().map(state => ({ id: state, name: state }))) : options((filter === 'office' ? ['house', 'senate', 'governor'] : filter === 'affiliation' ? ['democrat', 'republican', 'other'] : ['safe', 'likely', 'lean', 'toss_up', 'unrated']).map(id => ({ id, name: label(id) })))}</select></label>`).join('')}</div>
<div class="table-scroll"><table><thead><tr><th>Candidate</th><th>Race</th><th>Party / ballot lines</th><th>Frozen rating</th><th>Provenance</th></tr></thead><tbody id="candidates"></tbody></table></div>
<details><summary>Can everyone fill a roster?</summary><div class="capacity-controls"><label>League size<select id="players">${options(Array.from({ length: 11 }, (_, index) => ({ id: String(index + 2), name: `${index + 2} players` })), '12')}</select></label><p class="small">Each office needs one D, one R, one Other and one Flex per player. Counts include all race ratings.</p></div><div id="capacity" aria-live="polite"></div></details>
</section>
<section id="party" class="panel"><div class="section-heading"><div><p class="eyebrow">02 / Twelve picks. Three offices.</p><h2>Your party</h2></div><div class="actions">${catalog.mode === 'demo' ? '<button id="fill">Try an example roster</button>' : ''}<button id="clear" class="secondary">Clear picks</button></div></div><p class="small">One Democrat, one Republican, one Other and one Flex in each office. Other includes independent and third-party candidates. Flex accepts any party in that office. These picks stay in this page only.</p><div class="roster-grid">${['house', 'senate', 'governor'].map(office => `<fieldset><legend>${label(office)}</legend>${rules.slots.filter(slot => slot.office === office).map(slot => `<label>${label(slot.affiliation)}<select data-slot="${escape(slot.id)}" aria-label="${label(office)} ${label(slot.affiliation)}"></select></label>`).join('')}</fieldset>`).join('')}</div><p id="roster-status" role="status"></p></section>
<section id="night" class="panel"><div class="section-heading"><div><p class="eyebrow">03 / Follow the swings</p><h2>Election night, on replay.</h2></div><span class="tag">${catalog.mode === 'demo' ? 'Fictional results' : 'No verified live feed'}</span></div><p class="formula">${escape(rules.formula)}</p><div class="rating-key">${Object.entries(rules.multipliers).map(([rating, factor]) => `<span>${label(rating)} <strong>×${factor.toFixed(2)}</strong></span>`).join('')}</div><p class="small">${escape(rules.rounding)} Ratings are selected as a single snapshot here; a real league will freeze one before the draft. A lead is not a win. Corrections can reverse awarded points.</p><div class="replay-controls"><label>Rating snapshot<select id="rating-snapshot" ${ratings().length ? '' : 'disabled'}>${ratings().length ? options(ratings().map(item => ({ id: item.id, name: item.label })), ratingId) : '<option>No ratings imported</option>'}</select></label><label class="timeline">Result snapshot<input id="timeline" type="range" min="0" max="${Math.max(0, snapshots.length - 1)}" value="${step}" ${snapshots.length ? '' : 'disabled'}><span id="snapshot-label" class="small"></span></label><button id="play" ${snapshots.length > 1 ? '' : 'disabled'}>Play replay</button></div><p id="snapshot-source" class="small"></p><div id="preview" aria-live="polite"></div></section>`;
element<HTMLSelectElement>('dataset').onchange = event => {
catalog = documents.find(document => document.id === (event.target as HTMLSelectElement).value) as Catalog;
roster = {};
ratingId = ratings()[0]?.id ?? '';
step = 0;
render();
};
for (const id of ['search', 'filter-office', 'filter-affiliation', 'filter-state', 'filter-rating']) element(id).oninput = renderCandidates;
element('players').onchange = () => void renderCapacity();
element('clear').onclick = () => { roster = {}; renderRoster(); void renderPreview(); };
const fill = document.getElementById('fill');
if (fill) fill.onclick = () => {
roster = {};
for (const slot of rules.slots) {
const eligible = catalog.candidates.filter(candidate => fits(candidate, slot) && !Object.values(roster).includes(candidate.id));
const preferredRatings: Record<string, string> = { democrat: 'toss_up', republican: 'safe', other: 'likely', flex: 'lean' };
const preferredRating = preferredRatings[slot.affiliation];
const match = eligible.find(candidate => selectedRatings()?.entries.some(entry => entry.race_id === candidate.race_id && entry.rating === preferredRating)) ?? eligible[0];
if (match) roster[slot.id] = match.id;
}
renderRoster();
void renderPreview();
};
element('rating-snapshot').onchange = event => { ratingId = (event.target as HTMLSelectElement).value; renderCandidates(); void renderPreview(); };
element('timeline').oninput = event => { stopReplay(); step = Number((event.target as HTMLInputElement).value); void renderPreview(); };
element('play').onclick = () => {
if (playing) { stopReplay(); return; }
if (step === snapshots.length - 1) step = 0;
element('play').textContent = 'Pause replay';
void renderPreview();
playing = setInterval(() => {
step += 1;
void renderPreview();
if (step === snapshots.length - 1) stopReplay();
}, 1800);
};
renderCandidates();
renderRoster();
void renderCapacity();
void renderPreview();
}
function renderCandidates() {
const search = element<HTMLInputElement>('search').value.toLowerCase();
const filters = ['office', 'affiliation', 'state', 'rating'].map(filter => element<HTMLSelectElement>(`filter-${filter}`).value);
const ratingEntries = selectedRatings()?.entries ?? [];
const candidates = catalog.candidates.filter(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating ?? 'unrated';
return `${candidate.name} ${race.name}`.toLowerCase().includes(search) && [race.office, candidate.affiliation, race.state, rating].every((value, index) => !filters[index] || filters[index] === value);
});
element('candidate-count').textContent = `${candidates.length} of ${catalog.candidates.length} candidates`;
element('candidates').innerHTML = candidates.map(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating;
return `<tr><td><strong>${escape(candidate.name)}</strong></td><td>${escape(race.name)}<span class="cell-note">${escape(race.state)} · ${escape(label(race.office))}</span></td><td>${escape(label(candidate.affiliation))}<span class="cell-note">${escape(candidate.ballot_lines.join(' / '))}</span></td><td><span class="tag">${rating ? `${escape(label(rating))} ×${rules.multipliers[rating].toFixed(2)}` : 'Missing rating'}</span></td><td class="small">${sourceLink(candidate.source)}</td></tr>`;
}).join('') || '<tr><td colspan="5">No candidates match these filters.</td></tr>';
}
function renderRoster() {
for (const slot of rules.slots) {
const select = root.querySelector<HTMLSelectElement>(`[data-slot="${slot.id}"]`)!;
select.innerHTML = '<option value="">Choose a candidate</option>' + catalog.candidates.filter(candidate => fits(candidate, slot)).map(candidate => `<option value="${escape(candidate.id)}" ${roster[slot.id] === candidate.id ? 'selected' : ''} ${Object.entries(roster).some(([id, pick]) => id !== slot.id && pick === candidate.id) ? 'disabled' : ''}>${escape(candidate.name)} · ${escape(raceFor(candidate).state)}</option>`).join('');
select.onchange = () => { roster[slot.id] = select.value; renderRoster(); void renderPreview(); };
}
const filled = Object.values(roster).filter(Boolean).length;
element('roster-status').textContent = `${filled} / 12 slots filled${filled === 12 ? ' · Ready to simulate.' : ' · Fill each slot to calculate a complete total.'}`;
}
async function renderCapacity() {
const version = ++capacityVersion;
import { api, beginPage, setUser, user } from './api';
import type { User } from './api';
import { authPage, accountPage } from './auth';
import { createPage, joinPage, leaguePage, leaguesPage } from './leagues';
import { candidatesPage } from './candidates';
import { draftPage } from './draft';
import { resultsPage, standingsPage } from './results';
import { adminPage } from './admin';
import { click, element, escape, message } from './ui';
function shell(active: string) { element('#app').innerHTML = `<header class="site-header"><a class="wordmark" href="#leagues"><span class="monogram">fp.</span><span>Fantasy Politics</span></a>${user ? `<nav aria-label="Main navigation">${['leagues', 'candidates', 'results'].map(p => `<a href="#${p}" ${active === p ? 'aria-current="page"' : ''}>${p[0].toUpperCase() + p.slice(1)}</a>`).join('')}</nav><details class="account-menu"><summary>${escape(user.username)}</summary><div><a href="#account">Account</a>${user.admin ? '<a href="#admin">Source diagnostics</a>' : ''}<button class="quiet" id="logout">Sign out</button></div></details>` : '<span class="edition">The 2026 edition</span>'}</header><main><div id="notice" role="alert" class="notice error" hidden></div><div id="page"><p class="loading" role="status">Loading…</p></div></main><footer><span>A game among friends.</span><span>2026 US midterms</span></footer>`; click('#logout', async () => { await api('auth/logout', {}); setUser(null); await render(); }); }
async function render() {
const signal = beginPage();
const path = location.hash.slice(1).split('/');
const page = path[0] || 'leagues';
shell(page);
try {
const result = await api<Capacity>('capacity', { catalog_id: catalog.id, players: Number(element<HTMLSelectElement>('players').value) });
if (version !== capacityVersion) return;
element('capacity').innerHTML = `<p class="notice"><strong>${result.sufficient_candidates ? 'Enough candidates in this catalog.' : 'Not enough candidates in this catalog.'}</strong> ${!result.catalog_complete ? 'This is a partial catalog; these counts do not establish nationwide feasibility.' : catalog.mode === 'demo' ? 'Synthetic capacity only; this does not establish real-world feasibility.' : 'Ratings and results coverage still require separate verification.'}</p><div class="table-scroll"><table><thead><tr><th>Office</th><th>Group</th><th>Available</th><th>Needed</th><th>Shortfall</th></tr></thead><tbody>${result.offices.flatMap(office => office.requirements.map(group => `<tr><td>${label(office.office)}</td><td>${label(group.group)}</td><td>${group.available}</td><td>${group.required}</td><td class="${group.shortfall ? 'warning' : ''}">${group.shortfall}</td></tr>`)).join('')}</tbody></table></div>`;
} catch (error) { if (version === capacityVersion) element('capacity').textContent = String(error); }
if (!user || page === 'reset') {
authPage(path, () => void render());
return;
}
async function renderPreview() {
const version = ++previewVersion;
const snapshot = results()[step];
element<HTMLInputElement>('timeline').value = String(step);
element('snapshot-label').textContent = snapshot ? `${snapshot.label} · ${snapshot.as_of}` : 'No result snapshots imported';
element('snapshot-source').innerHTML = snapshot ? `Results: ${sourceLink(snapshot.source)}${selectedRatings() ? ` · Ratings: ${sourceLink(selectedRatings()!.source)}` : ''}` : '';
if (!snapshot || !ratingId) { element('preview').innerHTML = '<p class="notice">Scoring is unavailable until both ratings and results are imported for this catalog. Missing data is not treated as zero.</p>'; return; }
element('preview').innerHTML = '<p role="status">Calculating…</p>';
try {
const preview = await api<Preview>('score-preview', { catalog_id: catalog.id, ratings_id: ratingId, results_id: snapshot.id, roster: Object.entries(roster).filter(([, id]) => id).map(([slot, candidate_id]) => ({ slot, candidate_id })) });
if (version !== previewVersion) return;
if (!preview.valid) { element('preview').innerHTML = `<p class="notice warning">${preview.errors.map(escape).join('<br>')}</p>`; return; }
const max = Math.max(preview.known_subtotal ?? 0, preview.baseline_subtotal ?? 0, 1);
element('preview').innerHTML = `<div class="metrics"><div><span>Complete roster total</span><strong>${money(preview.total)}</strong><small>${preview.total === null ? 'Needs 12 picks with available scores' : 'Counted shares + awarded bonuses'}</small></div><div><span>Known subtotal</span><strong>${money(preview.known_subtotal)}</strong><small>${preview.missing_scores ?? 0} selected scores unavailable</small></div><div><span>If current leaders win</span><strong>${money(preview.leader_preview_subtotal)}</strong><small>${preview.unavailable_leader_previews ?? 0} previews unavailable · not awarded points</small></div></div><div class="comparison" aria-label="Scoring comparison"><div><span>No multipliers ${money(preview.baseline_subtotal)}</span><i style="width:${(preview.baseline_subtotal ?? 0) / max * 100}%"></i></div><div><span>Race-weighted ${money(preview.known_subtotal)}</span><i style="width:${(preview.known_subtotal ?? 0) / max * 100}%"></i></div></div><div class="table-scroll"><table><thead><tr><th>Pick</th><th>Vote share</th><th>Win bonus</th><th>Multiplier</th><th>Points</th><th>If leader wins</th></tr></thead><tbody>${preview.scores.map(score => `<tr><td><strong>${escape(catalog.candidates.find(candidate => candidate.id === score.candidate_id)?.name)}</strong><span class="cell-note">${escape(score.slot.split(':').map(label).join(' / '))} · ${escape(score.reason ?? score.status)}</span></td><td>${score.vote_share === undefined ? '—' : `${score.vote_share.toFixed(2)}%`}</td><td>${score.win_bonus ?? '—'}</td><td>${score.multiplier === undefined ? '—' : `×${score.multiplier.toFixed(2)}`}</td><td><strong>${money(score.points)}</strong></td><td>${money(score.leader_preview_points)}</td></tr>`).join('') || '<tr><td colspan="6">Choose candidates above to see their points.</td></tr>'}</tbody></table></div>`;
} catch (error) { if (version === previewVersion) element('preview').textContent = String(error); }
if (page === 'leagues')
await leaguesPage(signal);
else if (page === 'create')
await createPage(signal);
else if (page === 'join')
joinPage(path[1]);
else if (page === 'league' && path[1])
await leaguePage(path[1], signal);
else if (page === 'draft' && path[1])
await draftPage(path[1], signal);
else if (page === 'candidates')
candidatesPage(signal, path[1]);
else if (page === 'results')
await resultsPage(signal, path[1]);
else if ((page === 'standings' || page === 'roster') && path[1])
await standingsPage(path[1], signal, page === 'roster');
else if (page === 'account')
accountPage(() => void render());
else if (page === 'admin')
await adminPage(signal);
else
element('#page').innerHTML = '<h1>Page not found</h1><a href="#leagues">Back to my leagues</a>';
}
async function start() {
try {
const [ruleData, catalogData] = await Promise.all([api<Rules>('rules'), api<{ documents: Document[] }>('catalog')]);
rules = ruleData;
documents = catalogData.documents;
catalog = documents.find(document => document.kind === 'catalog' && document.mode === 'demo') as Catalog ?? documents.find(document => document.kind === 'catalog') as Catalog;
if (!catalog) { root.innerHTML = '<section class="intro"><h1>The field is empty.</h1><p>Import a catalog with the local admin tool to start exploring. Setup instructions are in the repository README.</p></section>'; return; }
ratingId = ratings()[0]?.id ?? '';
render();
} catch (error) {
root.innerHTML = `<section class="intro"><h1>Unable to load the league.</h1><p role="alert">${escape(error)}</p><p>Check that the C++ backend is running, then try again.</p><button id="retry">Retry</button></section>`;
element('retry').onclick = () => void start();
catch (error) {
if (!signal.aborted)
message(error);
}
}
window.addEventListener('hashchange', () => void render());
async function start() { try {
setUser(await api<User>('auth/me'));
}
catch {
setUser(null);
} await render(); }
void start();
+56
View File
@@ -0,0 +1,56 @@
import { api, query, user } from './api';
import { element, escape, message, points, sourceLink, time } from './ui';
import type { LeagueView, Provenance, RaceResult, Standing } from './types';
function provenance(source: Provenance) { return `${sourceLink(source.url, source.source_name || source.source_id)}${source.stale ? ' · stale, showing last good data' : ''}<br>Fetched ${time(source.received_at)} · source updated ${escape(source.source_time || 'not supplied')} · checked ${time(source.last_checked)}`; }
export async function resultsPage(signal: AbortSignal, league?: string) {
element('#page').innerHTML = `${league ? `<a class="back" href="#league/${league}">← League</a>` : ''}<p class="eyebrow">Election night</p><h1>Race results</h1><p class="muted">Updates every 15 seconds. Missing data stays unknown. Race calls can change.</p><label class="search-label">Find a race<input id="race-search" type="search" placeholder="State, district, or office"></label><div id="race-results"></div>`;
let races: RaceResult[] = [], timer: number | undefined;
function draw() { const search = element<HTMLInputElement>('#race-search').value.toLowerCase(); const filtered = races.filter(r => (r.name + ' ' + r.state + ' ' + r.office).toLowerCase().includes(search)); element('#race-results').innerHTML = filtered.map(r => `<details class="panel race"><summary><span>${escape(r.name)}</span><span class="muted">${r.counts ? r.counts.total_votes.toLocaleString() + ' votes' : 'Awaiting counts'} · ${r.outcome ? escape(r.outcome.status.replace('_', ' ')) : 'No call'}</span></summary>${r.counts ? `<p>${provenance(r.counts.provenance)}</p><p>November first-round counts</p><table><thead><tr><th>Candidate</th><th>Votes</th><th>Share</th></tr></thead><tbody>${r.candidates.map(c => { const votes = r.counts!.votes.find(v => v.candidate_id === c.id)?.votes; return `<tr><td>${escape(c.name)}<small>${escape(c.affiliation)}</small></td><td>${votes == null ? '—' : votes.toLocaleString()}</td><td>${votes == null || !r.counts!.total_votes ? '—' : (votes * 100 / r.counts!.total_votes).toFixed(2) + '%'}</td></tr>`; }).join('')}</tbody></table>` : '<p>No verified vote counts have arrived.</p>'}${r.outcome ? `<p>${provenance(r.outcome.provenance)}</p><p>Outcome round: ${escape(r.outcome.round)}${r.outcome.winner_id ? ' · Winner: ' + escape(r.candidates.find(c => c.id === r.outcome!.winner_id)?.name || r.outcome.winner_id) : ''}</p>` : '<p>No verified outcome has arrived.</p>'}</details>`).join('') || '<section class="empty"><h2>Waiting for the field.</h2><p>Results will appear here as verified sources become available.</p></section>'; }
async function update() { try {
const result = await api<{
races: RaceResult[];
}>(query('results', { league_id: league }), undefined, signal);
if (signal.aborted)
return;
const changed = JSON.stringify(result.races) !== JSON.stringify(races);
races = result.races;
if (changed || !element('#race-results').innerHTML)
draw();
}
catch (error) {
message(error);
} if (!signal.aborted)
timer = window.setTimeout(() => void update(), 15000); }
element('#race-search').addEventListener('input', draw);
signal.addEventListener('abort', () => clearTimeout(timer));
await update();
}
export async function standingsPage(id: string, signal: AbortSignal, rosterOnly = false) {
const view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a class="back" href="#league/${id}">← ${escape(view.league.name)}</a><h1>${rosterOnly ? 'My roster' : 'Standings'}</h1><nav class="subnav"><a href="#roster/${id}">My roster</a><a href="#standings/${id}">Standings</a><a href="#results/${id}">Race results</a></nav><div id="scores"></div><details class="panel"><summary>How points work</summary><p>(November first-round vote share + 50 for the eventual called or certified winner) × race rating.</p><p>Safe ×1 · Likely ×1.1 · Lean ×1.25 · Toss-up ×1.5. Each candidate is rounded to two decimals before adding. A later runoff changes the winner bonus, while November vote share stays in place.</p><p>Provisional positions compare known subtotals. Check reporting coverage before comparing teams with missing scores.</p></details>`;
let timer: number | undefined, previous = '';
function roster(row: Standing) { return `<div class="roster">${row.roster.map(score => `<details class="candidate"><summary><span><strong>${escape(score.candidate.name)}</strong><small>${escape(score.slot)}</small></span><strong>${points(score.points)} pts</strong></summary><p>November share ${score.share == null ? 'unknown' : score.share.toFixed(2) + '%'} · win bonus ${score.win_bonus} · rating ${score.rating == null ? 'unknown' : '×' + score.rating}</p>${score.counts ? `<p>${provenance(score.counts.provenance)}</p>` : '<p>Awaiting counts.</p>'}${score.outcome ? `<p>${escape(score.outcome.status)} · ${provenance(score.outcome.provenance)}</p>` : ''}</details>`).join('') || '<p class="empty">Your roster starts with the first pick.</p>'}</div>`; }
async function update() { try {
const data = await api<{
teams: Standing[];
provisional: boolean;
}>(query('standings', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
const serialized = JSON.stringify(data);
if (serialized !== previous) {
previous = serialized;
const mine = view.teams.find(t => t.user_id === user?.id)?.id;
const rows = rosterOnly ? data.teams.filter(r => r.team.id === mine) : data.teams;
element('#scores').innerHTML = `${data.provisional ? '<p class="notice">Provisional · totals include only reported scores.</p>' : ''}${rows.map(row => rosterOnly ? roster(row) : `<details class="panel standing"><summary><span class="rank">${row.rank ?? '—'}</span><span class="grow"><strong>${escape(row.team.name)}</strong><small>${row.known_scores} / 12 scores available</small></span><strong>${points(row.known_subtotal)} <small>${row.total == null ? 'known subtotal' : 'points'}</small></strong></summary>${roster(row)}</details>`).join('')}`;
}
}
catch (error) {
message(error);
} if (!signal.aborted)
timer = window.setTimeout(() => void update(), 15000); }
signal.addEventListener('abort', () => clearTimeout(timer));
await update();
}
+691 -73
View File
@@ -1,73 +1,691 @@
:root { font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif; color: #213632; background: #f6f4ee; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; --muted: #596760; --line: #d6dbd2; }
* { box-sizing: border-box; }
body { margin: 0; }
a { color: inherit; text-underline-offset: 3px; }
:focus-visible { outline: 3px solid #98602c; outline-offset: 3px; }
.site-header, main, footer { width: min(1200px, calc(100% - 64px)); margin-inline: auto; }
.site-header { min-height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -.03em; }
.monogram { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid #213632; border-radius: 50%; font-family: Georgia, serif; font-size: 25px; font-weight: 400; padding-bottom: 4px; }
.edition, .eyebrow { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .13em; }
.intro { padding: 56px 0 36px; }
.eyebrow { margin: 0 0 14px; font-weight: 650; }
h1 { margin: 0; font-family: Georgia, serif; font-size: clamp(38px, 5.5vw, 70px); font-weight: 400; line-height: 1.08; letter-spacing: -.05em; }
h1 em { font-weight: 400; color: #67765e; }
.description { max-width: 600px; margin: 22px 0 0; font-size: 17px; line-height: 1.7; color: var(--muted); }
h2 { font: 400 32px Georgia, serif; margin: 0; letter-spacing: -.02em; }
p { line-height: 1.65; }
.small, .muted, small { font-size: 12px; color: var(--muted); }
.workspace { padding: 24px; background: #e9ece3; border: 1px solid var(--line); }
.workspace > label { max-width: 510px; }
label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; font-weight: 600; }
select, input { font: inherit; font-size: 14px; background: #fffefa; border: 1px solid #b3bdb0; border-radius: 4px; color: #213632; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 42px; }
select { width: 100%; }
button { font: inherit; font-size: 13px; font-weight: 600; color: #fffefa; background: #294d41; padding: 11px 17px; border: 1px solid #294d41; border-radius: 4px; cursor: pointer; min-height: 42px; }
button:hover { background: #3f6150; }
button.secondary { background: transparent; color: #294d41; }
button:disabled { opacity: .5; cursor: default; }
.notice { border-left: 3px solid #8d784f; padding: 10px 14px; background: #f6f1e4; font-size: 13px; }
.notice.real { border-color: #9b5d2d; }
.notice strong { margin-right: 6px; }
.section-nav { display: flex; flex-wrap: wrap; gap: 30px; padding: 25px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
.section-nav a { text-decoration: none; }
.panel { padding: 38px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 24px; }
.filters { display: grid; grid-template-columns: 2fr repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
.table-scroll { overflow: auto; max-height: 440px; border: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: #e9ece3; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); }
tbody tr:last-child td { border: 0; }
td strong { font-weight: 600; }
.cell-note { display: block; font-size: 11px; color: var(--muted); margin-top: 5px; }
.tag { display: inline-block; border: 1px solid var(--line); padding: 4px 7px; font-size: 11px; white-space: nowrap; border-radius: 3px; background: #eeefe7; }
details { margin-top: 22px; }
summary { cursor: pointer; font-size: 14px; padding: 8px 0; }
.capacity-controls { display: flex; align-items: end; gap: 22px; margin-top: 15px; }
.capacity-controls label { min-width: 145px; }
.warning { color: #914518; font-weight: 600; }
.actions { display: flex; gap: 10px; }
.roster-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 25px; }
fieldset { margin: 0; padding: 22px; border: 1px solid var(--line); background: #fdfcf7; min-width: 0; }
legend { font: 23px Georgia, serif; padding: 0 8px; }
fieldset label + label { margin-top: 15px; }
#roster-status { font-size: 13px; }
.formula { font: 23px Georgia, serif; line-height: 1.6; }
.rating-key { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; }
.rating-key strong { margin-left: 6px; }
.replay-controls { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: start; gap: 24px; margin-top: 25px; }
.replay-controls button { margin-top: 21px; }
input[type=range] { width: 100%; accent-color: #294d41; padding: 0; border: 0; background: transparent; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); margin: 25px 0; }
.metrics > div { background: #e9ece3; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
.metrics span { font-size: 12px; }
.metrics strong { font: 42px Georgia, serif; font-variant-numeric: tabular-nums; }
.metrics small { line-height: 1.5; }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 20px 0; font-size: 12px; }
.comparison i { display: block; height: 8px; min-width: 2px; background: #8a987b; margin-top: 10px; transition: width .3s; }
.comparison > div:last-child i { background: #294d41; }
footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; font-size: 11px; color: var(--muted); }
.loading { padding: 60px 0; }
@media (max-width: 800px) { .filters { grid-template-columns: 1fr 1fr; } .search-label { grid-column: 1 / -1; } .roster-grid { grid-template-columns: 1fr; } .replay-controls { grid-template-columns: 1fr; gap: 16px; } .replay-controls button { margin: 0; } .section-heading { align-items: start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } }
@media (max-width: 500px) { .site-header, main, footer { width: calc(100% - 32px); } .edition { max-width: 90px; text-align: right; line-height: 1.6; } .wordmark { gap: 9px; font-size: 14px; } .workspace { padding: 16px; } .section-nav { gap: 15px; font-size: 12px; } .capacity-controls { align-items: stretch; flex-direction: column; gap: 0; } footer { flex-direction: column; gap: 8px; } }
@media (prefers-reduced-motion: reduce) { .comparison i { transition: none; } }
:root {
font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
color:#233b31;
background:#f6f4ee;
font-synthesis:none;
--green:#254f3b;
--line:#d9ded4;
--muted:#66736a;
--paper:#fffefa;
}
* {
box-sizing:border-box;
}
body {
margin:0;
}
button,input,select {
font:inherit;
}
a {
color:inherit;
text-underline-offset:4px;
}
button,.button {
display:inline-flex;
align-items:center;
justify-content:center;
gap:.4rem;
background:var(--green);
color:white;
border:1px solid var(--green);
border-radius:7px;
padding:.65rem 1rem;
font-size:.9rem;
font-weight:600;
cursor:pointer;
text-decoration:none;
min-height:42px;
}
button:hover,.button:hover {
filter:brightness(1.12);
}
button:disabled {
opacity:.45;
cursor:default;
}
button.secondary,.button.secondary {
background:transparent;
color:var(--green);
border-color:#a6b5a6;
}
.quiet {
background:transparent;
color:var(--green);
border-color:transparent;
padding:.35rem .55rem;
min-height:36px;
}
input,select {
display:block;
width:100%;
border:1px solid #b9c4b9;
border-radius:6px;
background:var(--paper);
color:inherit;
padding:.7rem .8rem;
min-height:43px;
}
input:focus,select:focus,button:focus-visible,a:focus-visible,summary:focus-visible {
outline:3px solid #94bca0;
outline-offset:3px;
}
label {
display:block;
font-size:.88rem;
font-weight:550;
line-height:1.5;
}
label input,label select {
margin-top:.4rem;
}
form {
display:grid;
gap:1rem;
}
form button[type=submit] {
justify-self:start;
}
p {
line-height:1.65;
margin:.6rem 0 1rem;
}
h1,h2,h3 {
font-weight:550;
letter-spacing:-.025em;
line-height:1.2;
}
h1 {
font-family:Georgia,serif;
font-size:clamp(2.2rem,5vw,3.5rem);
font-weight:400;
margin:.25rem 0 1.6rem;
}
h2 {
font-size:1.2rem;
margin:.2rem 0 1rem;
}
small {
display:block;
font-size:.78rem;
font-weight:400;
color:var(--muted);
margin-top:.3rem;
}
.site-header {
max-width:1280px;
margin:auto;
min-height:104px;
padding:1.5rem 2rem;
display:flex;
align-items:center;
gap:2.4rem;
border-bottom:1px solid var(--line);
}
.wordmark {
display:flex;
align-items:center;
gap:.8rem;
font-size:.95rem;
text-decoration:none;
font-weight:600;
white-space:nowrap;
}
.monogram {
font-family:Georgia,serif;
font-size:2.8rem;
letter-spacing:-.07em;
font-weight:400;
}
.site-header nav {
display:flex;
gap:1.8rem;
margin:auto;
}
.site-header nav a {
text-decoration:none;
color:var(--muted);
padding:.6rem 0;
font-size:.92rem;
}
.site-header nav a[aria-current] {
border-bottom:2px solid var(--green);
color:var(--green);
}
.edition {
margin-left:auto;
font-size:.8rem;
color:var(--muted);
}
main {
max-width:1120px;
margin:3.5rem auto 5rem;
padding:0 2rem;
min-height:60vh;
}
footer {
max-width:1280px;
margin:auto;
border-top:1px solid var(--line);
padding:1.5rem 2rem;
display:flex;
justify-content:space-between;
gap:1rem;
color:var(--muted);
font-size:.78rem;
}
.page-heading {
display:flex;
align-items:center;
justify-content:space-between;
gap:1rem;
margin-bottom:1.4rem;
}
.page-heading h1,.page-heading h2 {
margin-bottom:0;
}
.eyebrow {
text-transform:uppercase;
letter-spacing:.13em;
font-size:.7rem;
font-weight:700;
color:var(--muted);
margin-bottom:.65rem;
}
.actions {
display:flex;
align-items:center;
gap:.5rem;
flex-shrink:0;
}
.panel {
background:var(--paper);
border:1px solid var(--line);
border-radius:12px;
padding:1.5rem;
margin-bottom:1.2rem;
}
.narrow {
max-width:580px;
}
.muted {
color:var(--muted);
font-size:.88rem;
}
.empty {
text-align:center;
padding:5rem 1rem;
max-width:620px;
margin:auto;
}
.empty h2 {
font-family:Georgia,serif;
font-size:1.8rem;
font-weight:400;
}
.empty p {
color:var(--muted);
}
.league-grid {
display:grid;
grid-template-columns:repeat(auto-fit,minmax(270px,1fr));
gap:1.2rem;
}
.league-card {
text-decoration:none;
position:relative;
min-height:205px;
}
.league-card h2 {
font-size:1.5rem;
margin-top:1.2rem;
}
.league-card:hover {
border-color:#7c9983;
}
.arrow {
position:absolute;
bottom:1.5rem;
right:1.5rem;
font-size:1.5rem;
}
.badge {
display:inline-block;
background:#eaf0e5;
color:#446449;
border-radius:4px;
padding:.25rem .5rem;
font-size:.7rem;
text-transform:capitalize;
}
.back {
display:inline-block;
font-size:.87rem;
color:var(--muted);
margin-bottom:1.5rem;
text-decoration:none;
}
.subnav {
display:flex;
gap:1.5rem;
margin:0 0 2rem;
font-size:.9rem;
}
.notice {
background:#eef1df;
border:1px solid #d5ddbd;
border-radius:8px;
padding:1rem 1.2rem;
margin-bottom:1.5rem;
font-size:.9rem;
}
.notice ul {
margin-bottom:0;
padding-left:1.1rem;
line-height:1.8;
}
.error {
color:#893626;
}
.notice.error {
background:#faeee6;
border-color:#dec5b2;
}
.auth {
max-width:560px;
margin:5rem auto;
}
.auth>p {
color:var(--muted);
}
.auth .panel {
margin-top:1.5rem;
}
.auth h1 {
font-size:3rem;
}
.team-list {
list-style:none;
padding:0;
margin:0;
}
.team-list li {
display:flex;
align-items:center;
justify-content:space-between;
padding:1rem 0;
border-bottom:1px solid var(--line);
}
.team-list li:last-child {
border:0;
}
details>summary {
cursor:pointer;
font-size:.88rem;
font-weight:550;
line-height:1.5;
}
details[open]>summary {
margin-bottom:1.2rem;
}
details p {
font-size:.84rem;
color:var(--muted);
overflow-wrap:anywhere;
}
.search {
margin:0 0 1.5rem;
}
.search input[type=search] {
padding:1rem;
background:var(--paper);
}
.filters {
display:flex;
gap:1rem;
flex-wrap:wrap;
margin-top:1rem;
}
.filters label {
flex:1;
min-width:100px;
}
.filters .checkbox {
display:flex;
align-items:center;
gap:.5rem;
}
.checkbox input {
width:auto;
margin:0;
}
.candidate-list {
border-top:1px solid var(--line);
}
.candidate {
padding:1.15rem 0;
border-bottom:1px solid var(--line);
}
.candidate-main {
display:flex;
align-items:center;
justify-content:space-between;
gap:1rem;
}
.candidate-meta {
display:block;
color:var(--muted);
font-size:.78rem;
margin-top:.4rem;
}
.candidate>details {
margin-top:.7rem;
}
.candidate>details>summary {
font-size:.74rem;
color:var(--muted);
}
.candidate>details[open]>summary {
margin-bottom:.4rem;
}
.pagination {
display:flex;
align-items:center;
justify-content:center;
gap:1rem;
margin-top:1.5rem;
font-size:.82rem;
}
.draft-layout {
display:grid;
grid-template-columns:minmax(0,1fr) 290px;
gap:2rem;
}
.draft-layout aside .panel {
padding:1.1rem;
}
.draft-layout aside h2 {
font-size:1rem;
}
.draft-layout ol {
padding-left:1.2rem;
font-size:.85rem;
}
.draft-layout li {
padding:.6rem 0;
}
.draft-layout #queue {
list-style:none;
padding:0;
}
.draft-layout #queue li {
display:flex;
align-items:center;
justify-content:space-between;
gap:.4rem;
}
.draft-layout #queue li>div {
white-space:nowrap;
}
.turn {
display:flex;
align-items:center;
gap:1.5rem;
background:#e9eee1;
}
.turn>div:first-child {
flex:1;
}
.turn p {
margin-bottom:0;
font-size:.85rem;
}
.clock {
font-size:2.2rem;
font-variant-numeric:tabular-nums;
}
.rank {
font-family:Georgia,serif;
font-size:1.8rem;
min-width:2rem;
}
.standing>summary,.roster summary,.race>summary {
display:flex;
align-items:center;
gap:1.2rem;
justify-content:space-between;
}
.grow {
flex:1;
}
.standing summary strong {
font-weight:600;
}
.search-label {
max-width:580px;
margin-bottom:2rem;
}
.account-menu {
position:relative;
font-size:.8rem;
}
.account-menu>div {
position:absolute;
right:0;
top:2rem;
background:var(--paper);
padding:1rem;
border:1px solid var(--line);
border-radius:8px;
z-index:5;
min-width:180px;
box-shadow:0 8px 20px #233b3110;
}
.account-menu a {
display:block;
margin-bottom:1rem;
}
.account-menu[open]>summary {
margin-bottom:0;
}
.sr-only {
position:absolute;
width:1px;
height:1px;
padding:0;
overflow:hidden;
clip:rect(0,0,0,0);
white-space:nowrap;
border:0;
}
[hidden] {
display:none!important;
}
@media(max-width:760px) {
.site-header {
padding:1rem;
gap:.8rem;
flex-wrap:wrap;
}
.site-header nav {
order:3;
width:100%;
justify-content:center;
gap:2.5rem;
}
.account-menu {
margin-left:auto;
}
.wordmark {
font-size:.85rem;
}
main {
padding:0 1rem;
margin-top:2rem;
}
.page-heading {
align-items:flex-start;
flex-direction:column;
}
.page-heading .actions {
width:100%;
flex-wrap:wrap;
}
.draft-layout {
grid-template-columns:1fr;
}
.draft-layout aside {
display:flex;
flex-direction:column;
}
.turn {
flex-wrap:wrap;
}
.candidate-main {
align-items:flex-start;
flex-wrap:wrap;
}
.candidate-main .actions {
margin-left:auto;
}
.auth {
margin:2rem auto;
}
.auth h1 {
font-size:2.6rem;
}
.race>summary {
align-items:flex-start;
flex-direction:column;
gap:.4rem;
}
.panel {
padding:1.1rem;
}
footer {
padding:1.3rem 1rem;
}
.edition {
font-size:.7rem;
}
}
table {
width:100%;
border-collapse:collapse;
font-size:.85rem;
}
th,td {
text-align:left;
padding:.7rem .3rem;
border-bottom:1px solid var(--line);
}
th {
color:var(--muted);
font-weight:500;
}
td:not(:first-child) {
font-variant-numeric:tabular-nums;
}
+138
View File
@@ -0,0 +1,138 @@
export interface League {
id: string;
name: string;
commissioner_id: string;
max_teams: number;
status: string;
catalog_mode: string | null;
catalog_id: string | null;
ratings_id: string | null;
members: number;
team_name: string;
}
export interface Team {
id: string;
user_id: string;
name: string;
username: string;
draft_order: number | null;
queue?: string[];
}
export interface Pick {
number: number;
team_id: string;
candidate_id: string;
slot: string;
automatic: number;
}
export interface LeagueView {
league: League;
teams: Team[];
my_team: Team & {
queue: string[];
};
picks: Pick[];
draft: {
pick_number: number;
deadline: number | null;
remaining: number;
};
picker: Team | null;
legal_candidate_ids: string[];
candidate_names: Record<string, string>;
server_time: number;
readiness: {
ready: boolean;
problems: string[];
};
}
export interface Snapshot {
id: string;
label: string;
mode: string;
catalog_id?: string;
complete?: boolean;
}
export interface Catalogs {
catalogs: Snapshot[];
ratings: Snapshot[];
allow_demo: boolean;
}
export interface Candidate {
id: string;
race_id: string;
name: string;
affiliation: string;
party: string;
office: string;
state: string;
rating: string | null;
owner: string | null;
source: {
url: string;
name: string;
retrieved_at: string;
};
}
export interface CandidatePage {
items: Candidate[];
total: number;
catalog: Snapshot | null;
}
export interface Provenance {
source_id: string;
source_name: string;
url: string;
source_time: string | null;
received_at: number;
last_checked: number | null;
stale: boolean;
verified: number;
}
export interface Counts {
total_votes: number;
votes: {
candidate_id: string;
votes: number;
}[];
provenance: Provenance;
}
export interface Outcome {
status: string;
round: string;
winner_id: string | null;
provenance: Provenance;
}
export interface RaceResult {
id: string;
name: string;
office: string;
state: string;
candidates: {
id: string;
name: string;
affiliation: string;
}[];
counts: Counts | null;
outcome: Outcome | null;
}
export interface Score {
candidate: Candidate;
slot: string;
points: number | null;
share: number | null;
win_bonus: number;
rating: number | null;
counts: Counts | null;
outcome: Outcome | null;
}
export interface Standing {
team: Team;
roster: Score[];
known_scores: number;
missing_scores: number;
known_subtotal: number | null;
total: number | null;
rank: number | null;
provisional: boolean;
}
+43
View File
@@ -0,0 +1,43 @@
export function escape(value: unknown) { return String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!)); }
export function element<T extends HTMLElement = HTMLElement>(selector: string, root: ParentNode = document): T { const node = root.querySelector<T>(selector); if (!node)
throw new Error(`Missing element: ${selector}`); return node; }
export function message(error: unknown) { if (error instanceof DOMException && error.name === 'AbortError')
return; const region = document.querySelector<HTMLElement>('#notice'); if (region) {
region.textContent = error instanceof Error ? error.message : String(error);
region.hidden = false;
} }
export function clearMessage() { const region = element('#notice'); region.hidden = true; region.textContent = ''; }
export function submit(selector: string, operation: (data: FormData) => Promise<void>) {
element<HTMLFormElement>(selector).addEventListener('submit', async (event) => {
event.preventDefault();
clearMessage();
const form = event.currentTarget as HTMLFormElement;
const button = form.querySelector<HTMLButtonElement>('button[type=submit]');
if (button)
button.disabled = true;
try {
await operation(new FormData(form));
}
catch (error) {
message(error);
}
finally {
if (button)
button.disabled = false;
}
});
}
export function click(selector: string, operation: () => Promise<void>, root: ParentNode = document) { const button = root.querySelector<HTMLButtonElement>(selector); button?.addEventListener('click', async () => { clearMessage(); button.disabled = true; try {
await operation();
}
catch (error) {
message(error);
}
finally {
button.disabled = false;
} }); }
export function field(label: string, name: string, value = '', extra = '') { return `<label>${escape(label)}<input name="${name}" value="${escape(value)}" ${extra} required></label>`; }
export function sourceLink(url: string, label: string) { return /^https?:\/\//.test(url) ? `<a href="${escape(url)}" target="_blank" rel="noopener noreferrer">${escape(label)}</a>` : escape(label); }
export function time(value: number | null) { return value ? new Date(value * 1000).toLocaleString() : 'Not checked'; }
export function points(value: number | null) { return value == null ? '—' : value.toFixed(2); }
export function go(path: string) { location.hash = path; }
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: { rollupOptions: { input: { app: "index.html", lab: "lab.html" } } },
server: {
host: '127.0.0.1',
port: 5173,
+77 -28
View File
@@ -1,65 +1,114 @@
#include "store.hpp"
#include "application.hpp"
#include "domain.hpp"
#include <iostream>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/use_future.hpp>
#include <unistd.h>
namespace {
auto execute(fpol::store &store, std::string const &action, std::vector<std::string> const &files) -> boost::asio::awaitable<void> {
auto execute(fpol::store &store, fpol::application &application, std::filesystem::path path, std::string action, std::vector<std::string> files, boost::json::value request) -> boost::asio::awaitable<void> {
co_await store.initialize();
auto result = boost::json::value{};
if (action == "import") {
for (auto const &file : files) {
auto result = co_await store.import_document(fpol::read_json(file));
std::cout << boost::json::serialize(result) << '\n';
std::cout << boost::json::serialize(co_await store.import_document(fpol::read_json(file))) << '\n';
}
co_return;
}
if (action == "create-user" || action == "reset-link") {
result = co_await application.admin(action, request);
} else if (action == "backup") {
fpol::ensure(files.size() == 1 && !std::filesystem::exists(files[0]), "backup requires a new destination filename");
auto database = fpol::database{path};
database.backup(files[0]);
result = boost::json::object{{"backup", files[0]}};
} else if (action == "sources") {
result = co_await application.admin(action, boost::json::object{});
} else if (action == "app-request") {
auto command = fpol::text(request, "action");
if (command == "tick" || command == "restart") {
co_await application.tick(command == "restart");
result = boost::json::object{{"ok", true}};
} else {
auto optional = [&](std::string const &key) {auto item=request.as_object().if_contains(key);return item&&item->is_string()?std::string{item->as_string()}:std::string{}; };
result = co_await application.handle(command, request.as_object().contains("input") ? request.at("input") : boost::json::value{boost::json::object{}}, optional("session"), optional("csrf"), "operator-local");
}
} else {
auto request = files.empty() ? boost::json::value{boost::json::object{}} : fpol::read_json(files.front());
auto result = co_await store.handle(action, std::move(request));
result = co_await store.handle(action, std::move(request));
}
std::cout << boost::json::serialize(result) << '\n';
}
}
}
auto main(int argc, char **argv) -> int {
int main(int argc, char **argv) {
if (argc == 2 && std::string_view{argv[1]} == "--help") {
std::cout << "Usage: fpol_admin [--database PATH] import FILE...\n"
" fpol_admin [--database PATH] rules|catalog\n"
" fpol_admin [--database PATH] capacity|score-preview REQUEST.json\n"
"Default database: var/fpol.sqlite. Imports are atomic per file, immutable and idempotent.\n";
std::cout << "fpol_admin [--database PATH] [--allow-demo] import FILE... | rules | catalog | capacity FILE | score-preview FILE\n"
" create-user USERNAME (password read privately from terminal, or from stdin)\n"
" reset-link USERNAME (single-use token, expires in 30 minutes)\n"
" sources | backup NEW_FILENAME\n"
" app-request FILE|- (trusted local diagnostic API; returns session secrets; never expose publicly)\n";
return 0;
}
try {
auto index = 1;
auto path = std::filesystem::path{"var/fpol.sqlite"};
if (index < argc && std::string_view{argv[index]} == "--database") {
if (++index == argc) {
throw std::invalid_argument("Missing database path");
auto config = fpol::application_config{};
while (index < argc && std::string_view{argv[index]}.starts_with("--")) {
auto option = std::string_view{argv[index++]};
if (option == "--allow-demo") {
config.allow_demo = true;
} else if (option == "--database" && index < argc) {
config.database = argv[index++];
} else {
throw std::invalid_argument{"Invalid option"};
}
path = argv[index++];
}
if (index == argc) {
throw std::invalid_argument("Missing command; use --help");
}
fpol::ensure(index < argc, "Missing command");
auto action = std::string{argv[index++]};
auto files = std::vector<std::string>{};
while (index < argc) {
files.emplace_back(argv[index++]);
}
if (!((action == "import" && !files.empty()) || ((action == "rules" || action == "catalog") && files.empty()) || ((action == "capacity" || action == "score-preview") && files.size() == 1))) {
throw std::invalid_argument("Invalid command or argument count; use --help");
auto request = boost::json::value{boost::json::object{}};
if (action == "create-user" || action == "reset-link") {
fpol::ensure(files.size() == 1, "Expected username");
request.as_object()["username"] = files[0];
if (action == "create-user") {
auto password = std::string{};
if (isatty(STDIN_FILENO)) {
auto secret = getpass("New password (12+ characters): ");
fpol::ensure(secret != nullptr, "Cannot read password");
password = secret;
} else {
std::getline(std::cin, password);
}
request.as_object()["password"] = password;
}
} else if (action == "app-request" || action == "capacity" || action == "score-preview") {
fpol::ensure(files.size() == 1, "Expected a request file");
if (files[0] == "-") {
auto body = std::string{std::istreambuf_iterator<char>{std::cin}, {}};
request = boost::json::parse(body);
} else {
request = fpol::read_json(files[0]);
}
if (action == "app-request" && request.as_object().contains("clock")) {
auto time = fpol::integer(request, "clock");
config.clock = [time]() { return time; };
}
} else {
fpol::ensure((action == "import" && !files.empty()) || (action == "backup" && files.size() == 1) || ((action == "catalog" || action == "rules" || action == "sources") && files.empty()), "Invalid command or arguments; use --help");
}
auto io = boost::asio::io_context{};
auto store = fpol::store{io.get_executor(), path};
auto future = boost::asio::co_spawn(io, execute(store, action, files), boost::asio::use_future);
auto store = fpol::store{io.get_executor(), config.database};
auto application = fpol::application{config};
auto future = boost::asio::co_spawn(io, execute(store, application, config.database, action, files, request), boost::asio::use_future);
io.run();
future.get();
return 0;
} catch (std::exception const &error) {
std::cerr << "Import/request failed: " << error.what() << '\n';
std::cerr << "Request failed: " << error.what() << '\n';
return 1;
}
}
+307
View File
@@ -0,0 +1,307 @@
#include "application.hpp"
#include "crypto.hpp"
#include "domain.hpp"
#include <algorithm>
#include <chrono>
#include <regex>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <boost/system/system_error.hpp>
namespace {
auto execute(std::function<boost::json::value()> operation) -> boost::asio::awaitable<boost::json::value> {
co_return operation();
}
auto optional_text(boost::json::value const &value, std::string const &key, std::string fallback = {}) -> std::string {
auto item = value.as_object().if_contains(key);
return item && item->is_string() ? std::string{item->as_string()} : fallback;
}
auto lower(std::string value) -> std::string {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { return static_cast<char>(std::tolower(character)); });
return value;
}
auto username(boost::json::value const &input) -> std::string {
auto name = lower(fpol::text(input, "username"));
fpol::ensure(std::regex_match(name, std::regex{"[a-z0-9_]{3,32}"}), "Username must have 3–32 letters, numbers or underscores");
return name;
}
auto authenticated(fpol::database &database, std::string const &token, std::string const &csrf, std::int64_t now, bool mutation) -> boost::json::object {
auto user = database.one("SELECT u.id,u.username,u.admin,s.csrf FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?", {fpol::digest(token), now});
fpol::ensure(!user.empty(), "Please sign in", 401);
if (mutation) {
fpol::ensure(!csrf.empty() && std::string{user.at("csrf").as_string()} == csrf, "Invalid session request; refresh and try again", 403);
}
return user;
}
auto session(fpol::database &database, std::string const &user_id, std::int64_t now) -> boost::json::value {
auto token = fpol::random_token();
auto csrf = fpol::random_token();
database.query("DELETE FROM sessions WHERE expires_at<=?", {now});
database.query("INSERT INTO sessions VALUES(?,?,?,?)", {fpol::digest(token), user_id, csrf, now + 30 * 86400});
auto user = database.one("SELECT id,username,admin FROM users WHERE id=?", {user_id});
user["csrf"] = csrf;
return boost::json::object{{"ok", true}, {"status", 200}, {"payload", user}, {"session_token", token}};
}
auto success(boost::json::value payload) -> boost::json::value {
return boost::json::object{{"ok", true}, {"status", 200}, {"payload", std::move(payload)}};
}
auto mutation(std::string const &action) -> bool {
return action != "auth/me" && action != "leagues" && action != "league" && action != "catalogs" && action != "candidates" && action != "results" && action != "standings" && action != "admin/sources";
}
auto request_catalog(fpol::database &database, boost::json::value const &input, boost::json::object const &user, bool allow_demo) -> boost::json::value {
auto id = optional_text(input, "catalog_id");
auto league = optional_text(input, "league_id");
if (!league.empty()) {
auto row = database.one("SELECT l.catalog_id FROM leagues l JOIN teams t ON t.league_id=l.id WHERE l.id=? AND t.user_id=?", {league, fpol::text(user, "id")});
fpol::ensure(!row.empty(), "This league is private", 403);
id = row.at("catalog_id").is_null() ? "" : fpol::text(row, "catalog_id");
}
auto catalog = id.empty() ? fpol::current_catalog(database) : database.document(id, "catalog");
if (!catalog.is_null()) {
fpol::ensure(fpol::text(catalog, "mode") == "real" || allow_demo, "Demo catalogs are disabled", 403);
}
return catalog;
}
auto number(boost::json::value const &input, std::string const &key, std::int64_t fallback) -> std::int64_t {
auto item = input.as_object().if_contains(key);
if (!item) {
return fallback;
}
if (item->is_int64()) {
return item->as_int64();
}
if (item->is_string()) {
auto value = std::string{item->as_string()};
fpol::ensure(std::regex_match(value, std::regex{"[0-9]{1,6}"}), "Invalid pagination");
return std::stoll(value);
}
throw fpol::api_error{400, "Invalid pagination"};
}
}
fpol::application::application(fpol::application_config config)
: config_{std::move(config)} {}
auto fpol::application::now() const -> std::int64_t {
return config_.clock ? config_.clock() : std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
auto fpol::application::work(std::function<boost::json::value(fpol::database &)> operation) -> boost::asio::awaitable<boost::json::value> {
auto path = config_.database;
co_return co_await boost::asio::co_spawn(database_worker_, execute([path, operation = std::move(operation)]() {auto database=fpol::database{path};database.begin();auto value=operation(database);database.commit();return value; }), boost::asio::use_awaitable);
}
auto fpol::application::hash_work(std::function<boost::json::value()> operation) -> boost::asio::awaitable<boost::json::value> {
if (pending_hashes_.fetch_add(1) >= 8) {
--pending_hashes_;
throw fpol::api_error{429, "Too many sign-in requests; try again shortly"};
}
try {
auto result = co_await boost::asio::co_spawn(password_workers_, execute(std::move(operation)), boost::asio::use_awaitable);
--pending_hashes_;
co_return result;
} catch (...) {
--pending_hashes_;
throw;
}
}
auto fpol::application::tick(bool restart) -> boost::asio::awaitable<void> {
auto time = now();
co_await work([time, restart](fpol::database &database) {fpol::advance_drafts(database,time,restart);return boost::json::value{}; });
}
auto fpol::application::handle(std::string action, boost::json::value input, std::string token, std::string csrf, std::string peer) -> boost::asio::awaitable<boost::json::value> {
try {
ensure(input.is_object(), "Request must be an object");
auto time = now();
if (action == "auth/login") {
auto name = username(input);
auto password = fpol::text(input, "password");
ensure(password.size() <= 256, "Invalid credentials", 401);
auto user = co_await work([name, peer, time](fpol::database &database) {
for (auto const &key : {"user:" + name, "peer:" + peer}) {
auto limit = database.one("SELECT * FROM login_limits WHERE key=?", {key});
auto maximum = key.starts_with("user:") ? 10 : 100;
ensure(limit.empty() || time - limit.at("window_start").as_int64() >= 900 || limit.at("failures").as_int64() < maximum, "Too many login attempts; try again in 15 minutes", 429);
}
for (auto const &key : {"user:" + name, "peer:" + peer}) {
database.query("INSERT INTO login_limits VALUES(?,1,?) ON CONFLICT(key) DO UPDATE SET failures=CASE WHEN ?-window_start>=900 THEN 1 ELSE failures+1 END,window_start=CASE WHEN ?-window_start>=900 THEN ? ELSE window_start END", {key, time, time, time, time});
}
return boost::json::value{database.one("SELECT * FROM users WHERE username=?", {name})};
});
auto hash = user.as_object().empty() ? "scrypt$131072$8$1$" + std::string(32, '0') + "$" + std::string(128, '0') : fpol::text(user, "password_hash");
auto valid = co_await hash_work([password, hash]() { return boost::json::value{fpol::password_matches(password, hash)}; });
ensure(valid.as_bool() && !user.as_object().empty(), "Invalid username or password", 401);
co_return co_await work([user, hash, name, time](fpol::database &database) {auto current=database.one("SELECT password_hash FROM users WHERE id=?",{fpol::text(user,"id")});ensure(!current.empty()&&fpol::text(current,"password_hash")==hash,"Password changed; sign in again",401);database.query("DELETE FROM login_limits WHERE key=?",{"user:"+name});return session(database,fpol::text(user,"id"),time); });
}
if (action == "auth/register") {
auto name = username(input);
auto invite = fpol::text(input, "invite");
auto password = fpol::text(input, "password");
auto team_name = fpol::text(input, "team_name");
co_await work([name, invite, time](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username is unavailable",409);ensure(!database.one("SELECT token_hash FROM invites WHERE token_hash=? AND expires_at>?",{fpol::digest(invite),time}).empty(),"Invitation is invalid or expired",404);return boost::json::value{}; });
auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; });
co_return co_await work([=, this](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username is unavailable",409);auto id=fpol::random_token(12);database.query("INSERT INTO users(id,username,password_hash,created_at) VALUES(?,?,?,?)",{id,name,std::string{hash.as_string()},time});auto user=database.one("SELECT id,username,admin FROM users WHERE id=?",{id});game_action(database,"league/join",boost::json::object{{"invite",invite},{"team_name",team_name}},user,time,config_.allow_demo);return session(database,id,time); });
}
if (action == "auth/reset") {
auto reset = fpol::text(input, "reset_token");
auto password = fpol::text(input, "password");
auto previous = co_await work([reset, time](fpol::database &database) {auto value=database.one("SELECT user_id FROM password_resets WHERE token_hash=? AND expires_at>?",{fpol::digest(reset),time});ensure(!value.empty(),"Reset link is invalid or expired",400);return boost::json::value{value}; });
auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; });
co_return co_await work([reset, hash, time, previous](fpol::database &database) {auto value=database.one("SELECT user_id FROM password_resets WHERE token_hash=? AND expires_at>?",{fpol::digest(reset),time});ensure(!value.empty(),"Reset link is invalid or expired",400);auto id=fpol::text(previous,"user_id");database.query("UPDATE users SET password_hash=? WHERE id=?",{std::string{hash.as_string()},id});database.query("DELETE FROM sessions WHERE user_id=?",{id});database.query("DELETE FROM password_resets WHERE user_id=?",{id});return success(boost::json::object{{"message","Password reset. Sign in with your new password."}}); });
}
if (action == "auth/password") {
auto previous = co_await work([=](fpol::database &database) {auto user=authenticated(database,token,csrf,time,true);return boost::json::value{database.one("SELECT id,password_hash FROM users WHERE id=?",{fpol::text(user,"id")})}; });
auto old_password = fpol::text(input, "current_password");
auto password = fpol::text(input, "password");
auto hash = co_await hash_work([previous, old_password, password]() {ensure(fpol::password_matches(old_password,fpol::text(previous,"password_hash")),"Current password is incorrect",401);return boost::json::value{fpol::password_hash(password)}; });
co_return co_await work([=](fpol::database &database) {auto user=authenticated(database,token,csrf,time,true);auto current=database.one("SELECT password_hash FROM users WHERE id=?",{fpol::text(user,"id")});ensure(fpol::text(current,"password_hash")==fpol::text(previous,"password_hash"),"Password changed; sign in again",409);database.query("UPDATE users SET password_hash=? WHERE id=?",{std::string{hash.as_string()},fpol::text(user,"id")});database.query("DELETE FROM sessions WHERE user_id=?",{fpol::text(user,"id")});return session(database,fpol::text(user,"id"),time); });
}
co_return co_await work([=, this](fpol::database &database) {
auto user = authenticated(database, token, csrf, time, mutation(action));
if (action == "auth/me") {
return success(user);
}
if (action == "auth/logout") {
database.query("DELETE FROM sessions WHERE token_hash=?", {fpol::digest(token)});
auto result = success(boost::json::object{});
result.as_object()["clear_session"] = true;
return result;
}
if (action == "admin/sources") {
ensure(user.at("admin").as_int64() == 1, "Administrator access required", 403);
return success(boost::json::object{{"sources", database.query("SELECT id,kind,url,enabled,verified,last_checked,last_success,last_changed,last_error,next_check FROM sources ORDER BY id")}, {"quarantine", database.query("SELECT id,source_id,retrieved_at,reason FROM source_quarantine ORDER BY id DESC LIMIT 50")}, {"coverage", database.query("SELECT * FROM coverage ORDER BY race_id")}});
}
if (action == "catalogs") {
auto catalogs = boost::json::array{};
auto ratings = boost::json::array{};
for (auto const &row : database.query("SELECT body FROM documents WHERE kind IN ('catalog','ratings') ORDER BY as_of DESC,id DESC")) {
auto document = boost::json::parse(fpol::text(row, "body"));
if (fpol::text(document, "mode") == "demo" && !config_.allow_demo) {
continue;
}
auto summary = boost::json::object{{"id", document.at("id")}, {"label", document.at("label")}, {"as_of", document.at("as_of")}, {"mode", document.at("mode")}};
if (fpol::text(document, "kind") == "catalog") {
summary["complete"] = document.at("complete");
catalogs.emplace_back(summary);
} else {
summary["catalog_id"] = document.at("catalog_id");
ratings.emplace_back(summary);
}
}
return success(boost::json::object{{"catalogs", catalogs}, {"ratings", ratings}, {"allow_demo", config_.allow_demo}});
}
if (action == "candidates") {
auto catalog = request_catalog(database, input, user, config_.allow_demo);
if (catalog.is_null()) {
return success(boost::json::object{{"items", boost::json::array{}}, {"total", 0}, {"catalog", nullptr}});
}
auto model = fpol::read_catalog(catalog);
auto rated = std::map<std::string, std::string>{};
auto rating_id = optional_text(input, "ratings_id");
auto league_id = optional_text(input, "league_id");
auto owned = std::map<std::string, boost::json::value>{};
if (!league_id.empty()) {
auto league = database.one("SELECT ratings_id FROM leagues WHERE id=?", {league_id});
if (!league.at("ratings_id").is_null()) {
rating_id = fpol::text(league, "ratings_id");
}
for (auto const &pick : database.query("SELECT p.candidate_id,t.name FROM picks p JOIN teams t ON t.id=p.team_id WHERE p.league_id=?", {league_id})) {
owned.emplace(fpol::text(pick, "candidate_id"), pick.at("name"));
}
}
if (!rating_id.empty()) {
auto ratings = database.document(rating_id, "ratings");
fpol::validate_document(ratings, &model);
for (auto const &entry : ratings.at("entries").as_array()) {
rated.emplace(fpol::text(entry, "race_id"), fpol::text(entry, "rating"));
}
}
auto items = boost::json::array{};
auto search = lower(optional_text(input, "search"));
for (auto const &value : catalog.at("candidates").as_array()) {
auto candidate = value.as_object();
auto race = model.races.at(fpol::text(value, "race_id"));
auto id = fpol::text(value, "id");
if (!search.empty() && lower(fpol::text(value, "name") + " " + race.state + " " + race.id).find(search) == std::string::npos) {
continue;
}
if (!optional_text(input, "office").empty() && optional_text(input, "office") != race.office) {
continue;
}
if (!optional_text(input, "state").empty() && optional_text(input, "state") != race.state) {
continue;
}
if (!optional_text(input, "affiliation").empty() && optional_text(input, "affiliation") != fpol::text(value, "affiliation")) {
continue;
}
if (optional_text(input, "available") == "true" && owned.contains(id)) {
continue;
}
candidate["office"] = race.office;
candidate["state"] = race.state;
candidate["rating"] = rated.contains(race.id) ? boost::json::value{rated.at(race.id)} : boost::json::value{};
candidate["owner"] = owned.contains(id) ? owned.at(id) : boost::json::value{};
items.emplace_back(candidate);
}
auto offset = number(input, "offset", 0);
auto limit = number(input, "limit", 50);
ensure(offset >= 0 && offset <= 100000 && limit >= 1 && limit <= 100, "Invalid pagination");
auto total = items.size();
auto page = boost::json::array{};
for (auto index = static_cast<std::size_t>(offset); index < items.size() && page.size() < static_cast<std::size_t>(limit); ++index) {
page.emplace_back(items[index]);
}
return success(boost::json::object{{"items", page}, {"total", total}, {"catalog", boost::json::object{{"id", model.id}, {"label", catalog.at("label")}, {"complete", model.complete}, {"mode", model.mode}}}});
}
if (action == "results") {
auto catalog = request_catalog(database, input, user, config_.allow_demo);
return success(catalog.is_null() ? boost::json::value{boost::json::object{{"races", boost::json::array{}}}} : current_results(database, catalog));
}
return success(fpol::game_action(database, action, input, user, time, config_.allow_demo));
});
} catch (fpol::api_error const &error) {
co_return boost::json::object{{"ok", false}, {"status", error.status}, {"message", error.what()}, {"code", std::to_string(error.status)}};
} catch (std::invalid_argument const &error) {
co_return boost::json::object{{"ok", false}, {"status", 400}, {"message", error.what()}, {"code", "invalid_argument"}};
} catch (std::out_of_range const &) {
co_return boost::json::object{{"ok", false}, {"status", 400}, {"message", "Missing or invalid request field"}, {"code", "invalid_argument"}};
} catch (boost::system::system_error const &error) {
if (std::string_view{error.code().category().name()} == "boost.json") {
co_return boost::json::object{{"ok", false}, {"status", 400}, {"message", "Missing or invalid request field"}, {"code", "invalid_argument"}};
}
co_return boost::json::object{{"ok", false}, {"status", 500}, {"message", "The request could not be completed"}, {"code", "internal_error"}};
} catch (std::exception const &) {
co_return boost::json::object{{"ok", false}, {"status", 500}, {"message", "The request could not be completed"}, {"code", "internal_error"}};
}
}
auto fpol::application::admin(std::string action, boost::json::value input) -> boost::asio::awaitable<boost::json::value> {
auto time = now();
if (action == "create-user") {
auto name = username(input);
auto password = fpol::text(input, "password");
auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; });
co_return co_await work([name, hash, time](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username already exists",409);auto id=fpol::random_token(12);database.query("INSERT INTO users(id,username,password_hash,admin,created_at) VALUES(?,?,?,1,?)",{id,name,std::string{hash.as_string()},time});return boost::json::value{boost::json::object{{"id",id},{"username",name}}}; });
}
if (action == "reset-link") {
auto name = username(input);
co_return co_await work([name, time](fpol::database &database) {auto user=database.one("SELECT id FROM users WHERE username=?",{name});ensure(!user.empty(),"Unknown account",404);auto token=fpol::random_token();auto id=fpol::text(user,"id");database.query("DELETE FROM password_resets WHERE user_id=?",{id});database.query("INSERT INTO password_resets VALUES(?,?,?)",{fpol::digest(token),id,time+1800});return boost::json::value{boost::json::object{{"reset_token",token},{"expires_at",time+1800}}}; });
}
if (action == "sources") {
co_return co_await work([](fpol::database &database) { return boost::json::value{database.query("SELECT * FROM sources ORDER BY id")}; });
}
throw fpol::api_error{400, "Unknown administrative action"};
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "database.hpp"
#include <atomic>
#include <functional>
#include <boost/asio/thread_pool.hpp>
namespace fpol {
struct application_config {
std::filesystem::path database{"var/fpol.sqlite"};
bool allow_demo{false};
std::function<std::int64_t()> clock{};
};
class application {
public:
explicit application(fpol::application_config config);
auto handle(std::string action, boost::json::value input, std::string session = {}, std::string csrf = {}, std::string peer = {}) -> boost::asio::awaitable<boost::json::value>;
auto tick(bool restart = false) -> boost::asio::awaitable<void>;
auto admin(std::string action, boost::json::value input) -> boost::asio::awaitable<boost::json::value>;
private:
auto work(std::function<boost::json::value(fpol::database &)> operation) -> boost::asio::awaitable<boost::json::value>;
auto hash_work(std::function<boost::json::value()> operation) -> boost::asio::awaitable<boost::json::value>;
auto now() const -> std::int64_t;
fpol::application_config config_;
boost::asio::thread_pool database_worker_{1};
boost::asio::thread_pool password_workers_{2};
std::atomic<unsigned> pending_hashes_{};
};
auto readiness(fpol::database &database, boost::json::object const &league, std::size_t players, bool allow_demo) -> boost::json::value;
auto league_view(fpol::database &database, std::string const &league_id, std::string const &user_id, std::int64_t now, bool allow_demo) -> boost::json::value;
auto game_action(fpol::database &database, std::string const &action, boost::json::value const &input, boost::json::object const &user, std::int64_t now, bool allow_demo) -> boost::json::value;
auto advance_drafts(fpol::database &database, std::int64_t now, bool restart) -> void;
auto current_catalog(fpol::database &database, bool demo = false) -> boost::json::value;
auto current_results(fpol::database &database, boost::json::value const &catalog) -> boost::json::value;
}
+65
View File
@@ -0,0 +1,65 @@
#include "crypto.hpp"
#include "database.hpp"
#include <array>
#include <charconv>
#include <limits>
#include <openssl/crypto.h>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/sha.h>
namespace {
auto hex(unsigned char const *data, std::size_t size) -> std::string {
auto output = std::string{};
for (std::size_t index = 0; index < size; ++index) {
output += "0123456789abcdef"[data[index] >> 4];
output += "0123456789abcdef"[data[index] & 15];
}
return output;
}
auto derive(std::string const &password, std::string const &salt) -> std::string {
auto output = std::array<unsigned char, 64>{};
fpol::ensure(EVP_PBE_scrypt(password.data(), password.size(), reinterpret_cast<unsigned char const *>(salt.data()), salt.size(), 131072, 8, 1, 256ULL * 1024 * 1024, output.data(), output.size()) == 1, "Password hashing failed", 500);
return hex(output.data(), output.size());
}
}
auto fpol::random_token(std::size_t bytes) -> std::string {
auto buffer = std::vector<unsigned char>(bytes);
ensure(RAND_bytes(buffer.data(), static_cast<int>(buffer.size())) == 1, "Random generator failed", 500);
return hex(buffer.data(), buffer.size());
}
auto fpol::digest(std::string_view input) -> std::string {
auto output = std::array<unsigned char, SHA256_DIGEST_LENGTH>{};
SHA256(reinterpret_cast<unsigned char const *>(input.data()), input.size(), output.data());
return hex(output.data(), output.size());
}
auto fpol::password_hash(std::string const &password) -> std::string {
ensure(password.size() >= 12 && password.size() <= 256, "Use a password between 12 and 256 bytes");
auto salt = random_token(16);
return "scrypt$131072$8$1$" + salt + "$" + derive(password, salt);
}
auto fpol::password_matches(std::string const &password, std::string const &hash) -> bool {
if (password.size() > 256 || !hash.starts_with("scrypt$131072$8$1$") || hash.size() != 18 + 32 + 1 + 128) {
return false;
}
auto expected = derive(password, hash.substr(18, 32));
return CRYPTO_memcmp(expected.data(), hash.data() + 51, 128) == 0;
}
auto fpol::random_index(std::size_t size) -> std::size_t {
ensure(size > 0, "Cannot choose from an empty list", 500);
auto value = std::uint64_t{};
auto limit = std::numeric_limits<std::uint64_t>::max() - std::numeric_limits<std::uint64_t>::max() % size;
do {
ensure(RAND_bytes(reinterpret_cast<unsigned char *>(&value), sizeof(value)) == 1, "Random generator failed", 500);
} while (value >= limit);
return static_cast<std::size_t>(value % size);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
namespace fpol {
auto random_token(std::size_t bytes = 32) -> std::string;
auto digest(std::string_view input) -> std::string;
auto password_hash(std::string const &password) -> std::string;
auto password_matches(std::string const &password, std::string const &hash) -> bool;
auto random_index(std::size_t size) -> std::size_t;
}
+110
View File
@@ -0,0 +1,110 @@
#include "database.hpp"
namespace {
auto migrate(noto::connection &connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto statements = std::vector<std::string>{
"CREATE TABLE users(id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE, password_hash TEXT NOT NULL, admin INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)",
"CREATE TABLE sessions(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),csrf TEXT NOT NULL,expires_at INTEGER NOT NULL)",
"CREATE TABLE login_limits(key TEXT PRIMARY KEY,failures INTEGER NOT NULL,window_start INTEGER NOT NULL)",
"CREATE TABLE password_resets(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),expires_at INTEGER NOT NULL)",
"CREATE TABLE leagues(id TEXT PRIMARY KEY,name TEXT NOT NULL,commissioner_id TEXT NOT NULL REFERENCES users(id),max_teams INTEGER NOT NULL CHECK(max_teams BETWEEN 2 AND 12),status TEXT NOT NULL DEFAULT 'forming',catalog_id TEXT REFERENCES documents(id),ratings_id TEXT REFERENCES documents(id),revision INTEGER NOT NULL DEFAULT 0,rules_json TEXT,created_at INTEGER NOT NULL)",
"CREATE TABLE teams(id TEXT PRIMARY KEY,league_id TEXT NOT NULL REFERENCES leagues(id),user_id TEXT NOT NULL REFERENCES users(id),name TEXT NOT NULL,draft_order INTEGER,queue TEXT NOT NULL DEFAULT '[]',UNIQUE(league_id,user_id))",
"CREATE TABLE invites(token_hash TEXT PRIMARY KEY,league_id TEXT NOT NULL REFERENCES leagues(id),expires_at INTEGER NOT NULL)",
"CREATE TABLE drafts(league_id TEXT PRIMARY KEY REFERENCES leagues(id),pick_number INTEGER NOT NULL DEFAULT 0,deadline INTEGER,remaining INTEGER NOT NULL DEFAULT 120)",
"CREATE TABLE picks(league_id TEXT NOT NULL REFERENCES leagues(id),number INTEGER NOT NULL,team_id TEXT NOT NULL REFERENCES teams(id),candidate_id TEXT NOT NULL,slot TEXT NOT NULL,automatic INTEGER NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(league_id,number),UNIQUE(league_id,candidate_id),UNIQUE(team_id,slot))",
"CREATE TABLE commands(league_id TEXT NOT NULL REFERENCES leagues(id),user_id TEXT NOT NULL REFERENCES users(id),request_id TEXT NOT NULL,request_hash TEXT NOT NULL,response TEXT NOT NULL,PRIMARY KEY(league_id,user_id,request_id))",
"CREATE TABLE events(id INTEGER PRIMARY KEY AUTOINCREMENT,league_id TEXT REFERENCES leagues(id),kind TEXT NOT NULL,body TEXT NOT NULL,created_at INTEGER NOT NULL)",
"CREATE INDEX team_memberships ON teams(user_id,league_id)",
"CREATE INDEX league_events ON events(league_id,id)",
"CREATE TABLE sources(id TEXT PRIMARY KEY,kind TEXT NOT NULL,url TEXT NOT NULL,config TEXT NOT NULL,enabled INTEGER NOT NULL DEFAULT 0,verified INTEGER NOT NULL DEFAULT 0,last_checked INTEGER,last_success INTEGER,last_changed INTEGER,last_error TEXT,etag TEXT,last_modified TEXT,last_payload TEXT,next_check INTEGER NOT NULL DEFAULT 0,failures INTEGER NOT NULL DEFAULT 0)",
"CREATE TABLE source_checks(source_id TEXT NOT NULL REFERENCES sources(id),race_id TEXT NOT NULL,kind TEXT NOT NULL,checked_at INTEGER NOT NULL,PRIMARY KEY(source_id,race_id,kind))",
"CREATE TABLE source_payloads(id TEXT PRIMARY KEY,source_id TEXT NOT NULL REFERENCES sources(id),retrieved_at INTEGER NOT NULL,body TEXT NOT NULL)",
"CREATE TABLE observations(id TEXT PRIMARY KEY,source_id TEXT NOT NULL REFERENCES sources(id),race_id TEXT NOT NULL,kind TEXT NOT NULL,source_time TEXT,received_at INTEGER NOT NULL,body TEXT NOT NULL)",
"CREATE INDEX race_observations ON observations(race_id,kind,received_at)",
"CREATE TABLE current_observations(race_id TEXT NOT NULL,kind TEXT NOT NULL,observation_id TEXT NOT NULL REFERENCES observations(id),PRIMARY KEY(race_id,kind))",
"CREATE TABLE coverage(race_id TEXT PRIMARY KEY,counts_source TEXT REFERENCES sources(id),outcome_source TEXT REFERENCES sources(id))",
"CREATE TABLE source_quarantine(id INTEGER PRIMARY KEY AUTOINCREMENT,source_id TEXT NOT NULL REFERENCES sources(id),retrieved_at INTEGER NOT NULL,reason TEXT NOT NULL,payload_id TEXT REFERENCES source_payloads(id))"};
for (auto const &statement : statements) {
auto result = co_await connection.async_execute(statement);
if (!result) {
co_return std::unexpected(result.error());
}
}
co_return std::expected<void, modulus::error>{};
}
}
auto fpol::game_migration() -> noto::migration {
return {"002_leagues", "Accounts, private leagues, drafts and source observations", "002-v1", migrate, {}};
}
auto fpol::ensure(bool condition, std::string const &message, int status) -> void {
if (!condition) {
throw fpol::api_error{status, message};
}
}
fpol::database::database(std::filesystem::path const &path)
: connection_{io_.get_executor(), noto::file_connection_config(path.string())} {
wait(connection_.async_connect());
wait(connection_.async_set_foreign_keys(true));
wait(connection_.async_set_busy_timeout(std::chrono::milliseconds{3000}));
}
fpol::database::~database() {
try {
if (transaction_) {
query("ROLLBACK");
}
wait(connection_.async_close());
} catch (...) {
}
}
auto fpol::database::query(std::string sql, std::vector<noto::sqlite_value> parameters) -> boost::json::array {
auto result = wait(connection_.async_execute(std::move(sql), std::move(parameters)));
auto rows = boost::json::array{};
for (auto const &row : result.rows) {
auto object = boost::json::object{};
for (std::size_t index = 0; index < row.size(); ++index) {
std::visit([&](auto const &value) {
if constexpr (std::is_same_v<std::decay_t<decltype(value)>, std::monostate>) {
object[result.columns[index]] = nullptr;
} else if constexpr (!std::is_same_v<std::decay_t<decltype(value)>, std::vector<std::byte>>) {
object[result.columns[index]] = value;
}
},
row[index].value);
}
rows.emplace_back(std::move(object));
}
return rows;
}
auto fpol::database::one(std::string sql, std::vector<noto::sqlite_value> parameters) -> boost::json::object {
auto rows = query(std::move(sql), std::move(parameters));
return rows.empty() ? boost::json::object{} : rows.front().as_object();
}
auto fpol::database::document(std::string const &id, std::string const &kind) -> boost::json::value {
auto row = one("SELECT body FROM documents WHERE id=? AND kind=?", {id, kind});
fpol::ensure(!row.empty(), "Unknown " + kind + " snapshot", 404);
return boost::json::parse(row.at("body").as_string());
}
auto fpol::database::begin() -> void {
query("BEGIN IMMEDIATE");
transaction_ = true;
}
auto fpol::database::commit() -> void {
query("COMMIT");
transaction_ = false;
}
auto fpol::database::backup(std::string const &path) -> void {
wait(connection_.async_backup_to_file(path));
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <filesystem>
#include <stdexcept>
#include <boost/asio/io_context.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/json.hpp>
#include <noto.hpp>
namespace fpol {
class api_error : public std::runtime_error {
public:
api_error(int status, std::string message)
: std::runtime_error{std::move(message)}, status{status} {}
int status;
};
auto ensure(bool condition, std::string const &message, int status = 400) -> void;
auto game_migration() -> noto::migration;
class database {
public:
explicit database(std::filesystem::path const &path);
~database();
auto query(std::string sql, std::vector<noto::sqlite_value> parameters = {}) -> boost::json::array;
auto one(std::string sql, std::vector<noto::sqlite_value> parameters = {}) -> boost::json::object;
auto document(std::string const &id, std::string const &kind) -> boost::json::value;
auto begin() -> void;
auto commit() -> void;
auto backup(std::string const &path) -> void;
private:
template <typename VALUE>
auto wait(boost::asio::awaitable<std::expected<VALUE, modulus::error>> operation) -> VALUE {
io_.restart();
auto future = boost::asio::co_spawn(io_, std::move(operation), boost::asio::use_future);
io_.run();
auto result = future.get();
if (!result) {
throw std::runtime_error(result.error().describe());
}
if constexpr (!std::is_void_v<VALUE>) {
return std::move(*result);
}
}
boost::asio::io_context io_;
noto::connection connection_;
bool transaction_{};
};
}
+462
View File
@@ -0,0 +1,462 @@
#include "application.hpp"
#include "crypto.hpp"
#include "results.hpp"
#include "national_races.hpp"
#include "domain.hpp"
#include <algorithm>
#include <array>
#include <map>
#include <set>
namespace {
auto string(boost::json::object const &value, std::string const &key) -> std::string {
return std::string{value.at(key).as_string()};
}
auto event(fpol::database &database, std::string const &league, std::string const &kind, boost::json::value body, std::int64_t now) -> void {
database.query("INSERT INTO events(league_id,kind,body,created_at) VALUES(?,?,?,?)", {league, kind, boost::json::serialize(body), now});
database.query("UPDATE leagues SET revision=revision+1 WHERE id=?", {league});
}
auto member(fpol::database &database, std::string const &league, std::string const &user) -> boost::json::object {
auto team = database.one("SELECT * FROM teams WHERE league_id=? AND user_id=?", {league, user});
fpol::ensure(!team.empty(), "This league is private", 403);
return team;
}
auto league_row(fpol::database &database, std::string const &id) -> boost::json::object {
auto league = database.one("SELECT * FROM leagues WHERE id=?", {id});
fpol::ensure(!league.empty(), "League not found", 404);
return league;
}
auto legal_candidates(fpol::catalog const &catalog, boost::json::array const &teams, boost::json::array const &picks, std::string const &team) -> std::map<std::string, std::string> {
auto taken = std::set<std::string>{};
auto filled = std::map<std::string, std::set<std::string>>{};
for (auto const &pick : picks) {
taken.insert(fpol::text(pick, "candidate_id"));
filled[fpol::text(pick, "team_id")].insert(fpol::text(pick, "slot"));
}
auto pool = std::map<std::string, std::map<std::string, int>>{};
auto needs = std::map<std::string, std::map<std::string, int>>{};
for (auto const &[id, candidate] : catalog.candidates) {
if (!taken.contains(id)) {
auto office = catalog.races.at(candidate.race_id).office;
++pool[office][candidate.affiliation];
++pool[office]["total"];
}
}
for (auto const &entry : teams) {
for (auto const &office : {"house", "senate", "governor"}) {
for (auto const &group : {"democrat", "republican", "other", "flex"}) {
if (filled[fpol::text(entry, "id")].contains(std::string{office} + ":" + group)) {
continue;
}
if (std::string_view{group} != "flex") {
++needs[office][group];
}
++needs[office]["total"];
}
}
}
auto result = std::map<std::string, std::string>{};
for (auto const &[id, candidate] : catalog.candidates) {
if (taken.contains(id)) {
continue;
}
auto office = catalog.races.at(candidate.race_id).office;
auto group = candidate.affiliation;
auto slot = office + ":" + group;
if (filled[team].contains(slot)) {
group = "flex";
slot = office + ":flex";
}
if (filled[team].contains(slot)) {
continue;
}
auto available = pool[office];
auto needed = needs[office];
--available[candidate.affiliation];
--available["total"];
--needed["total"];
if (group != "flex") {
--needed[group];
}
auto legal = true;
for (auto const &[affiliation, count] : needed) {
legal = legal && available[affiliation] >= count;
}
if (legal) {
result.emplace(id, slot);
}
}
return result;
}
auto current_team(boost::json::array const &teams, std::int64_t pick) -> boost::json::object {
auto round = pick / static_cast<std::int64_t>(teams.size());
auto position = pick % static_cast<std::int64_t>(teams.size());
if (round % 2 == 1) {
position = static_cast<std::int64_t>(teams.size()) - 1 - position;
}
return teams.at(static_cast<std::size_t>(position)).as_object();
}
auto make_pick(fpol::database &database, boost::json::object const &league, std::string candidate_id, bool automatic, std::int64_t now) -> boost::json::value {
auto id = string(league, "id");
auto draft = database.one("SELECT * FROM drafts WHERE league_id=?", {id});
auto teams = database.query("SELECT * FROM teams WHERE league_id=? ORDER BY draft_order", {id});
auto picks = database.query("SELECT * FROM picks WHERE league_id=? ORDER BY number", {id});
auto catalog = fpol::read_catalog(database.document(string(league, "catalog_id"), "catalog"));
auto team = current_team(teams, draft.at("pick_number").as_int64());
auto legal = legal_candidates(catalog, teams, picks, string(team, "id"));
fpol::ensure(!legal.empty(), "No legal picks remain; commissioner review required", 409);
if (automatic) {
auto queue = boost::json::parse(string(team, "queue")).as_array();
for (auto const &entry : queue) {
if (entry.is_string() && legal.contains(std::string{entry.as_string()})) {
candidate_id = std::string{entry.as_string()};
break;
}
}
if (candidate_id.empty()) {
auto iterator = legal.begin();
std::advance(iterator, static_cast<long>(fpol::random_index(legal.size())));
candidate_id = iterator->first;
}
}
fpol::ensure(legal.contains(candidate_id), "Candidate is unavailable or would prevent a complete roster", 409);
auto number = draft.at("pick_number").as_int64();
database.query("INSERT INTO picks(league_id,number,team_id,candidate_id,slot,automatic,created_at) VALUES(?,?,?,?,?,?,?)", {id, number, string(team, "id"), candidate_id, legal.at(candidate_id), automatic ? 1 : 0, now});
auto complete = number + 1 == static_cast<std::int64_t>(teams.size() * 12);
database.query("UPDATE drafts SET pick_number=pick_number+1,deadline=?,remaining=120 WHERE league_id=?", {complete ? noto::sqlite_value{nullptr} : noto::sqlite_value{now + 120}, id});
if (complete) {
database.query("UPDATE leagues SET status='drafted' WHERE id=?", {id});
}
auto result = boost::json::object{{"number", number}, {"team_id", string(team, "id")}, {"candidate_id", candidate_id}, {"slot", legal.at(candidate_id)}, {"automatic", automatic}};
event(database, id, "pick", result, now);
return result;
}
}
auto fpol::current_catalog(fpol::database &database, bool demo) -> boost::json::value {
auto rows = database.query("SELECT body FROM documents WHERE kind='catalog' ORDER BY as_of DESC,id DESC");
for (auto const &row : rows) {
auto doc = boost::json::parse(fpol::text(row, "body"));
if (fpol::text(doc, "mode") == (demo ? "demo" : "real")) {
return doc;
}
}
return nullptr;
}
auto fpol::readiness(fpol::database &database, boost::json::object const &league, std::size_t players, bool allow_demo) -> boost::json::value {
auto problems = boost::json::array{};
if (players < 2) {
problems.emplace_back("Invite at least one friend to draft");
}
if (league.at("catalog_id").is_null()) {
problems.emplace_back("A real candidate catalog is needed");
return boost::json::object{{"ready", false}, {"problems", problems}};
}
auto document = database.document(string(league, "catalog_id"), "catalog");
auto catalog = fpol::read_catalog(document);
if (catalog.mode == "demo") {
if (!allow_demo) {
problems.emplace_back("Demo leagues are disabled");
}
} else {
auto complete = catalog.complete && catalog.races.size() == fpol::national_races.size();
auto represented = std::set<std::string>{};
for (auto const &[id, candidate] : catalog.candidates) {
represented.insert(candidate.race_id);
}
for (auto const &id : fpol::national_races) {
complete = complete && catalog.races.contains(std::string{id}) && represented.contains(std::string{id});
}
if (!complete) {
problems.emplace_back("National candidate coverage is incomplete");
}
}
auto capacity = fpol::capacity(catalog, static_cast<std::int64_t>(std::max<std::size_t>(2, players)));
if (!capacity.at("sufficient_candidates").as_bool()) {
problems.emplace_back("Not enough eligible candidates for every roster slot");
}
if (league.at("ratings_id").is_null()) {
problems.emplace_back("Frozen race ratings are needed");
} else {
auto ratings = database.document(string(league, "ratings_id"), "ratings");
fpol::validate_document(ratings, &catalog);
if (ratings.at("entries").as_array().size() != catalog.races.size()) {
problems.emplace_back("Some races have no rating");
}
}
auto uncovered = 0;
if (catalog.mode == "real") {
for (auto const &[id, race] : catalog.races) {
auto coverage = database.one("SELECT c.race_id FROM coverage c JOIN sources a ON a.id=c.counts_source JOIN sources b ON b.id=c.outcome_source WHERE c.race_id=? AND a.enabled=1 AND b.enabled=1 AND a.verified=1 AND b.verified=1", {id});
if (coverage.empty()) {
++uncovered;
}
}
}
if (uncovered) {
problems.emplace_back(std::to_string(uncovered) + " races still need verified counts and outcome coverage");
}
return boost::json::object{{"ready", problems.empty()}, {"problems", problems}, {"capacity", capacity}, {"uncovered_races", uncovered}};
}
auto fpol::league_view(fpol::database &database, std::string const &id, std::string const &user_id, std::int64_t now, bool allow_demo) -> boost::json::value {
auto mine = member(database, id, user_id);
auto league = league_row(database, id);
league["catalog_mode"] = league.at("catalog_id").is_null() ? boost::json::value{} : database.document(string(league, "catalog_id"), "catalog").at("mode");
auto teams = database.query("SELECT t.id,t.name,t.user_id,t.draft_order,u.username FROM teams t JOIN users u ON u.id=t.user_id WHERE league_id=? ORDER BY draft_order,t.id", {id});
auto picks = database.query("SELECT number,team_id,candidate_id,slot,automatic,created_at FROM picks WHERE league_id=? ORDER BY number", {id});
auto draft = database.one("SELECT * FROM drafts WHERE league_id=?", {id});
auto legal = boost::json::array{};
auto picker = boost::json::value{};
if (!draft.empty() && string(league, "status") != "drafted") {
auto team = current_team(teams, draft.at("pick_number").as_int64());
picker = team;
if (string(team, "id") == string(mine, "id")) {
auto catalog = fpol::read_catalog(database.document(string(league, "catalog_id"), "catalog"));
for (auto const &[candidate, slot] : legal_candidates(catalog, teams, picks, string(mine, "id"))) {
legal.emplace_back(candidate);
}
}
}
auto names = boost::json::object{};
if (!league.at("catalog_id").is_null()) {
auto document = database.document(string(league, "catalog_id"), "catalog");
auto wanted = std::set<std::string>{};
for (auto const &pick : picks) {
wanted.insert(fpol::text(pick, "candidate_id"));
}
auto private_queue = boost::json::parse(string(mine, "queue"));
for (auto const &entry : private_queue.as_array()) {
wanted.insert(std::string{entry.as_string()});
}
for (auto const &candidate : document.at("candidates").as_array()) {
if (wanted.contains(fpol::text(candidate, "id"))) {
names[fpol::text(candidate, "id")] = candidate.at("name");
}
}
}
return boost::json::object{{"league", league}, {"teams", teams}, {"my_team", boost::json::object{{"id", string(mine, "id")}, {"name", string(mine, "name")}, {"queue", boost::json::parse(string(mine, "queue"))}}}, {"candidate_names", names}, {"picks", picks}, {"draft", draft}, {"picker", picker}, {"legal_candidate_ids", legal}, {"server_time", now}, {"readiness", fpol::readiness(database, league, teams.size(), allow_demo)}, {"events", database.query("SELECT id,kind,body,created_at FROM events WHERE league_id=? ORDER BY id DESC LIMIT 20", {id})}};
}
auto fpol::advance_drafts(fpol::database &database, std::int64_t now, bool restart) -> void {
auto leagues = database.query("SELECT l.* FROM leagues l JOIN drafts d ON d.league_id=l.id WHERE l.status='drafting' AND (?=1 OR d.deadline<=?)", {restart ? 1 : 0, now});
for (auto const &value : leagues) {
auto league = value.as_object();
auto id = string(league, "id");
if (restart) {
database.query("UPDATE leagues SET status='paused' WHERE id=?", {id});
database.query("UPDATE drafts SET deadline=NULL,remaining=120 WHERE league_id=?", {id});
event(database, id, "restart_pause", boost::json::object{}, now);
} else {
try {
make_pick(database, league, {}, true, now);
} catch (fpol::api_error const &error) {
if (error.status != 409) {
throw;
}
database.query("UPDATE leagues SET status='paused' WHERE id=?", {id});
database.query("UPDATE drafts SET deadline=NULL,remaining=120 WHERE league_id=?", {id});
event(database, id, "needs_review", boost::json::object{{"message", error.what()}}, now);
}
}
}
}
auto fpol::game_action(fpol::database &database, std::string const &action, boost::json::value const &input, boost::json::object const &user, std::int64_t now, bool allow_demo) -> boost::json::value {
auto user_id = string(user, "id");
if (action == "leagues") {
return database.query("SELECT l.*,(SELECT json_extract(d.body,'$.mode') FROM documents d WHERE d.id=l.catalog_id) AS catalog_mode,t.name AS team_name,(SELECT COUNT(*) FROM teams x WHERE x.league_id=l.id) AS members FROM leagues l JOIN teams t ON t.league_id=l.id WHERE t.user_id=? ORDER BY l.created_at DESC", {user_id});
}
if (action == "league/create") {
auto name = fpol::text(input, "name");
auto team_name = fpol::text(input, "team_name");
auto maximum = fpol::integer(input, "max_teams");
ensure(name.size() <= 80 && team_name.size() <= 60 && maximum >= 2 && maximum <= 12, "Invalid league name, team name or league size");
auto catalog = input.as_object().contains("catalog_id") && !input.at("catalog_id").is_null() ? database.document(fpol::text(input, "catalog_id"), "catalog") : current_catalog(database);
if (!catalog.is_null()) {
ensure(fpol::text(catalog, "mode") == "real" || allow_demo, "Demo leagues are disabled", 403);
}
auto ratings_id = std::string{};
if (!catalog.is_null()) {
auto ratings = database.one("SELECT id FROM documents WHERE kind='ratings' AND catalog_id=? ORDER BY as_of DESC,id DESC LIMIT 1", {fpol::text(catalog, "id")});
if (!ratings.empty()) {
ratings_id = string(ratings, "id");
}
}
auto id = fpol::random_token(12);
database.query("INSERT INTO leagues(id,name,commissioner_id,max_teams,catalog_id,ratings_id,created_at) VALUES(?,?,?,?,?,?,?)", {id, name, user_id, maximum, catalog.is_null() ? noto::sqlite_value{nullptr} : noto::sqlite_value{fpol::text(catalog, "id")}, ratings_id.empty() ? noto::sqlite_value{nullptr} : noto::sqlite_value{ratings_id}, now});
database.query("INSERT INTO teams(id,league_id,user_id,name) VALUES(?,?,?,?)", {fpol::random_token(12), id, user_id, team_name});
return boost::json::object{{"league_id", id}};
}
if (action == "league/join") {
auto invite = database.one("SELECT l.* FROM invites i JOIN leagues l ON l.id=i.league_id WHERE i.token_hash=? AND i.expires_at>?", {fpol::digest(fpol::text(input, "invite")), now});
ensure(!invite.empty(), "Invitation is invalid or expired", 404);
auto id = string(invite, "id");
auto existing = database.one("SELECT id FROM teams WHERE league_id=? AND user_id=?", {id, user_id});
if (!existing.empty()) {
return boost::json::object{{"league_id", id}};
}
ensure(string(invite, "status") == "forming", "The draft has already started", 409);
auto count = database.one("SELECT COUNT(*) AS n FROM teams WHERE league_id=?", {id}).at("n").as_int64();
ensure(count < invite.at("max_teams").as_int64(), "League is full", 409);
auto name = fpol::text(input, "team_name");
ensure(name.size() <= 60, "Team name is too long");
database.query("INSERT INTO teams(id,league_id,user_id,name) VALUES(?,?,?,?)", {fpol::random_token(12), id, user_id, name});
event(database, id, "joined", boost::json::object{{"username", user.at("username")}}, now);
return boost::json::object{{"league_id", id}};
}
auto id = fpol::text(input, "league_id");
auto mine = member(database, id, user_id);
auto league = league_row(database, id);
if (action == "standings") {
return fpol::standings(database, league);
}
if (action == "league") {
return league_view(database, id, user_id, now, allow_demo);
}
if (action == "team/name") {
auto name = fpol::text(input, "name");
ensure(name.size() <= 60, "Team name is too long");
database.query("UPDATE teams SET name=? WHERE id=?", {name, string(mine, "id")});
event(database, id, "team_renamed", boost::json::object{{"team_id", string(mine, "id")}}, now);
return boost::json::object{};
}
if (action == "draft/queue") {
ensure(string(league, "status") != "drafted", "Draft is complete", 409);
ensure(input.at("candidates").is_array() && input.at("candidates").as_array().size() <= 200, "Queue must contain at most 200 candidates");
ensure(!league.at("catalog_id").is_null(), "No catalog available", 409);
auto catalog = fpol::read_catalog(database.document(string(league, "catalog_id"), "catalog"));
auto seen = std::set<std::string>{};
for (auto const &candidate : input.at("candidates").as_array()) {
ensure(candidate.is_string(), "Invalid queued candidate");
auto value = std::string{candidate.as_string()};
ensure(catalog.candidates.contains(value) && seen.insert(value).second, "Unknown or duplicate queued candidate");
}
database.query("UPDATE teams SET queue=? WHERE id=?", {boost::json::serialize(input.at("candidates")), string(mine, "id")});
return boost::json::object{};
}
if (action == "draft/pick") {
auto request_id = fpol::text(input, "request_id");
ensure(request_id.size() <= 120, "Invalid request ID");
auto request_hash = fpol::digest(boost::json::serialize(input));
auto prior = database.one("SELECT request_hash,response FROM commands WHERE league_id=? AND user_id=? AND request_id=?", {id, user_id, request_id});
if (!prior.empty()) {
ensure(string(prior, "request_hash") == request_hash, "Request ID already used for a different pick", 409);
return boost::json::parse(string(prior, "response"));
}
ensure(string(league, "status") == "drafting", "Draft is not running", 409);
auto draft = database.one("SELECT * FROM drafts WHERE league_id=?", {id});
ensure(draft.at("deadline").as_int64() > now, "The turn has expired", 409);
ensure(fpol::integer(input, "pick_number") == draft.at("pick_number").as_int64(), "Draft has advanced; refresh the current turn", 409);
auto teams = database.query("SELECT * FROM teams WHERE league_id=? ORDER BY draft_order", {id});
ensure(string(current_team(teams, draft.at("pick_number").as_int64()), "user_id") == user_id, "It is another team's turn", 403);
auto result = make_pick(database, league, fpol::text(input, "candidate_id"), false, now);
database.query("INSERT INTO commands VALUES(?,?,?,?,?)", {id, user_id, request_id, request_hash, boost::json::serialize(result)});
return result;
}
ensure(string(league, "commissioner_id") == user_id, "Commissioner access required", 403);
if (action == "league/name") {
auto name = fpol::text(input, "name");
ensure(name.size() <= 80, "League name is too long");
database.query("UPDATE leagues SET name=? WHERE id=?", {name, id});
event(database, id, "league_renamed", boost::json::object{}, now);
return boost::json::object{};
}
if (action == "league/ratings") {
ensure(string(league, "status") == "forming" && !league.at("catalog_id").is_null(), "Choose a catalog before importing ratings; imports close at draft start", 409);
auto catalog = fpol::read_catalog(database.document(string(league, "catalog_id"), "catalog"));
auto document = input.at("document");
ensure(fpol::text(document, "kind") == "ratings", "Expected a ratings snapshot");
fpol::validate_document(document, &catalog);
auto rating_id = fpol::text(document, "id");
auto body = boost::json::serialize(document);
auto previous = database.one("SELECT body FROM documents WHERE id=?", {rating_id});
ensure(previous.empty() || string(previous, "body") == body, "This immutable snapshot ID already exists with different contents", 409);
database.query("INSERT OR IGNORE INTO documents(id,kind,catalog_id,as_of,body) VALUES(?,'ratings',?,?,?)", {rating_id, catalog.id, fpol::text(document, "as_of"), body});
database.query("UPDATE leagues SET ratings_id=? WHERE id=?", {rating_id, id});
event(database, id, "ratings_imported", boost::json::object{{"ratings_id", rating_id}}, now);
return boost::json::object{{"ratings_id", rating_id}};
}
if (action == "league/invite") {
ensure(string(league, "status") == "forming", "Invitations close when the draft starts", 409);
auto token = fpol::random_token();
database.query("DELETE FROM invites WHERE league_id=?", {id});
database.query("INSERT INTO invites VALUES(?,?,?)", {fpol::digest(token), id, now + 7 * 86400});
return boost::json::object{{"invite", token}, {"expires_at", now + 7 * 86400}};
}
if (action == "league/revoke-invites") {
database.query("DELETE FROM invites WHERE league_id=?", {id});
return boost::json::object{};
}
if (action == "league/remove") {
ensure(string(league, "status") == "forming", "Membership is frozen after draft start", 409);
auto team_id = fpol::text(input, "team_id");
ensure(team_id != string(mine, "id"), "The commissioner cannot remove their own team");
database.query("DELETE FROM teams WHERE id=? AND league_id=?", {team_id, id});
database.query("DELETE FROM invites WHERE league_id=?", {id});
event(database, id, "member_removed", boost::json::object{{"team_id", team_id}}, now);
return boost::json::object{};
}
if (action == "league/settings") {
ensure(string(league, "status") == "forming", "Settings are frozen after draft start", 409);
auto name = fpol::text(input, "name");
ensure(name.size() <= 80, "League name is too long");
auto catalog = database.document(fpol::text(input, "catalog_id"), "catalog");
auto model = fpol::read_catalog(catalog);
ensure(model.mode == "real" || allow_demo, "Demo leagues are disabled", 403);
auto rating_id = std::string{};
if (input.as_object().contains("ratings_id") && !input.at("ratings_id").is_null()) {
rating_id = fpol::text(input, "ratings_id");
fpol::validate_document(database.document(rating_id, "ratings"), &model);
}
database.query("UPDATE leagues SET name=?,catalog_id=?,ratings_id=? WHERE id=?", {name, model.id, rating_id.empty() ? noto::sqlite_value{nullptr} : noto::sqlite_value{rating_id}, id});
database.query("UPDATE teams SET queue='[]' WHERE league_id=?", {id});
event(database, id, "settings_changed", boost::json::object{}, now);
return boost::json::object{};
}
if (action == "draft/start") {
ensure(string(league, "status") == "forming", "Draft already started", 409);
auto teams = database.query("SELECT * FROM teams WHERE league_id=? ORDER BY id", {id});
auto ready = fpol::readiness(database, league, teams.size(), allow_demo);
ensure(ready.at("ready").as_bool(), "League data or membership is not ready: " + boost::json::serialize(ready.at("problems")), 409);
for (std::size_t index = teams.size(); index > 1; --index) {
std::swap(teams[index - 1], teams[fpol::random_index(index)]);
}
for (std::size_t index = 0; index < teams.size(); ++index) {
database.query("UPDATE teams SET draft_order=? WHERE id=?", {static_cast<std::int64_t>(index), fpol::text(teams[index], "id")});
}
database.query("INSERT INTO drafts(league_id,deadline) VALUES(?,?)", {id, now + 120});
auto frozen_rules = fpol::rules();
frozen_rules.as_object()["version"] = "v2-november-share";
frozen_rules.as_object()["vote_share_round"] = "november_first";
database.query("UPDATE leagues SET status='drafting',rules_json=? WHERE id=?", {boost::json::serialize(frozen_rules), id});
database.query("DELETE FROM invites WHERE league_id=?", {id});
event(database, id, "draft_started", boost::json::object{}, now);
return boost::json::object{};
}
if (action == "draft/pause") {
ensure(string(league, "status") == "drafting", "Draft is not running", 409);
database.query("UPDATE drafts SET remaining=MAX(0,deadline-?),deadline=NULL WHERE league_id=?", {now, id});
database.query("UPDATE leagues SET status='paused' WHERE id=?", {id});
event(database, id, "paused", boost::json::object{}, now);
return boost::json::object{};
}
if (action == "draft/resume") {
ensure(string(league, "status") == "paused", "Draft is not paused", 409);
database.query("UPDATE drafts SET deadline=?+remaining WHERE league_id=?", {now, id});
database.query("UPDATE leagues SET status='drafting' WHERE id=?", {id});
event(database, id, "resumed", boost::json::object{}, now);
return boost::json::object{};
}
throw fpol::api_error{404, "Unknown action"};
}
+106
View File
@@ -0,0 +1,106 @@
#include "ingestion.hpp"
#include "store.hpp"
#include "domain.hpp"
#include <csignal>
#include <iostream>
#include <fstream>
#include <thread>
namespace {
volatile std::sig_atomic_t stopped = 0;
void stop(int) {
stopped = 1;
}
auto execute(fpol::store &store, std::filesystem::path path, std::vector<std::string> args) -> boost::asio::awaitable<void> {
co_await store.initialize();
auto action = args.at(0);
if (action == "probe") {
fpol::ensure(args.size() == 2, "probe SOURCE_ID");
co_await fpol::poll_source(path, args[1]);
co_return;
}
if (action == "once" || action == "run") {
do {
auto ids = boost::json::array{};
{
auto database = fpol::database{path};
ids = database.query("SELECT id FROM sources WHERE enabled=1 AND next_check<=strftime('%s','now')");
}
for (auto const &row : ids) {
if (stopped) {
break;
}
co_await fpol::poll_source(path, fpol::text(row, "id"));
}
if (action == "run" && !stopped) {
std::this_thread::sleep_for(std::chrono::seconds{1});
}
} while (action == "run" && !stopped);
co_return;
}
auto database = fpol::database{path};
database.begin();
auto result = boost::json::value{};
if (action == "configure" && args.size() == 2) {
result = fpol::configure_source(database, fpol::read_json(args[1]));
} else if (action == "activate" && args.size() == 2) {
result = fpol::activate_source(database, args[1]);
} else if (action == "watch" && args.size() == 2) {
auto source = database.one("SELECT kind FROM sources WHERE id=?", {args[1]});
fpol::ensure(!source.empty() && fpol::text(source, "kind") == "candidate-watch", "Only candidate-watch sources may be watched without result verification");
database.query("UPDATE sources SET enabled=1 WHERE id=?", {args[1]});
result = boost::json::object{{"watching", args[1]}};
} else if (action == "disable" && args.size() == 2) {
database.query("UPDATE sources SET enabled=0 WHERE id=?", {args[1]});
result = boost::json::object{{"disabled", args[1]}};
} else if (action == "replay" && args.size() == 3) {
auto file = std::ifstream{args[2], std::ios::binary};
fpol::ensure(static_cast<bool>(file), "Cannot read replay file");
auto body = std::string{std::istreambuf_iterator<char>{file}, {}};
result = fpol::accept_payload(database, args[1], body, std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(), false);
} else if (action == "status" && args.size() == 1) {
result = database.query("SELECT id,kind,enabled,verified,last_checked,last_success,last_changed,last_error FROM sources ORDER BY id");
} else {
throw std::invalid_argument{"Unknown command or wrong arguments; use --help"};
}
database.commit();
std::cout << boost::json::serialize(result) << '\n';
}
}
int main(int argc, char **argv) {
if (argc == 2 && std::string_view{argv[1]} == "--help") {
std::cout << "fpol_ingest [--database PATH] configure FILE | probe ID | activate ID | watch ID | disable ID | replay ID FILE | status | once | run\nReplays never verify a source or publish to real catalogs.\n";
return 0;
}
try {
auto index = 1;
auto path = std::filesystem::path{"var/fpol.sqlite"};
if (index < argc && std::string_view{argv[index]} == "--database") {
fpol::ensure(index + 1 < argc, "Missing database");
path = argv[++index];
++index;
}
auto args = std::vector<std::string>{};
while (index < argc) {
args.emplace_back(argv[index++]);
}
fpol::ensure(!args.empty(), "Missing command");
std::signal(SIGINT, stop);
std::signal(SIGTERM, stop);
auto io = boost::asio::io_context{};
auto store = fpol::store{io.get_executor(), path};
auto future = boost::asio::co_spawn(io, execute(store, path, args), boost::asio::use_future);
io.run();
future.get();
return 0;
} catch (std::exception const &error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+284
View File
@@ -0,0 +1,284 @@
#include "ingestion.hpp"
#include "domain.hpp"
#include "crypto.hpp"
#include <chrono>
#include <regex>
#include <set>
#include <ligo.hpp>
namespace {
auto now() -> std::int64_t {
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
auto source_time(boost::json::value const &value) -> boost::json::value {
if (value.is_null()) {
return nullptr;
}
fpol::ensure(value.is_string(), "Invalid source timestamp");
auto text = std::string{value.as_string()};
if (text.ends_with("+00:00")) {
text = text.substr(0, text.size() - 6) + "Z";
}
fpol::ensure(std::regex_match(text, std::regex{"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z"}), "Source timestamp needs an explicit UTC normalization");
auto date = std::chrono::year_month_day{std::chrono::year{std::stoi(text.substr(0, 4))}, std::chrono::month{static_cast<unsigned>(std::stoi(text.substr(5, 2)))}, std::chrono::day{static_cast<unsigned>(std::stoi(text.substr(8, 2)))}};
fpol::ensure(date.ok() && std::stoi(text.substr(11, 2)) < 24 && std::stoi(text.substr(14, 2)) < 60 && std::stoi(text.substr(17, 2)) < 60, "Invalid source timestamp");
return boost::json::value{text};
}
auto normalize(boost::json::value const &config, boost::json::value const &payload, fpol::catalog const &catalog) -> boost::json::array {
auto entries = boost::json::array{};
auto kind = fpol::text(config, "kind");
if (kind == "openamerica") {
fpol::ensure(fpol::integer(payload, "api") == 1 && fpol::integer(payload, "year") == 2026, "Unexpected Open America API/election version");
fpol::ensure(fpol::text(payload, "office") == fpol::text(config, "office"), "Unexpected office");
fpol::ensure(payload.at("stale").is_bool() && !payload.at("stale").as_bool(), "Provider reports stale data");
auto time = source_time(payload.at("as_of"));
for (auto const &mapping : config.at("races").as_array()) {
auto key = fpol::text(mapping, "provider_id");
auto race = payload.at("races").as_object().if_contains(key);
if (!race) {
continue;
}
auto votes = boost::json::array{};
auto totals = std::map<std::string, std::int64_t>{};
auto seen = std::set<std::string>{};
auto const &candidates = mapping.at("candidates").as_object();
for (auto const &candidate : race->at("cands").as_array()) {
auto identity = fpol::text(candidate, "n") + "|" + std::string{candidate.at("p").as_string()};
fpol::ensure(seen.insert(identity).second, "Duplicate provider ballot line");
auto id = candidates.if_contains(identity);
fpol::ensure(id != nullptr, "Unmapped provider candidacy: " + identity);
if (id->is_null()) {
continue;
}
auto count = fpol::integer(candidate, "v");
fpol::ensure(count >= 0 && count <= 1000000000, "Invalid provider votes");
totals[std::string{id->as_string()}] += count;
}
for (auto const &[candidate, votes_cast] : totals) {
votes.emplace_back(boost::json::object{{"candidate_id", candidate}, {"votes", votes_cast}});
}
entries.emplace_back(boost::json::object{{"race_id", mapping.at("race_id")}, {"kind", "counts"}, {"round", "november_first"}, {"source_time", time}, {"total_votes", race->at("total")}, {"votes", votes}});
}
} else if (kind == "official-json") {
fpol::ensure(fpol::integer(payload, "schema_version") == 1, "Unexpected official JSON schema");
entries = payload.at("observations").as_array();
} else {
throw fpol::api_error{409, "This source is capture-only until its adapter contract is reviewed"};
}
auto selected = std::set<std::pair<std::string, std::string>>{};
for (auto const &entry : entries) {
auto id = fpol::text(entry, "race_id");
auto role = fpol::text(entry, "kind");
fpol::ensure(catalog.races.contains(id) && selected.emplace(id, role).second, "Unknown or duplicate race observation");
auto assigned = false;
for (auto const &race : config.at("races").as_array()) {
if (fpol::text(race, "race_id") == id && fpol::text(race, "role") == role) {
assigned = true;
}
}
fpol::ensure(assigned, "Observation role/race is outside reviewed source assignments");
source_time(entry.at("source_time"));
if (role == "counts") {
fpol::ensure(fpol::text(entry, "round") == "november_first", "Later-round counts must never replace November first-choice counts");
auto total = fpol::integer(entry, "total_votes");
fpol::ensure(total >= 0 && total <= 1000000000, "Invalid total votes");
auto sum = std::int64_t{};
auto seen = std::set<std::string>{};
for (auto const &vote : entry.at("votes").as_array()) {
auto candidate = fpol::text(vote, "candidate_id");
auto count = fpol::integer(vote, "votes");
fpol::ensure(catalog.candidates.contains(candidate) && catalog.candidates.at(candidate).race_id == id && seen.insert(candidate).second, "Unknown, duplicate or wrong-race candidacy");
fpol::ensure(count >= 0 && count <= total, "Invalid candidate votes");
sum += count;
}
fpol::ensure(sum <= total, "Candidate votes exceed total");
} else {
fpol::ensure(role == "outcome", "Invalid observation role");
auto status = fpol::text(entry, "status");
fpol::ensure(status == "reporting" || status == "pending_round" || status == "called" || status == "certified", "Invalid outcome status");
fpol::text(entry, "round");
auto winner = entry.at("winner_id");
fpol::ensure((status == "called" || status == "certified") == !winner.is_null(), "Winner and outcome status disagree");
if (!winner.is_null()) {
auto candidate = fpol::text(entry, "winner_id");
fpol::ensure(catalog.candidates.contains(candidate) && catalog.candidates.at(candidate).race_id == id, "Unknown outcome winner");
}
}
}
return entries;
}
}
auto fpol::configure_source(fpol::database &database, boost::json::value const &config) -> boost::json::value {
auto id = fpol::text(config, "id");
auto kind = fpol::text(config, "kind");
auto url = fpol::text(config, "url");
ensure(std::regex_match(id, std::regex{"[a-z0-9_-]{1,80}"}), "Invalid source ID");
ensure(std::regex_match(url, std::regex{"https://[a-zA-Z0-9.-]+(/[^ #]*)?"}), "Sources require a standard HTTPS host without credentials, ports or fragments");
ensure(kind == "openamerica" || kind == "official-json" || kind == "civicapi-probe" || kind == "candidate-watch", "Unsupported source adapter");
auto catalog = fpol::read_catalog(database.document(fpol::text(config, "catalog_id"), "catalog"));
auto assigned = std::set<std::pair<std::string, std::string>>{};
for (auto const &race : config.at("races").as_array()) {
auto race_id = fpol::text(race, "race_id");
auto role = fpol::text(race, "role");
ensure(catalog.races.contains(race_id) && (role == "counts" || role == "outcome") && assigned.emplace(race_id, role).second, "Invalid source assignment");
if (kind == "openamerica") {
ensure(role == "counts", "Open America does not provide race calls");
fpol::text(race, "provider_id");
for (auto const &entry : race.at("candidates").as_object()) {
if (!entry.value().is_null()) {
auto candidate = std::string{entry.value().as_string()};
ensure(catalog.candidates.contains(candidate) && catalog.candidates.at(candidate).race_id == race_id, "Invalid candidacy mapping");
}
}
}
}
if (kind == "openamerica") {
ensure(url.starts_with("https://openamerica.io/elections/results.json?"), "Unexpected Open America endpoint");
fpol::text(config, "office");
source_time(config.at("counts_until"));
ensure(!config.at("counts_until").is_null(), "Counts cutoff is required to avoid later-round overwrites");
}
auto serialized = boost::json::serialize(config);
auto previous = database.one("SELECT config FROM sources WHERE id=?", {id});
if (!previous.empty() && fpol::text(previous, "config") == serialized) {
return boost::json::object{{"id", id}, {"unchanged", true}};
}
database.query("DELETE FROM source_checks WHERE source_id=?", {id});
database.query("INSERT INTO sources(id,kind,url,config) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,url=excluded.url,config=excluded.config,enabled=0,verified=0,last_success=NULL,etag=NULL,last_modified=NULL,last_payload=NULL,next_check=0", {id, kind, url, serialized});
return boost::json::object{{"id", id}, {"enabled", false}, {"verified", false}};
}
auto fpol::accept_payload(fpol::database &database, std::string const &id, std::string const &body, std::int64_t time, bool network) -> boost::json::value {
auto source = database.one("SELECT * FROM sources WHERE id=?", {id});
ensure(!source.empty(), "Unknown source", 404);
auto config = boost::json::parse(fpol::text(source, "config"));
auto payload_id = fpol::digest(id + body);
database.query("INSERT OR IGNORE INTO source_payloads VALUES(?,?,?,?)", {payload_id, id, time, body});
try {
auto kind = fpol::text(source, "kind");
if (kind == "candidate-watch" || kind == "civicapi-probe") {
database.query("UPDATE sources SET last_checked=?,last_success=?,last_changed=CASE WHEN last_payload IS NULL OR last_payload!=? THEN ? ELSE last_changed END,last_payload=?,last_error=?,next_check=? WHERE id=?", {time, time, payload_id, time, payload_id, kind == "candidate-watch" ? "Candidate source captured; review changes before import" : "Captured only: civicAPI schema/mappings still require validation", time + 86400, id});
return boost::json::object{{"captured", true}, {"verified", false}, {"payload_id", payload_id}};
}
if (kind == "openamerica") {
auto cutoff = std::string{source_time(config.at("counts_until")).as_string()};
auto date = std::chrono::year_month_day{std::chrono::year{std::stoi(cutoff.substr(0, 4))}, std::chrono::month{static_cast<unsigned>(std::stoi(cutoff.substr(5, 2)))}, std::chrono::day{static_cast<unsigned>(std::stoi(cutoff.substr(8, 2)))}};
auto seconds = std::chrono::duration_cast<std::chrono::seconds>(std::chrono::sys_days{date}.time_since_epoch()).count() + std::stoi(cutoff.substr(11, 2)) * 3600 + std::stoi(cutoff.substr(14, 2)) * 60 + std::stoi(cutoff.substr(17, 2));
ensure(time < seconds, "Automatic counts cutoff reached; use a reviewed first-round correction import");
}
auto catalog = fpol::read_catalog(database.document(fpol::text(config, "catalog_id"), "catalog"));
auto entries = normalize(config, boost::json::parse(body), catalog);
// Validate the complete response before writing any observation. A bad race quarantines this response.
auto changed = 0;
for (auto const &entry : entries) {
auto race = fpol::text(entry, "race_id");
auto role = fpol::text(entry, "kind");
auto normalized = entry.as_object();
normalized["source_time"] = source_time(entry.at("source_time"));
auto serialized = boost::json::serialize(normalized);
auto observation_id = fpol::digest(id + serialized);
database.query("INSERT OR IGNORE INTO observations VALUES(?,?,?,?,?,?,?)", {observation_id, id, race, role, normalized.at("source_time").is_null() ? noto::sqlite_value{nullptr} : noto::sqlite_value{fpol::text(normalized, "source_time")}, time, serialized});
if (network) {
database.query("INSERT INTO source_checks VALUES(?,?,?,?) ON CONFLICT(source_id,race_id,kind) DO UPDATE SET checked_at=excluded.checked_at", {id, race, role, time});
}
// Offline replays are fixtures only and cannot replace data in a real catalog.
if (!network && catalog.mode != "demo") {
continue;
}
auto previous = database.one("SELECT o.id,o.body,o.source_time,o.source_id FROM current_observations c JOIN observations o ON o.id=c.observation_id WHERE c.race_id=? AND c.kind=?", {race, role});
auto coverage = database.one("SELECT * FROM coverage WHERE race_id=?", {race});
auto column = role == "counts" ? "counts_source" : "outcome_source";
if (!network && catalog.mode == "demo" && (coverage.empty() || coverage.at(column).is_null())) {
database.query("INSERT INTO coverage(race_id," + std::string{column} + ") VALUES(?,?) ON CONFLICT(race_id) DO UPDATE SET " + column + "=excluded." + column, {race, id});
}
if (!coverage.empty() && !coverage.at(column).is_null() && fpol::text(coverage, column) != id) {
continue;
}
if (!previous.empty()) {
if (fpol::text(previous, "id") == observation_id) {
continue;
}
if (!previous.at("source_time").is_null() && (normalized.at("source_time").is_null() || fpol::text(normalized, "source_time") < fpol::text(previous, "source_time"))) {
continue;
}
auto old = boost::json::parse(fpol::text(previous, "body"));
if (role == "outcome" && fpol::text(old, "status") == "certified" && fpol::text(entry, "status") != "certified") {
continue;
}
}
database.query("INSERT INTO current_observations VALUES(?,?,?) ON CONFLICT(race_id,kind) DO UPDATE SET observation_id=excluded.observation_id", {race, role, observation_id});
++changed;
}
auto verified = network && !entries.empty();
database.query("UPDATE sources SET last_checked=?,last_success=?,last_changed=CASE WHEN ?>0 THEN ? ELSE last_changed END,last_error=NULL,failures=0,next_check=?,verified=CASE WHEN ? THEN 1 ELSE verified END WHERE id=?", {time, time, changed, time, time + 60, verified ? 1 : 0, id});
return boost::json::object{{"observations", entries.size()}, {"changed", changed}, {"verified", verified}, {"payload_id", payload_id}};
} catch (std::exception const &error) {
database.query("INSERT INTO source_quarantine(source_id,retrieved_at,reason,payload_id) VALUES(?,?,?,?)", {id, time, std::string{error.what()}, payload_id});
database.query("UPDATE sources SET last_checked=?,last_error=?,failures=failures+1,next_check=?+MIN(3600,60*(1 << MIN(failures,6))) WHERE id=?", {time, std::string{error.what()}, time, id});
return boost::json::object{{"quarantined", true}, {"message", error.what()}, {"payload_id", payload_id}};
}
}
auto fpol::activate_source(fpol::database &database, std::string const &id) -> boost::json::value {
auto source = database.one("SELECT * FROM sources WHERE id=?", {id});
ensure(!source.empty(), "Unknown source", 404);
ensure(source.at("verified").as_int64() == 1 && source.at("last_error").is_null(), "A successful live capture and mapping validation are required before activation", 409);
auto config = boost::json::parse(fpol::text(source, "config"));
for (auto const &race : config.at("races").as_array()) {
ensure(!database.one("SELECT race_id FROM source_checks WHERE source_id=? AND race_id=? AND kind=?", {id, fpol::text(race, "race_id"), fpol::text(race, "role")}).empty(), "Every assigned race/role needs its own validated live observation", 409);
auto column = fpol::text(race, "role") == "counts" ? "counts_source" : "outcome_source";
database.query("INSERT INTO coverage(race_id," + std::string{column} + ") VALUES(?,?) ON CONFLICT(race_id) DO UPDATE SET " + column + "=excluded." + column, {fpol::text(race, "race_id"), id});
database.query("DELETE FROM current_observations WHERE race_id=? AND kind=? AND observation_id IN (SELECT id FROM observations WHERE source_id!=?)", {fpol::text(race, "race_id"), fpol::text(race, "role"), id});
}
database.query("UPDATE sources SET enabled=1,etag=NULL,last_modified=NULL,next_check=0 WHERE id=?", {id});
return boost::json::object{{"id", id}, {"enabled", true}};
}
auto fpol::poll_source(std::filesystem::path path, std::string id) -> boost::asio::awaitable<void> {
auto source = boost::json::object{};
{
auto database = fpol::database{path};
source = database.one("SELECT * FROM sources WHERE id=?", {id});
}
ensure(!source.empty(), "Unknown source", 404);
auto url = fpol::text(source, "url");
auto match = std::smatch{};
ensure(std::regex_match(url, match, std::regex{"https://([a-zA-Z0-9.-]+)(/[^ ]*)?"}), "Only standard HTTPS endpoints are supported");
auto options = ligo::http::client_options{};
options.timeout = std::chrono::seconds{25};
options.security.mode = ligo::security_mode::TLS;
options.security.verify = ligo::verify_mode::PEER;
auto client = ligo::http::client{{match[1].str(), 443}, options};
auto request = ligo::http::request{};
request.method = ligo::http::method::GET;
request.target = match[2].matched ? match[2].str() : "/";
request.headers = {{"Accept", "application/json"}, {"Accept-Encoding", "identity"}, {"User-Agent", "product-fpol/0.1 (+private fantasy game)"}};
if (!source.at("etag").is_null()) {
request.headers["If-None-Match"] = fpol::text(source, "etag");
}
if (!source.at("last_modified").is_null()) {
request.headers["If-Modified-Since"] = fpol::text(source, "last_modified");
}
auto response = co_await client.exchange(request);
auto time = now();
auto database = fpol::database{path};
database.begin();
if (response && response->status == 304) {
database.query("UPDATE sources SET last_checked=?,last_success=?,last_error=NULL,failures=0,next_check=? WHERE id=?", {time, time, time + (fpol::text(source, "kind") == "candidate-watch" ? 86400 : 60), id});
} else if (response && response->status == 200) {
auto accepted = accept_payload(database, id, response->body, time, true);
if (!accepted.as_object().contains("quarantined")) {
database.query("UPDATE sources SET etag=?,last_modified=? WHERE id=?", {response->headers.contains("etag") ? noto::sqlite_value{response->headers.at("etag")} : noto::sqlite_value{nullptr}, response->headers.contains("last-modified") ? noto::sqlite_value{response->headers.at("last-modified")} : noto::sqlite_value{nullptr}, id});
}
} else {
auto message = response ? "HTTP " + std::to_string(response->status) : response.error().describe();
database.query("UPDATE sources SET last_checked=?,last_error=?,failures=failures+1,next_check=?+MIN(3600,60*(1 << MIN(failures,6))) WHERE id=?", {time, message, time, id});
}
database.commit();
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "database.hpp"
namespace fpol {
auto configure_source(fpol::database &database, boost::json::value const &config) -> boost::json::value;
auto accept_payload(fpol::database &database, std::string const &source_id, std::string const &body, std::int64_t now, bool network) -> boost::json::value;
auto activate_source(fpol::database &database, std::string const &id) -> boost::json::value;
auto poll_source(std::filesystem::path path, std::string id) -> boost::asio::awaitable<void>;
}
+16 -3
View File
@@ -16,14 +16,24 @@ auto parse_options(int argc, char **argv) -> fpol::server_options {
auto options = fpol::server_options{};
for (auto index = 1; index < argc; ++index) {
auto name = std::string_view{argv[index]};
if (name != "--host" && name != "--port" && name != "--web-root" && name != "--database") {
if (name == "--allow-demo") {
options.allow_demo = true;
continue;
}
if (name == "--secure-cookies") {
options.secure_cookies = true;
continue;
}
if (name != "--origin" && name != "--host" && name != "--port" && name != "--web-root" && name != "--database") {
throw std::invalid_argument("Unknown option: " + std::string{name});
}
if (++index == argc || std::string_view{argv[index]}.empty()) {
throw std::invalid_argument("Missing value for " + std::string{name});
}
auto value = std::string_view{argv[index]};
if (name == "--host") {
if (name == "--origin") {
options.origin = value;
} else if (name == "--host") {
options.host = value;
} else if (name == "--database") {
options.database = value;
@@ -38,6 +48,9 @@ auto parse_options(int argc, char **argv) -> fpol::server_options {
options.port = static_cast<std::uint16_t>(port);
}
}
if (options.secure_cookies && !options.origin.starts_with("https://")) {
throw std::invalid_argument("Secure cookies require an HTTPS origin");
}
return options;
}
@@ -45,7 +58,7 @@ auto parse_options(int argc, char **argv) -> fpol::server_options {
auto main(int argc, char **argv) -> int {
if (argc == 2 && std::string_view{argv[1]} == "--help") {
std::cout << "Usage: fpol_server [--host ADDRESS] [--port PORT] [--web-root DIRECTORY] [--database PATH]\n"
std::cout << "Usage: fpol_server [--host ADDRESS] [--port PORT] [--web-root DIRECTORY] [--database PATH] [--origin URL] [--secure-cookies] [--allow-demo]\n"
"Defaults: 127.0.0.1:8080, API only. Port 0 selects an available port.\n";
return 0;
}
+517
View File
@@ -0,0 +1,517 @@
#pragma once
#include <array>
#include <string_view>
namespace fpol {
inline constexpr auto national_races = std::array{
std::string_view{"2026-al-house-01-general"},
std::string_view{"2026-al-house-02-general"},
std::string_view{"2026-al-house-03-general"},
std::string_view{"2026-al-house-04-general"},
std::string_view{"2026-al-house-05-general"},
std::string_view{"2026-al-house-06-general"},
std::string_view{"2026-al-house-07-general"},
std::string_view{"2026-al-senate-general"},
std::string_view{"2026-al-governor-general"},
std::string_view{"2026-ak-house-01-general"},
std::string_view{"2026-ak-senate-general"},
std::string_view{"2026-ak-governor-general"},
std::string_view{"2026-az-house-01-general"},
std::string_view{"2026-az-house-02-general"},
std::string_view{"2026-az-house-03-general"},
std::string_view{"2026-az-house-04-general"},
std::string_view{"2026-az-house-05-general"},
std::string_view{"2026-az-house-06-general"},
std::string_view{"2026-az-house-07-general"},
std::string_view{"2026-az-house-08-general"},
std::string_view{"2026-az-house-09-general"},
std::string_view{"2026-az-governor-general"},
std::string_view{"2026-ar-house-01-general"},
std::string_view{"2026-ar-house-02-general"},
std::string_view{"2026-ar-house-03-general"},
std::string_view{"2026-ar-house-04-general"},
std::string_view{"2026-ar-senate-general"},
std::string_view{"2026-ar-governor-general"},
std::string_view{"2026-ca-house-01-general"},
std::string_view{"2026-ca-house-02-general"},
std::string_view{"2026-ca-house-03-general"},
std::string_view{"2026-ca-house-04-general"},
std::string_view{"2026-ca-house-05-general"},
std::string_view{"2026-ca-house-06-general"},
std::string_view{"2026-ca-house-07-general"},
std::string_view{"2026-ca-house-08-general"},
std::string_view{"2026-ca-house-09-general"},
std::string_view{"2026-ca-house-10-general"},
std::string_view{"2026-ca-house-11-general"},
std::string_view{"2026-ca-house-12-general"},
std::string_view{"2026-ca-house-13-general"},
std::string_view{"2026-ca-house-14-general"},
std::string_view{"2026-ca-house-15-general"},
std::string_view{"2026-ca-house-16-general"},
std::string_view{"2026-ca-house-17-general"},
std::string_view{"2026-ca-house-18-general"},
std::string_view{"2026-ca-house-19-general"},
std::string_view{"2026-ca-house-20-general"},
std::string_view{"2026-ca-house-21-general"},
std::string_view{"2026-ca-house-22-general"},
std::string_view{"2026-ca-house-23-general"},
std::string_view{"2026-ca-house-24-general"},
std::string_view{"2026-ca-house-25-general"},
std::string_view{"2026-ca-house-26-general"},
std::string_view{"2026-ca-house-27-general"},
std::string_view{"2026-ca-house-28-general"},
std::string_view{"2026-ca-house-29-general"},
std::string_view{"2026-ca-house-30-general"},
std::string_view{"2026-ca-house-31-general"},
std::string_view{"2026-ca-house-32-general"},
std::string_view{"2026-ca-house-33-general"},
std::string_view{"2026-ca-house-34-general"},
std::string_view{"2026-ca-house-35-general"},
std::string_view{"2026-ca-house-36-general"},
std::string_view{"2026-ca-house-37-general"},
std::string_view{"2026-ca-house-38-general"},
std::string_view{"2026-ca-house-39-general"},
std::string_view{"2026-ca-house-40-general"},
std::string_view{"2026-ca-house-41-general"},
std::string_view{"2026-ca-house-42-general"},
std::string_view{"2026-ca-house-43-general"},
std::string_view{"2026-ca-house-44-general"},
std::string_view{"2026-ca-house-45-general"},
std::string_view{"2026-ca-house-46-general"},
std::string_view{"2026-ca-house-47-general"},
std::string_view{"2026-ca-house-48-general"},
std::string_view{"2026-ca-house-49-general"},
std::string_view{"2026-ca-house-50-general"},
std::string_view{"2026-ca-house-51-general"},
std::string_view{"2026-ca-house-52-general"},
std::string_view{"2026-ca-governor-general"},
std::string_view{"2026-co-house-01-general"},
std::string_view{"2026-co-house-02-general"},
std::string_view{"2026-co-house-03-general"},
std::string_view{"2026-co-house-04-general"},
std::string_view{"2026-co-house-05-general"},
std::string_view{"2026-co-house-06-general"},
std::string_view{"2026-co-house-07-general"},
std::string_view{"2026-co-house-08-general"},
std::string_view{"2026-co-senate-general"},
std::string_view{"2026-co-governor-general"},
std::string_view{"2026-ct-house-01-general"},
std::string_view{"2026-ct-house-02-general"},
std::string_view{"2026-ct-house-03-general"},
std::string_view{"2026-ct-house-04-general"},
std::string_view{"2026-ct-house-05-general"},
std::string_view{"2026-ct-governor-general"},
std::string_view{"2026-de-house-01-general"},
std::string_view{"2026-de-senate-general"},
std::string_view{"2026-fl-house-01-general"},
std::string_view{"2026-fl-house-02-general"},
std::string_view{"2026-fl-house-03-general"},
std::string_view{"2026-fl-house-04-general"},
std::string_view{"2026-fl-house-05-general"},
std::string_view{"2026-fl-house-06-general"},
std::string_view{"2026-fl-house-07-general"},
std::string_view{"2026-fl-house-08-general"},
std::string_view{"2026-fl-house-09-general"},
std::string_view{"2026-fl-house-10-general"},
std::string_view{"2026-fl-house-11-general"},
std::string_view{"2026-fl-house-12-general"},
std::string_view{"2026-fl-house-13-general"},
std::string_view{"2026-fl-house-14-general"},
std::string_view{"2026-fl-house-15-general"},
std::string_view{"2026-fl-house-16-general"},
std::string_view{"2026-fl-house-17-general"},
std::string_view{"2026-fl-house-18-general"},
std::string_view{"2026-fl-house-19-general"},
std::string_view{"2026-fl-house-20-general"},
std::string_view{"2026-fl-house-21-general"},
std::string_view{"2026-fl-house-22-general"},
std::string_view{"2026-fl-house-23-general"},
std::string_view{"2026-fl-house-24-general"},
std::string_view{"2026-fl-house-25-general"},
std::string_view{"2026-fl-house-26-general"},
std::string_view{"2026-fl-house-27-general"},
std::string_view{"2026-fl-house-28-general"},
std::string_view{"2026-fl-senate-special"},
std::string_view{"2026-fl-governor-general"},
std::string_view{"2026-ga-house-01-general"},
std::string_view{"2026-ga-house-02-general"},
std::string_view{"2026-ga-house-03-general"},
std::string_view{"2026-ga-house-04-general"},
std::string_view{"2026-ga-house-05-general"},
std::string_view{"2026-ga-house-06-general"},
std::string_view{"2026-ga-house-07-general"},
std::string_view{"2026-ga-house-08-general"},
std::string_view{"2026-ga-house-09-general"},
std::string_view{"2026-ga-house-10-general"},
std::string_view{"2026-ga-house-11-general"},
std::string_view{"2026-ga-house-12-general"},
std::string_view{"2026-ga-house-13-general"},
std::string_view{"2026-ga-house-14-general"},
std::string_view{"2026-ga-senate-general"},
std::string_view{"2026-ga-governor-general"},
std::string_view{"2026-hi-house-01-general"},
std::string_view{"2026-hi-house-02-general"},
std::string_view{"2026-hi-governor-general"},
std::string_view{"2026-id-house-01-general"},
std::string_view{"2026-id-house-02-general"},
std::string_view{"2026-id-senate-general"},
std::string_view{"2026-id-governor-general"},
std::string_view{"2026-il-house-01-general"},
std::string_view{"2026-il-house-02-general"},
std::string_view{"2026-il-house-03-general"},
std::string_view{"2026-il-house-04-general"},
std::string_view{"2026-il-house-05-general"},
std::string_view{"2026-il-house-06-general"},
std::string_view{"2026-il-house-07-general"},
std::string_view{"2026-il-house-08-general"},
std::string_view{"2026-il-house-09-general"},
std::string_view{"2026-il-house-10-general"},
std::string_view{"2026-il-house-11-general"},
std::string_view{"2026-il-house-12-general"},
std::string_view{"2026-il-house-13-general"},
std::string_view{"2026-il-house-14-general"},
std::string_view{"2026-il-house-15-general"},
std::string_view{"2026-il-house-16-general"},
std::string_view{"2026-il-house-17-general"},
std::string_view{"2026-il-senate-general"},
std::string_view{"2026-il-governor-general"},
std::string_view{"2026-in-house-01-general"},
std::string_view{"2026-in-house-02-general"},
std::string_view{"2026-in-house-03-general"},
std::string_view{"2026-in-house-04-general"},
std::string_view{"2026-in-house-05-general"},
std::string_view{"2026-in-house-06-general"},
std::string_view{"2026-in-house-07-general"},
std::string_view{"2026-in-house-08-general"},
std::string_view{"2026-in-house-09-general"},
std::string_view{"2026-ia-house-01-general"},
std::string_view{"2026-ia-house-02-general"},
std::string_view{"2026-ia-house-03-general"},
std::string_view{"2026-ia-house-04-general"},
std::string_view{"2026-ia-senate-general"},
std::string_view{"2026-ia-governor-general"},
std::string_view{"2026-ks-house-01-general"},
std::string_view{"2026-ks-house-02-general"},
std::string_view{"2026-ks-house-03-general"},
std::string_view{"2026-ks-house-04-general"},
std::string_view{"2026-ks-senate-general"},
std::string_view{"2026-ks-governor-general"},
std::string_view{"2026-ky-house-01-general"},
std::string_view{"2026-ky-house-02-general"},
std::string_view{"2026-ky-house-03-general"},
std::string_view{"2026-ky-house-04-general"},
std::string_view{"2026-ky-house-05-general"},
std::string_view{"2026-ky-house-06-general"},
std::string_view{"2026-ky-senate-general"},
std::string_view{"2026-la-house-01-general"},
std::string_view{"2026-la-house-02-general"},
std::string_view{"2026-la-house-03-general"},
std::string_view{"2026-la-house-04-general"},
std::string_view{"2026-la-house-05-general"},
std::string_view{"2026-la-house-06-general"},
std::string_view{"2026-la-senate-general"},
std::string_view{"2026-me-house-01-general"},
std::string_view{"2026-me-house-02-general"},
std::string_view{"2026-me-senate-general"},
std::string_view{"2026-me-governor-general"},
std::string_view{"2026-md-house-01-general"},
std::string_view{"2026-md-house-02-general"},
std::string_view{"2026-md-house-03-general"},
std::string_view{"2026-md-house-04-general"},
std::string_view{"2026-md-house-05-general"},
std::string_view{"2026-md-house-06-general"},
std::string_view{"2026-md-house-07-general"},
std::string_view{"2026-md-house-08-general"},
std::string_view{"2026-md-governor-general"},
std::string_view{"2026-ma-house-01-general"},
std::string_view{"2026-ma-house-02-general"},
std::string_view{"2026-ma-house-03-general"},
std::string_view{"2026-ma-house-04-general"},
std::string_view{"2026-ma-house-05-general"},
std::string_view{"2026-ma-house-06-general"},
std::string_view{"2026-ma-house-07-general"},
std::string_view{"2026-ma-house-08-general"},
std::string_view{"2026-ma-house-09-general"},
std::string_view{"2026-ma-senate-general"},
std::string_view{"2026-ma-governor-general"},
std::string_view{"2026-mi-house-01-general"},
std::string_view{"2026-mi-house-02-general"},
std::string_view{"2026-mi-house-03-general"},
std::string_view{"2026-mi-house-04-general"},
std::string_view{"2026-mi-house-05-general"},
std::string_view{"2026-mi-house-06-general"},
std::string_view{"2026-mi-house-07-general"},
std::string_view{"2026-mi-house-08-general"},
std::string_view{"2026-mi-house-09-general"},
std::string_view{"2026-mi-house-10-general"},
std::string_view{"2026-mi-house-11-general"},
std::string_view{"2026-mi-house-12-general"},
std::string_view{"2026-mi-house-13-general"},
std::string_view{"2026-mi-senate-general"},
std::string_view{"2026-mi-governor-general"},
std::string_view{"2026-mn-house-01-general"},
std::string_view{"2026-mn-house-02-general"},
std::string_view{"2026-mn-house-03-general"},
std::string_view{"2026-mn-house-04-general"},
std::string_view{"2026-mn-house-05-general"},
std::string_view{"2026-mn-house-06-general"},
std::string_view{"2026-mn-house-07-general"},
std::string_view{"2026-mn-house-08-general"},
std::string_view{"2026-mn-senate-general"},
std::string_view{"2026-mn-governor-general"},
std::string_view{"2026-ms-house-01-general"},
std::string_view{"2026-ms-house-02-general"},
std::string_view{"2026-ms-house-03-general"},
std::string_view{"2026-ms-house-04-general"},
std::string_view{"2026-ms-senate-general"},
std::string_view{"2026-mo-house-01-general"},
std::string_view{"2026-mo-house-02-general"},
std::string_view{"2026-mo-house-03-general"},
std::string_view{"2026-mo-house-04-general"},
std::string_view{"2026-mo-house-05-general"},
std::string_view{"2026-mo-house-06-general"},
std::string_view{"2026-mo-house-07-general"},
std::string_view{"2026-mo-house-08-general"},
std::string_view{"2026-mt-house-01-general"},
std::string_view{"2026-mt-house-02-general"},
std::string_view{"2026-mt-senate-general"},
std::string_view{"2026-ne-house-01-general"},
std::string_view{"2026-ne-house-02-general"},
std::string_view{"2026-ne-house-03-general"},
std::string_view{"2026-ne-senate-general"},
std::string_view{"2026-ne-governor-general"},
std::string_view{"2026-nv-house-01-general"},
std::string_view{"2026-nv-house-02-general"},
std::string_view{"2026-nv-house-03-general"},
std::string_view{"2026-nv-house-04-general"},
std::string_view{"2026-nv-governor-general"},
std::string_view{"2026-nh-house-01-general"},
std::string_view{"2026-nh-house-02-general"},
std::string_view{"2026-nh-senate-general"},
std::string_view{"2026-nh-governor-general"},
std::string_view{"2026-nj-house-01-general"},
std::string_view{"2026-nj-house-02-general"},
std::string_view{"2026-nj-house-03-general"},
std::string_view{"2026-nj-house-04-general"},
std::string_view{"2026-nj-house-05-general"},
std::string_view{"2026-nj-house-06-general"},
std::string_view{"2026-nj-house-07-general"},
std::string_view{"2026-nj-house-08-general"},
std::string_view{"2026-nj-house-09-general"},
std::string_view{"2026-nj-house-10-general"},
std::string_view{"2026-nj-house-11-general"},
std::string_view{"2026-nj-house-12-general"},
std::string_view{"2026-nj-senate-general"},
std::string_view{"2026-nm-house-01-general"},
std::string_view{"2026-nm-house-02-general"},
std::string_view{"2026-nm-house-03-general"},
std::string_view{"2026-nm-senate-general"},
std::string_view{"2026-nm-governor-general"},
std::string_view{"2026-ny-house-01-general"},
std::string_view{"2026-ny-house-02-general"},
std::string_view{"2026-ny-house-03-general"},
std::string_view{"2026-ny-house-04-general"},
std::string_view{"2026-ny-house-05-general"},
std::string_view{"2026-ny-house-06-general"},
std::string_view{"2026-ny-house-07-general"},
std::string_view{"2026-ny-house-08-general"},
std::string_view{"2026-ny-house-09-general"},
std::string_view{"2026-ny-house-10-general"},
std::string_view{"2026-ny-house-11-general"},
std::string_view{"2026-ny-house-12-general"},
std::string_view{"2026-ny-house-13-general"},
std::string_view{"2026-ny-house-14-general"},
std::string_view{"2026-ny-house-15-general"},
std::string_view{"2026-ny-house-16-general"},
std::string_view{"2026-ny-house-17-general"},
std::string_view{"2026-ny-house-18-general"},
std::string_view{"2026-ny-house-19-general"},
std::string_view{"2026-ny-house-20-general"},
std::string_view{"2026-ny-house-21-general"},
std::string_view{"2026-ny-house-22-general"},
std::string_view{"2026-ny-house-23-general"},
std::string_view{"2026-ny-house-24-general"},
std::string_view{"2026-ny-house-25-general"},
std::string_view{"2026-ny-house-26-general"},
std::string_view{"2026-ny-governor-general"},
std::string_view{"2026-nc-house-01-general"},
std::string_view{"2026-nc-house-02-general"},
std::string_view{"2026-nc-house-03-general"},
std::string_view{"2026-nc-house-04-general"},
std::string_view{"2026-nc-house-05-general"},
std::string_view{"2026-nc-house-06-general"},
std::string_view{"2026-nc-house-07-general"},
std::string_view{"2026-nc-house-08-general"},
std::string_view{"2026-nc-house-09-general"},
std::string_view{"2026-nc-house-10-general"},
std::string_view{"2026-nc-house-11-general"},
std::string_view{"2026-nc-house-12-general"},
std::string_view{"2026-nc-house-13-general"},
std::string_view{"2026-nc-house-14-general"},
std::string_view{"2026-nc-senate-general"},
std::string_view{"2026-nd-house-01-general"},
std::string_view{"2026-oh-house-01-general"},
std::string_view{"2026-oh-house-02-general"},
std::string_view{"2026-oh-house-03-general"},
std::string_view{"2026-oh-house-04-general"},
std::string_view{"2026-oh-house-05-general"},
std::string_view{"2026-oh-house-06-general"},
std::string_view{"2026-oh-house-07-general"},
std::string_view{"2026-oh-house-08-general"},
std::string_view{"2026-oh-house-09-general"},
std::string_view{"2026-oh-house-10-general"},
std::string_view{"2026-oh-house-11-general"},
std::string_view{"2026-oh-house-12-general"},
std::string_view{"2026-oh-house-13-general"},
std::string_view{"2026-oh-house-14-general"},
std::string_view{"2026-oh-house-15-general"},
std::string_view{"2026-oh-senate-special"},
std::string_view{"2026-oh-governor-general"},
std::string_view{"2026-ok-house-01-general"},
std::string_view{"2026-ok-house-02-general"},
std::string_view{"2026-ok-house-03-general"},
std::string_view{"2026-ok-house-04-general"},
std::string_view{"2026-ok-house-05-general"},
std::string_view{"2026-ok-senate-general"},
std::string_view{"2026-ok-governor-general"},
std::string_view{"2026-or-house-01-general"},
std::string_view{"2026-or-house-02-general"},
std::string_view{"2026-or-house-03-general"},
std::string_view{"2026-or-house-04-general"},
std::string_view{"2026-or-house-05-general"},
std::string_view{"2026-or-house-06-general"},
std::string_view{"2026-or-senate-general"},
std::string_view{"2026-or-governor-general"},
std::string_view{"2026-pa-house-01-general"},
std::string_view{"2026-pa-house-02-general"},
std::string_view{"2026-pa-house-03-general"},
std::string_view{"2026-pa-house-04-general"},
std::string_view{"2026-pa-house-05-general"},
std::string_view{"2026-pa-house-06-general"},
std::string_view{"2026-pa-house-07-general"},
std::string_view{"2026-pa-house-08-general"},
std::string_view{"2026-pa-house-09-general"},
std::string_view{"2026-pa-house-10-general"},
std::string_view{"2026-pa-house-11-general"},
std::string_view{"2026-pa-house-12-general"},
std::string_view{"2026-pa-house-13-general"},
std::string_view{"2026-pa-house-14-general"},
std::string_view{"2026-pa-house-15-general"},
std::string_view{"2026-pa-house-16-general"},
std::string_view{"2026-pa-house-17-general"},
std::string_view{"2026-pa-governor-general"},
std::string_view{"2026-ri-house-01-general"},
std::string_view{"2026-ri-house-02-general"},
std::string_view{"2026-ri-senate-general"},
std::string_view{"2026-ri-governor-general"},
std::string_view{"2026-sc-house-01-general"},
std::string_view{"2026-sc-house-02-general"},
std::string_view{"2026-sc-house-03-general"},
std::string_view{"2026-sc-house-04-general"},
std::string_view{"2026-sc-house-05-general"},
std::string_view{"2026-sc-house-06-general"},
std::string_view{"2026-sc-house-07-general"},
std::string_view{"2026-sc-senate-general"},
std::string_view{"2026-sc-governor-general"},
std::string_view{"2026-sd-house-01-general"},
std::string_view{"2026-sd-senate-general"},
std::string_view{"2026-sd-governor-general"},
std::string_view{"2026-tn-house-01-general"},
std::string_view{"2026-tn-house-02-general"},
std::string_view{"2026-tn-house-03-general"},
std::string_view{"2026-tn-house-04-general"},
std::string_view{"2026-tn-house-05-general"},
std::string_view{"2026-tn-house-06-general"},
std::string_view{"2026-tn-house-07-general"},
std::string_view{"2026-tn-house-08-general"},
std::string_view{"2026-tn-house-09-general"},
std::string_view{"2026-tn-senate-general"},
std::string_view{"2026-tn-governor-general"},
std::string_view{"2026-tx-house-01-general"},
std::string_view{"2026-tx-house-02-general"},
std::string_view{"2026-tx-house-03-general"},
std::string_view{"2026-tx-house-04-general"},
std::string_view{"2026-tx-house-05-general"},
std::string_view{"2026-tx-house-06-general"},
std::string_view{"2026-tx-house-07-general"},
std::string_view{"2026-tx-house-08-general"},
std::string_view{"2026-tx-house-09-general"},
std::string_view{"2026-tx-house-10-general"},
std::string_view{"2026-tx-house-11-general"},
std::string_view{"2026-tx-house-12-general"},
std::string_view{"2026-tx-house-13-general"},
std::string_view{"2026-tx-house-14-general"},
std::string_view{"2026-tx-house-15-general"},
std::string_view{"2026-tx-house-16-general"},
std::string_view{"2026-tx-house-17-general"},
std::string_view{"2026-tx-house-18-general"},
std::string_view{"2026-tx-house-19-general"},
std::string_view{"2026-tx-house-20-general"},
std::string_view{"2026-tx-house-21-general"},
std::string_view{"2026-tx-house-22-general"},
std::string_view{"2026-tx-house-23-general"},
std::string_view{"2026-tx-house-24-general"},
std::string_view{"2026-tx-house-25-general"},
std::string_view{"2026-tx-house-26-general"},
std::string_view{"2026-tx-house-27-general"},
std::string_view{"2026-tx-house-28-general"},
std::string_view{"2026-tx-house-29-general"},
std::string_view{"2026-tx-house-30-general"},
std::string_view{"2026-tx-house-31-general"},
std::string_view{"2026-tx-house-32-general"},
std::string_view{"2026-tx-house-33-general"},
std::string_view{"2026-tx-house-34-general"},
std::string_view{"2026-tx-house-35-general"},
std::string_view{"2026-tx-house-36-general"},
std::string_view{"2026-tx-house-37-general"},
std::string_view{"2026-tx-house-38-general"},
std::string_view{"2026-tx-senate-general"},
std::string_view{"2026-tx-governor-general"},
std::string_view{"2026-ut-house-01-general"},
std::string_view{"2026-ut-house-02-general"},
std::string_view{"2026-ut-house-03-general"},
std::string_view{"2026-ut-house-04-general"},
std::string_view{"2026-vt-house-01-general"},
std::string_view{"2026-vt-governor-general"},
std::string_view{"2026-va-house-01-general"},
std::string_view{"2026-va-house-02-general"},
std::string_view{"2026-va-house-03-general"},
std::string_view{"2026-va-house-04-general"},
std::string_view{"2026-va-house-05-general"},
std::string_view{"2026-va-house-06-general"},
std::string_view{"2026-va-house-07-general"},
std::string_view{"2026-va-house-08-general"},
std::string_view{"2026-va-house-09-general"},
std::string_view{"2026-va-house-10-general"},
std::string_view{"2026-va-house-11-general"},
std::string_view{"2026-va-senate-general"},
std::string_view{"2026-wa-house-01-general"},
std::string_view{"2026-wa-house-02-general"},
std::string_view{"2026-wa-house-03-general"},
std::string_view{"2026-wa-house-04-general"},
std::string_view{"2026-wa-house-05-general"},
std::string_view{"2026-wa-house-06-general"},
std::string_view{"2026-wa-house-07-general"},
std::string_view{"2026-wa-house-08-general"},
std::string_view{"2026-wa-house-09-general"},
std::string_view{"2026-wa-house-10-general"},
std::string_view{"2026-wv-house-01-general"},
std::string_view{"2026-wv-house-02-general"},
std::string_view{"2026-wv-senate-general"},
std::string_view{"2026-wi-house-01-general"},
std::string_view{"2026-wi-house-02-general"},
std::string_view{"2026-wi-house-03-general"},
std::string_view{"2026-wi-house-04-general"},
std::string_view{"2026-wi-house-05-general"},
std::string_view{"2026-wi-house-06-general"},
std::string_view{"2026-wi-house-07-general"},
std::string_view{"2026-wi-house-08-general"},
std::string_view{"2026-wi-governor-general"},
std::string_view{"2026-wy-house-01-general"},
std::string_view{"2026-wy-senate-general"},
std::string_view{"2026-wy-governor-general"},
};
}
+128
View File
@@ -0,0 +1,128 @@
#include "results.hpp"
#include "domain.hpp"
#include <algorithm>
#include <chrono>
#include <map>
namespace {
auto timestamp() -> std::int64_t {
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
auto observation(fpol::database &database, std::string const &race, std::string const &kind) -> boost::json::value {
auto row = database.one("SELECT o.body,o.source_time,o.received_at,s.id AS source_id,s.kind,s.url,s.last_checked,s.last_success,s.last_error,s.verified,s.enabled FROM current_observations c JOIN observations o ON o.id=c.observation_id JOIN sources s ON s.id=o.source_id JOIN coverage v ON v.race_id=c.race_id WHERE c.race_id=? AND c.kind=? AND s.id=CASE WHEN c.kind='counts' THEN v.counts_source ELSE v.outcome_source END", {race, kind});
if (row.empty()) {
return nullptr;
}
auto body = boost::json::parse(fpol::text(row, "body")).as_object();
row.erase("body");
row["source_name"] = fpol::text(row, "kind") == "openamerica" ? "Official state election offices via Open America" : fpol::text(row, "source_id");
row["stale"] = row.at("last_success").is_null() || timestamp() - row.at("last_success").as_int64() > 180 || !row.at("last_error").is_null();
body["provenance"] = row;
return body;
}
}
auto fpol::current_results(fpol::database &database, boost::json::value const &document) -> boost::json::value {
auto races = boost::json::array{};
for (auto const &race : document.at("races").as_array()) {
auto id = fpol::text(race, "id");
auto row = race.as_object();
auto names = boost::json::array{};
for (auto const &candidate : document.at("candidates").as_array()) {
if (fpol::text(candidate, "race_id") == id) {
names.emplace_back(boost::json::object{{"id", candidate.at("id")}, {"name", candidate.at("name")}, {"affiliation", candidate.at("affiliation")}});
}
}
row["candidates"] = names;
row["counts"] = observation(database, id, "counts");
row["outcome"] = observation(database, id, "outcome");
races.emplace_back(row);
}
return boost::json::object{{"catalog_id", document.at("id")}, {"mode", document.at("mode")}, {"races", races}, {"formula", "(November first-round vote share + 50 for the eventual called/certified winner) × frozen race rating"}};
}
auto fpol::standings(fpol::database &database, boost::json::object const &league) -> boost::json::value {
if (!league.at("rules_json").is_null()) {
fpol::ensure(fpol::text(boost::json::parse(fpol::text(league, "rules_json")), "version") == "v2-november-share", "Unsupported frozen scoring rules", 409);
}
auto rows = boost::json::array{};
auto document = league.at("catalog_id").is_null() ? boost::json::value{} : database.document(fpol::text(league, "catalog_id"), "catalog");
auto candidates = std::map<std::string, boost::json::value>{};
auto races = std::map<std::string, boost::json::value>{};
auto ratings = std::map<std::string, std::int64_t>{};
if (!document.is_null()) {
for (auto const &candidate : document.at("candidates").as_array()) {
candidates.emplace(fpol::text(candidate, "id"), candidate);
}
auto results = current_results(database, document);
for (auto const &race : results.at("races").as_array()) {
races.emplace(fpol::text(race, "id"), race);
}
}
if (!league.at("ratings_id").is_null()) {
auto snapshot = database.document(fpol::text(league, "ratings_id"), "ratings");
auto multipliers = std::map<std::string, std::int64_t>{{"safe", 100}, {"likely", 110}, {"lean", 125}, {"toss_up", 150}};
for (auto const &entry : snapshot.at("entries").as_array()) {
ratings.emplace(fpol::text(entry, "race_id"), multipliers.at(fpol::text(entry, "rating")));
}
}
for (auto const &team : database.query("SELECT id,name FROM teams WHERE league_id=? ORDER BY id", {fpol::text(league, "id")})) {
auto roster = boost::json::array{};
std::int64_t subtotal = 0;
auto known = 0;
auto certified = 0;
for (auto const &pick : database.query("SELECT candidate_id,slot FROM picks WHERE team_id=? ORDER BY number", {fpol::text(team, "id")})) {
auto id = fpol::text(pick, "candidate_id");
auto candidate = candidates.at(id);
auto race_id = fpol::text(candidate, "race_id");
auto race = races.at(race_id);
auto row = boost::json::object{{"candidate", candidate}, {"slot", pick.at("slot")}, {"points", nullptr}, {"share", nullptr}, {"win_bonus", 0}, {"rating", nullptr}, {"counts", race.at("counts")}, {"outcome", race.at("outcome")}};
auto const &counts = race.at("counts");
auto const &outcome = race.at("outcome");
auto winner = !outcome.is_null() && !outcome.at("winner_id").is_null() && fpol::text(outcome, "winner_id") == id && (fpol::text(outcome, "status") == "called" || fpol::text(outcome, "status") == "certified");
row["win_bonus"] = winner ? 50 : 0;
if (!counts.is_null() && ratings.contains(race_id)) {
auto total = fpol::integer(counts, "total_votes");
for (auto const &entry : counts.at("votes").as_array()) {
if (fpol::text(entry, "candidate_id") == id && total > 0) {
auto votes = fpol::integer(entry, "votes");
auto multiplier = ratings.at(race_id);
auto numerator = (votes * 100 + (winner ? total * 50 : 0)) * multiplier;
auto cents = (numerator + total / 2) / total;
subtotal += cents;
++known;
if (!outcome.is_null() && fpol::text(outcome, "status") == "certified") {
++certified;
}
row["points"] = static_cast<double>(cents) / 100;
row["share"] = static_cast<double>(votes) * 100 / static_cast<double>(total);
row["win_bonus"] = winner ? 50 : 0;
row["rating"] = static_cast<double>(multiplier) / 100;
}
}
}
roster.emplace_back(row);
}
rows.emplace_back(boost::json::object{{"team", team}, {"roster", roster}, {"known_scores", known}, {"missing_scores", 12 - known}, {"known_subtotal", known ? boost::json::value{static_cast<double>(subtotal) / 100} : boost::json::value{}}, {"total", known == 12 ? boost::json::value{static_cast<double>(subtotal) / 100} : boost::json::value{}}, {"provisional", certified != 12}, {"rank", nullptr}});
}
auto score = [](boost::json::value const &row) { return row.at("known_subtotal").is_null() ? -1.0 : row.at("known_subtotal").as_double(); };
std::stable_sort(rows.begin(), rows.end(), [&](auto const &left, auto const &right) { return score(left) > score(right); });
std::int64_t rank = 0;
double previous = -2;
for (std::size_t index = 0; index < rows.size(); ++index) {
auto value = score(rows[index]);
if (value < 0) {
continue;
}
if (value != previous) {
rank = static_cast<std::int64_t>(index + 1);
}
rows[index].as_object()["rank"] = rank;
previous = value;
}
return boost::json::object{{"teams", rows}, {"rules_version", "v2-november-share"}, {"provisional", std::any_of(rows.begin(), rows.end(), [](auto const &row) { return row.at("provisional").as_bool(); })}};
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include "database.hpp"
namespace fpol {
auto current_results(fpol::database &database, boost::json::value const &catalog) -> boost::json::value;
auto standings(fpol::database &database, boost::json::object const &league) -> boost::json::value;
}
+96 -5
View File
@@ -1,5 +1,9 @@
#include "server.hpp"
#include "store.hpp"
#include "application.hpp"
#include <boost/asio/steady_timer.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <boost/asio/redirect_error.hpp>
#include <chrono>
#include <csignal>
@@ -10,6 +14,7 @@
#include <memory>
#include <stdexcept>
#include <string>
#include <set>
#include <utility>
#include <boost/asio/co_spawn.hpp>
@@ -122,30 +127,116 @@ auto fpol::run(fpol::server_options const &options) -> int {
io.run();
initialized.get();
io.restart();
for (auto const &action : {"rules", "catalog", "capacity", "score-preview"}) {
auto method = std::string_view{action} == "rules" || std::string_view{action} == "catalog" ? "GET" : "POST";
auto application = fpol::application{{options.database, options.allow_demo, {}}};
auto restarted = boost::asio::co_spawn(io, application.tick(true), boost::asio::use_future);
io.run();
restarted.get();
io.restart();
auto reads = std::set<std::string>{"auth/me", "leagues", "league", "catalogs", "candidates", "results", "standings", "admin/sources"};
auto actions = reads;
for (auto const &action : {"auth/login", "auth/register", "auth/logout", "auth/password", "auth/reset", "league/create", "league/join", "league/invite", "league/revoke-invites", "league/remove", "league/settings", "league/name", "league/ratings", "team/name", "draft/queue", "draft/pick", "draft/start", "draft/pause", "draft/resume"}) {
actions.insert(action);
}
for (auto const &action : actions) {
auto method = reads.contains(action) ? "GET" : "POST";
router.add_response_route(method, "/api/" + action, [&application, &options, action, read = reads.contains(action)](ligo::request_context context, boost::json::value input) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
auto session = std::string{};
auto cookie = context.headers["cookie"];
for (std::size_t start = 0; start < cookie.size();) {
auto end = cookie.find(';', start);
if (end == std::string::npos) {
end = cookie.size();
}
auto part = cookie.substr(start, end - start);
auto first = part.find_first_not_of(' ');
if (first != std::string::npos) {
part.erase(0, first);
}
if (part.starts_with("fpol_session=")) {
if (!session.empty()) {
session.clear();
break;
}
session = part.substr(13);
}
start = end + 1;
}
auto result = boost::json::value{};
if (!read && (context.headers["origin"] != options.origin || context.headers["x-fpol-request"] != "1")) {
result = boost::json::object{{"ok", false}, {"status", 403}, {"message", "Request origin is not allowed"}};
} else {
if (read) {
input = boost::json::object{};
for (auto const &[key, value] : context.query) {
input.as_object()[key] = value;
}
}
result = co_await application.handle(action, std::move(input), session, context.headers["x-csrf-token"], context.peer);
}
auto response = ligo::service_response{};
response.status = static_cast<std::uint16_t>(result.at("status").as_int64());
response.headers = {{"Cache-Control", "no-store"}, {"X-Content-Type-Options", "nosniff"}};
auto suffix = std::string{"; Path=/; HttpOnly; SameSite=Lax"} + (options.secure_cookies ? "; Secure" : "");
if (auto token = result.as_object().if_contains("session_token")) {
response.headers.emplace_back("Set-Cookie", "fpol_session=" + std::string{token->as_string()} + "; Max-Age=2592000" + suffix);
result.as_object().erase("session_token");
}
if (result.as_object().contains("clear_session")) {
response.headers.emplace_back("Set-Cookie", "fpol_session=; Max-Age=0" + suffix);
result.as_object().erase("clear_session");
}
response.body = boost::json::serialize(result);
co_return response;
});
}
router.add_route("GET", "/api/rules", [&store](ligo::request_context, boost::json::value input) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> { co_return co_await store.handle("rules", std::move(input)); });
// The snapshot simulator stays a local development tool, explicitly enabled.
if (options.allow_demo) {
for (auto const &action : {"catalog", "capacity", "score-preview"}) {
auto method = std::string_view{action} == "catalog" ? "GET" : "POST";
router.add_route(method, std::string{"/api/"} + action, [&store, action = std::string{action}](ligo::request_context, boost::json::value input) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
try {
co_return co_await store.handle(action, std::move(input));
} catch (std::invalid_argument const &error) {
co_return std::unexpected(ligo::detail::make_error(ligo::detail::error_invalid_argument, error.what()));
} catch (std::out_of_range const &error) {
} catch (std::exception const &error) {
co_return std::unexpected(ligo::detail::make_error(ligo::detail::error_invalid_argument, error.what()));
}
});
}
}
auto http_options = ligo::http::server_options{};
http_options.parse_query = true;
http_options.max_body_size = 64 * 1024;
http_options.on_error = [](modulus::error error) { modulus::log_warn(error.describe()); };
auto server = ligo::http::server{io, {.host = options.host, .port = options.port}, std::move(router), std::move(http_options)};
auto signals = boost::asio::signal_set{io, SIGINT, SIGTERM};
auto timer = boost::asio::steady_timer{io};
auto stopping = false;
auto started = server.start();
if (!started) {
modulus::log_error(started.error().describe());
return 1;
}
auto exit_code = 0;
auto ticking = [&]() -> boost::asio::awaitable<void> {
while (!stopping) {
timer.expires_after(std::chrono::seconds{1});
auto error = boost::system::error_code{};
co_await timer.async_wait(boost::asio::redirect_error(boost::asio::use_awaitable, error));
if (error || stopping) {
break;
}
try {
co_await application.tick();
} catch (std::exception const &failure) {
modulus::log_error(failure.what());
}
}
};
boost::asio::co_spawn(io, ticking(), [&](std::exception_ptr failure) {if(failure){exit_code=1;io.stop();} });
signals.async_wait([&](boost::system::error_code error, int) {
if (!error) {
stopping = true;
timer.cancel();
modulus::log_info("Stopping server");
boost::asio::co_spawn(io, stop_server(server), [&](std::exception_ptr failure) {
if (failure) {
+3
View File
@@ -12,6 +12,9 @@ struct server_options {
std::uint16_t port{8080};
std::filesystem::path web_root{};
std::filesystem::path database{"var/fpol.sqlite"};
bool allow_demo{false};
bool secure_cookies{false};
std::string origin{"http://127.0.0.1:8080"};
};
auto run(fpol::server_options const &options) -> int;
+3 -1
View File
@@ -1,5 +1,6 @@
#include "store.hpp"
#include "domain.hpp"
#include "database.hpp"
#include <fstream>
#include <iterator>
@@ -52,8 +53,9 @@ auto fpol::store::initialize() -> boost::asio::awaitable<void> {
checked(co_await connection_.async_connect());
checked(co_await connection_.async_set_foreign_keys(true));
checked(co_await connection_.async_set_busy_timeout(std::chrono::milliseconds{5000}));
checked(co_await connection_.async_journal_mode("WAL"));
auto migrator = noto::migrator{connection_};
auto migrations = std::vector<noto::migration>{{"001_snapshots", "Immutable catalog, rating and result snapshots", "001-v1", create_schema, {}}};
auto migrations = std::vector<noto::migration>{{"001_snapshots", "Immutable catalog, rating and result snapshots", "001-v1", create_schema, {}}, fpol::game_migration()};
checked(co_await migrator.async_migrate(std::move(migrations)));
}
+46
View File
@@ -0,0 +1,46 @@
// Real-browser smoke test with Node's standard library and Chromium's CDP pipe.
// Usage: node tests/browser.mjs build/workspace_debug/fpol_server
import assert from 'node:assert/strict';
import {spawn, spawnSync} from 'node:child_process';
import {mkdtempSync, mkdirSync, writeFileSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import path from 'node:path';
import net from 'node:net';
const root=path.resolve(import.meta.dirname,'..');
const executable=path.resolve(process.argv[2]||'build/workspace_debug/fpol_server');
const temporary=mkdtempSync(path.join(tmpdir(),'fpol-browser-'));
const database=path.join(temporary,'fpol.sqlite');
const password='browser test password';
let server,browser;
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function until(check,description){const deadline=Date.now()+15000;while(Date.now()<deadline){const result=await check();if(result)return result;await pause(50);}throw new Error(`Timed out: ${description}`);}
function admin(args,input){const result=spawnSync(path.join(path.dirname(executable),'fpol_admin'),['--database',database,...args],{encoding:'utf8',input});assert.equal(result.status,0,result.stderr);return result.stdout;}
try{
admin(['import',path.join(root,'data/demo/catalog.json'),path.join(root,'data/demo/ratings.json')]);admin(['create-user','alice'],password+'\n');
const listener=net.createServer();await new Promise((resolve,reject)=>{listener.once('error',reject);listener.listen(0,'127.0.0.1',resolve);});const port=listener.address().port;await new Promise(resolve=>listener.close(resolve));
const origin=`http://127.0.0.1:${port}`;
let serverLog='';server=spawn(executable,['--host','127.0.0.1','--port',String(port),'--database',database,'--origin',origin,'--allow-demo','--web-root',path.join(root,'frontend/dist')]);server.stderr.on('data',data=>{serverLog+=data;});server.stdout.on('data',data=>{serverLog+=data;});
await until(()=>{if(server.exitCode!=null)throw new Error(serverLog);return serverLog.includes('Listening on');},'C++ server startup');
const flags=['--headless','--disable-gpu','--disable-background-networking','--disable-component-update','--no-first-run','--remote-debugging-pipe',`--user-data-dir=${temporary}/chrome`];
if(process.getuid?.()===0)flags.push('--no-sandbox');
browser=spawn(process.env.FPOL_CHROMIUM||'chromium',flags,{stdio:['ignore','ignore','pipe','pipe','pipe']});let browserLog='';browser.stderr.on('data',data=>{browserLog+=data;});
let counter=0,buffer='';const pending=new Map();
browser.stdio[4].on('data',chunk=>{buffer+=chunk.toString();let index;while((index=buffer.indexOf('\0'))>=0){const value=JSON.parse(buffer.slice(0,index));buffer=buffer.slice(index+1);if(value.id&&pending.has(value.id)){const promise=pending.get(value.id);pending.delete(value.id);clearTimeout(promise.timer);value.error?promise.reject(new Error(JSON.stringify(value.error))):promise.resolve(value.result);}}});
browser.on('exit',()=>{for(const promise of pending.values()){clearTimeout(promise.timer);promise.reject(new Error(`Chromium exited: ${browserLog}`));}pending.clear();});
function cdp(method,params={},sessionId){return new Promise((resolve,reject)=>{const id=++counter;const timer=setTimeout(()=>{pending.delete(id);reject(new Error(`CDP timeout: ${method}. ${browserLog}`));},15000);pending.set(id,{resolve,reject,timer});browser.stdio[3].write(JSON.stringify({id,method,params,sessionId})+'\0');});}
async function page(){const {browserContextId}=await cdp('Target.createBrowserContext');const {targetId}=await cdp('Target.createTarget',{url:'about:blank',browserContextId});const {sessionId}=await cdp('Target.attachToTarget',{targetId,flatten:true});await cdp('Page.enable',{},sessionId);await cdp('Runtime.enable',{},sessionId);
async function evaluate(expression){const result=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true},sessionId);if(result.exceptionDetails)throw new Error(result.exceptionDetails.text+': '+result.exceptionDetails.exception?.description);return result.result.value;}
return {sessionId,evaluate,go:async(hash='')=>{await cdp('Page.navigate',{url:origin+'/'+hash},sessionId);},wait:selector=>until(()=>evaluate(`!!document.querySelector(${JSON.stringify(selector)})`),selector),click:selector=>evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`),fill:(selector,value)=>evaluate(`document.querySelector(${JSON.stringify(selector)}).value=${JSON.stringify(value)}`),text:()=>evaluate('document.body.innerText')};
}
async function signIn(tab){await tab.wait('#signin');await tab.fill('#signin [name=username]','alice');await tab.fill('#signin [name=password]',password);await tab.click('#signin button');await tab.wait('a[href="#create"]');}
const alice=await page();await alice.go();await signIn(alice);assert.match(await alice.text(),/My leagues/);
await alice.click('a[href="#create"]');await alice.wait('#create');await alice.fill('[name=name]','Election friends');await alice.fill('[name=team_name]','The Night Owls');await alice.fill('[name=catalog_id]','demo-2026');await alice.click('#create button');await alice.wait('#invite');
const league=await alice.evaluate('location.hash.split("/")[1]');await alice.click('#invite');await alice.wait('#invite-link input');const inviteUrl=await alice.evaluate('document.querySelector("#invite-link input").value');assert.match(inviteUrl,/#join\/[a-f0-9]+$/);
const bob=await page();await bob.go(inviteUrl.split('/#')[1]? '#'+inviteUrl.split('/#')[1]:'');await bob.wait('#register');await bob.fill('#register [name=username]','bob');await bob.fill('#register [name=password]',password);await bob.fill('#register [name=team_name]','Late Calls');await bob.click('#register button');await bob.wait(`a[href="#league/${league}"]`);
await alice.go(`#draft/${league}`);await alice.wait('#start');await until(()=>alice.evaluate('!document.querySelector("#start").disabled'),'draft readiness');await alice.click('#start');await alice.wait('#pause');await bob.go(`#draft/${league}`);await bob.wait('#clock');
const active=await alice.evaluate('document.querySelector("#turn-label").innerText.includes("You’re on the clock")')?alice:bob;
await until(()=>active.evaluate('!!document.querySelector(".pick-candidate:not([disabled])")'),'legal candidate buttons');await active.click('.queue-candidate:not([disabled])');await until(()=>active.evaluate('!!document.querySelector(".unqueue")'),'saved private queue');await active.click('.pick-candidate:not([disabled])');await until(()=>active.evaluate('document.querySelector("#recent").innerText.includes("house:")||document.querySelector("#recent").innerText.includes("governor:")||document.querySelector("#recent").innerText.includes("senate:")'),'recorded pick');
const artifacts=path.join(root,'build/browser-artifacts');mkdirSync(artifacts,{recursive:true});await cdp('Emulation.setDeviceMetricsOverride',{width:1365,height:900,deviceScaleFactor:1,mobile:false},alice.sessionId);writeFileSync(path.join(artifacts,'draft-desktop.png'),Buffer.from((await cdp('Page.captureScreenshot',{format:'png'},alice.sessionId)).data,'base64'));
await alice.go(`#standings/${league}`);await alice.wait('.standing');assert.match(await alice.text(),/Provisional/);await cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},alice.sessionId);assert.equal(await alice.evaluate('document.documentElement.scrollWidth <= 390'),true);writeFileSync(path.join(artifacts,'standings-mobile.png'),Buffer.from((await cdp('Page.captureScreenshot',{format:'png'},alice.sessionId)).data,'base64'));
console.log('Browser smoke passed: accounts, invitations, leagues, queues, picks, standings, responsive layout.');
}finally{browser?.kill('SIGTERM');server?.kill('SIGTERM');await pause(300);rmSync(temporary,{recursive:true,force:true});}
+2 -2
View File
@@ -5,7 +5,7 @@ import sys
ROOT = pathlib.Path(__file__).resolve().parent
status = 0
for script, executable in (("test_core.py", sys.argv[1]), ("test_server.py", sys.argv[2])):
result = subprocess.run([sys.executable, str(ROOT / script), executable], check=False)
for script, arguments in (("test_core.py", [sys.argv[1]]), ("test_application.py", [sys.argv[1], sys.argv[3]]), ("test_server.py", [sys.argv[2]])):
result = subprocess.run([sys.executable, str(ROOT / script), *arguments], check=False)
status = status or result.returncode
sys.exit(status)
+331
View File
@@ -0,0 +1,331 @@
"""Real C++ service/SQLite integration without requiring a listening socket."""
import concurrent.futures
import copy
import json
import pathlib
import sqlite3
import subprocess
import sys
import tempfile
import unittest
ADMIN = pathlib.Path(sys.argv.pop(1)).resolve()
INGEST = pathlib.Path(sys.argv.pop(1)).resolve()
ROOT = pathlib.Path(__file__).resolve().parents[1]
NOW = 2000000000
PASSWORD = "a sufficiently long password"
class application_tests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.base = tempfile.TemporaryDirectory(prefix="fpol_accounts_")
cls.base_database = pathlib.Path(cls.base.name) / "base.sqlite"
cls.identities = {}
def command(*args, stdin=None):
run = subprocess.run([str(ADMIN), "--database", str(cls.base_database), "--allow-demo", *args], input=stdin, text=True, capture_output=True, timeout=30)
if run.returncode:
raise RuntimeError(run.stderr)
return json.loads(run.stdout.splitlines()[-1])
command("import", str(ROOT / "data/demo/catalog.json"), str(ROOT / "data/demo/ratings.json"), str(ROOT / "data/real/us-2026-10-04-partial.json"))
for username in ("alice", "bob", "carol"):
command("create-user", username, stdin=PASSWORD + "\n")
cls.identities[username] = command("app-request", "-", stdin=json.dumps({"action": "auth/login", "input": {"username": username, "password": PASSWORD}, "clock": NOW}))
@classmethod
def tearDownClass(cls):
cls.base.cleanup()
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="fpol_application_")
self.addCleanup(self.temp.cleanup)
self.path = pathlib.Path(self.temp.name)
self.database = self.path / "test.sqlite"
with sqlite3.connect(self.base_database) as source, sqlite3.connect(self.database) as destination:
source.backup(destination)
self.identities = copy.deepcopy(type(self).identities)
self.now = NOW
def command(self, executable, *args, stdin=None):
result = subprocess.run([str(executable), "--database", str(self.database), *map(str, args)], input=stdin, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout.splitlines()[-1])
def request(self, action, data=None, who="alice", status=200, csrf=True, raw=False):
identity = self.identities.get(who, {})
result = self.command(ADMIN, "--allow-demo", "app-request", "-", stdin=json.dumps({"action": action, "input": data or {}, "clock": self.now,
"session": identity.get("session_token", ""), "csrf": identity.get("payload", {}).get("csrf", "") if csrf else ""}))
self.assertEqual(result.get("status", 200), status, result)
return result if raw or not result.get("ok") else result.get("payload", {})
def league(self, players=("alice", "bob"), maximum=2, demo=True):
data = {"name": "Friends", "team_name": "Alice team", "max_teams": maximum}
if demo:
data["catalog_id"] = "demo-2026"
league = self.request("league/create", data)["league_id"]
invite = self.request("league/invite", {"league_id": league})["invite"]
for player in players[1:]:
self.request("league/join", {"invite": invite, "team_name": player + " team"}, player)
return league, invite
def view(self, league, who="alice"):
return self.request("league", {"league_id": league}, who)
def start(self, players=("alice", "bob")):
league, _ = self.league(players=players, maximum=len(players))
self.request("draft/start", {"league_id": league})
return league
def turn(self, league):
view = self.view(league)
who = view["picker"]["username"]
return who, self.view(league, who)
def pick(self, league, candidate=None, request_id=None):
who, view = self.turn(league)
data = {"league_id": league, "candidate_id": candidate or view["legal_candidate_ids"][0], "pick_number": view["draft"]["pick_number"], "request_id": request_id or f"pick-{view['draft']['pick_number']}"}
return who, data, self.request("draft/pick", data, who)
def file(self, name, body):
path = self.path / name
path.write_text(json.dumps(body))
return path
def ingest(self, payload, configure=False):
if configure:
config = {"id": "fixture", "kind": "official-json", "url": "https://example.org/reviewed-test-fixture", "catalog_id": "demo-2026", "races": [{"race_id": "demo-house-01", "role": role} for role in ("counts", "outcome")]}
self.command(INGEST, "configure", self.file("source.json", config))
return self.command(INGEST, "replay", "fixture", self.file("payload.json", payload))
def test_auth_and_csrf_and_logout(self):
self.request("leagues", who="nobody", status=401)
self.request("league/create", {}, csrf=False, status=403)
self.request("auth/login", {"username": "alice", "password": "incorrect password"}, status=401)
self.request("auth/logout")
self.request("auth/me", status=401)
def test_login_rate_limit(self):
for _ in range(10):
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=401)
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=429)
self.now += 901
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=401)
def test_invited_registration_capacity_and_atomicity(self):
league, invite = self.league(players=("alice",), maximum=2)
credentials = {"username": "david", "password": PASSWORD, "team_name": "David", "invite": invite}
registered = self.request("auth/register", credentials, who="nobody", raw=True)
self.assertEqual(registered["payload"]["admin"], 0)
self.assertEqual(len(self.view(league)["teams"]), 2)
credentials["username"] = "edward"
self.request("auth/register", credentials, who="nobody", status=409)
with sqlite3.connect(self.database) as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM users WHERE username='edward'").fetchone()[0], 0)
self.assertNotIn(invite, str(database.execute("SELECT * FROM invites").fetchall()))
def test_invite_expiry_and_revocation(self):
league, invite = self.league(players=("alice",))
self.request("league/revoke-invites", {"league_id": league})
self.request("league/join", {"invite": invite, "team_name": "Bob"}, "bob", status=404)
invite = self.request("league/invite", {"league_id": league})["invite"]
self.now += 7 * 86400
self.request("league/join", {"invite": invite, "team_name": "Bob"}, "bob", status=404)
def test_isolation_and_commissioner_permissions(self):
league, _ = self.league()
self.request("league", {"league_id": league}, "carol", status=403)
self.request("standings", {"league_id": league}, "carol", status=403)
self.request("candidates", {"league_id": league}, "carol", status=403)
self.request("draft/start", {"league_id": league}, "bob", status=403)
self.request("league/invite", {"league_id": league}, "bob", status=403)
self.assertEqual(self.request("leagues", who="carol"), [])
self.request("draft/queue", {"league_id": league}, status=400)
def test_reset_single_use_expiry_and_session_revocation(self):
reset = self.command(ADMIN, "reset-link", "alice")
# Operator reset timestamps use wall time, not the diagnostic clock.
self.now = reset["expires_at"] - 1
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD + " new"}, who="nobody")
self.now = NOW
self.request("auth/me", status=401)
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD}, who="nobody", status=400)
reset = self.command(ADMIN, "reset-link", "bob")
self.now = reset["expires_at"]
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD}, who="nobody", status=400)
def test_password_change_invalidates_old_session(self):
result = self.request("auth/password", {"current_password": PASSWORD, "password": PASSWORD + " updated"}, raw=True)
self.request("auth/me", status=401)
self.identities["alice"] = result
self.assertEqual(self.request("auth/me")["username"], "alice")
def test_real_leagues_exist_before_data_ready(self):
league, _ = self.league(demo=False)
view = self.view(league)
self.assertFalse(view["readiness"]["ready"])
self.assertEqual(len(view["teams"]), 2)
self.request("draft/start", {"league_id": league}, status=409)
self.assertEqual(self.request("candidates", {"limit": 100})["total"], 115)
self.assertEqual(len(self.request("candidates", {"offset": 100, "limit": 100})["items"]), 15)
def test_private_queue_timeout_and_snake(self):
league = self.start()
who, view = self.turn(league)
candidate = view["legal_candidate_ids"][-1]
self.request("draft/queue", {"league_id": league, "candidates": [candidate]}, who)
other = "alice" if who == "bob" else "bob"
self.assertNotIn(candidate, json.dumps(self.view(league, other)["teams"]))
self.now += 120
self.request("tick")
view = self.view(league)
self.assertEqual(view["picks"][0]["candidate_id"], candidate)
self.assertEqual(view["picks"][0]["automatic"], 1)
second = view["picker"]["id"]
self.pick(league)
self.assertEqual(self.view(league)["picker"]["id"], second)
def test_pause_restart_and_frozen_settings(self):
league = self.start()
self.now += 50
self.request("draft/pause", {"league_id": league})
self.assertEqual(self.view(league)["draft"]["remaining"], 70)
self.now += 1000
self.request("tick")
self.assertEqual(len(self.view(league)["picks"]), 0)
self.request("draft/resume", {"league_id": league})
self.assertEqual(self.view(league)["draft"]["deadline"], self.now + 70)
self.request("restart")
view = self.view(league)
self.assertEqual(view["league"]["status"], "paused")
self.assertEqual(view["draft"]["remaining"], 120)
self.assertEqual(json.loads(view["league"]["rules_json"])["version"], "v2-november-share")
self.request("league/settings", {"league_id": league, "name": "Changed", "catalog_id": "demo-2026"}, status=409)
def test_idempotent_concurrent_picks_and_timeout_boundary(self):
league = self.start()
who, view = self.turn(league)
data = {"league_id": league, "candidate_id": view["legal_candidate_ids"][0], "pick_number": 0, "request_id": "same-request"}
with concurrent.futures.ThreadPoolExecutor(2) as pool:
results = list(pool.map(lambda _: self.request("draft/pick", data, who), range(2)))
self.assertEqual(results[0], results[1])
self.assertEqual(len(self.view(league)["picks"]), 1)
data["candidate_id"] = view["legal_candidate_ids"][1]
self.request("draft/pick", data, who, status=409)
who, view = self.turn(league)
self.now = view["draft"]["deadline"]
self.request("draft/pick", {"league_id": league, "candidate_id": view["legal_candidate_ids"][0], "pick_number": 1, "request_id": "expired"}, who, status=409)
self.request("tick")
self.assertEqual(len(self.view(league)["picks"]), 2)
def test_full_draft_capacity_and_league_exclusivity(self):
league = self.start(players=("alice", "bob", "carol"))
for _ in range(36):
self.now += 120
self.request("tick")
view = self.view(league)
self.assertEqual(view["league"]["status"], "drafted")
self.assertEqual(len({p["candidate_id"] for p in view["picks"]}), 36)
for team in view["teams"]:
self.assertEqual(len({p["slot"] for p in view["picks"] if p["team_id"] == team["id"]}), 12)
second = self.start()
self.pick(second, view["picks"][0]["candidate_id"])
self.assertEqual(len(self.view(second)["picks"]), 1)
def test_ingest_corrections_runoff_retraction_and_outage(self):
league = self.start()
self.pick(league, "demo-house-01-1")
counts = {"race_id": "demo-house-01", "kind": "counts", "round": "november_first", "source_time": "2026-11-04T01:00:00Z", "total_votes": 1000, "votes": [{"candidate_id": "demo-house-01-1", "votes": 400}]}
outcome = {"race_id": "demo-house-01", "kind": "outcome", "round": "general", "source_time": "2026-11-04T01:00:00Z", "status": "pending_round", "winner_id": None}
payload = {"schema_version": 1, "observations": [counts, outcome]}
result = self.ingest(payload, configure=True)
self.assertFalse(result["verified"])
def score():
return next(row["roster"][0]["points"] for row in self.request("standings", {"league_id": league})["teams"] if row["roster"])
self.assertEqual(score(), 40)
outcome.update(round="runoff", source_time="2026-12-06T01:00:00Z", status="called", winner_id="demo-house-01-1")
self.ingest({"schema_version": 1, "observations": [outcome]})
self.assertEqual(score(), 90)
outcome.update(source_time="2026-12-06T02:00:00Z", status="reporting", winner_id=None)
self.ingest({"schema_version": 1, "observations": [outcome]})
self.assertEqual(score(), 40)
counts.update(source_time="2026-11-04T03:00:00Z", votes=[{"candidate_id": "demo-house-01-1", "votes": 450}])
self.ingest({"schema_version": 1, "observations": [counts]})
self.assertEqual(score(), 45)
counts["round"] = "runoff"
self.assertTrue(self.ingest({"schema_version": 1, "observations": [counts]})["quarantined"])
self.assertEqual(score(), 45)
counts["round"] = "november_first"
counts["source_time"] = "2026-11-04T00:00:00Z"
counts["votes"][0]["votes"] = 100
self.ingest({"schema_version": 1, "observations": [counts]})
self.assertEqual(score(), 45)
def test_twelve_teams_preserve_party_capacity_and_finish(self):
league, invite = self.league(players=("alice", "bob", "carol"), maximum=12)
for index in range(9):
name = f"player_{index}"
self.identities[name] = self.request("auth/register", {"username": name, "password": PASSWORD, "team_name": name, "invite": invite}, who="nobody", raw=True)
self.request("draft/start", {"league_id": league})
self.pick(league, "demo-house-01-1")
# Once someone has their D slot, a second D would consume another team's only required supply.
with sqlite3.connect(self.database) as database:
first = database.execute("SELECT team_id FROM picks WHERE number=0 AND league_id=?", (league,)).fetchone()[0]
database.execute("UPDATE drafts SET pick_number=23 WHERE league_id=?", (league,))
who, view = self.turn(league)
self.assertEqual(view["picker"]["id"], first)
self.assertNotIn("demo-house-02-1", view["legal_candidate_ids"])
with sqlite3.connect(self.database) as database:
database.execute("UPDATE drafts SET pick_number=1 WHERE league_id=?", (league,))
for _ in range(143):
self.now += 120
self.request("tick")
view = self.view(league)
self.assertEqual(view["league"]["status"], "drafted")
self.assertEqual(len(view["picks"]), 144)
self.assertEqual(len({row["candidate_id"] for row in view["picks"]}), 144)
def test_openamerica_mapping_and_fusion_lines(self):
config = {"id": "openamerica-test", "kind": "openamerica", "url": "https://openamerica.io/elections/results.json?year=2026&office=House", "office": "House", "catalog_id": "demo-2026", "counts_until": "2099-01-01T00:00:00Z", "races": [{"race_id": "demo-house-01", "role": "counts", "provider_id": "MI-01", "candidates": {"Test Candidate|D": "demo-house-01-1", "Test Candidate|W": "demo-house-01-1", "Write-in|": None}}]}
self.command(INGEST, "configure", self.file("oa-config.json", config))
# Synthetic transport fixture. It cannot verify a real feed.
payload = {"api": 1, "year": 2026, "office": "House", "stale": False, "as_of": "2026-11-04T01:00:00+00:00", "races": {"MI-01": {"total": 100, "cands": [{"n": "Test Candidate", "p": "D", "v": 60}, {"n": "Test Candidate", "p": "W", "v": 10}]}}}
result = self.command(INGEST, "replay", "openamerica-test", self.file("oa-payload.json", payload))
self.assertEqual(result["changed"], 1)
self.assertFalse(result["verified"])
with sqlite3.connect(self.database) as database:
normalized = json.loads(database.execute("SELECT body FROM observations").fetchone()[0])
self.assertEqual(normalized["votes"], [{"candidate_id": "demo-house-01-1", "votes": 70}])
payload["races"]["MI-01"]["cands"].append({"n": "Unknown", "p": "R", "v": 20})
self.assertTrue(self.command(INGEST, "replay", "openamerica-test", self.file("oa-payload.json", payload))["quarantined"])
activation = subprocess.run([str(INGEST), "--database", str(self.database), "activate", "openamerica-test"], capture_output=True, text=True)
self.assertNotEqual(activation.returncode, 0)
def test_ratings_import_permission_immutability_and_freeze(self):
league, _ = self.league()
ratings = json.loads((ROOT / "data/demo/ratings.json").read_text())
ratings["id"] = "commissioner-ratings"
data = {"league_id": league, "document": ratings}
self.request("league/ratings", data, "bob", status=403)
self.request("league/ratings", data)
self.request("league/ratings", data)
self.assertEqual(self.view(league)["league"]["ratings_id"], ratings["id"])
ratings["entries"][0]["rating"] = "toss_up"
self.request("league/ratings", data, status=409)
self.request("draft/start", {"league_id": league})
ratings["id"] = "too-late"
self.request("league/ratings", data, status=409)
self.request("league/name", {"league_id": league, "name": "Renamed after start"})
self.assertEqual(self.view(league)["league"]["name"], "Renamed after start")
def test_one_verified_race_cannot_activate_whole_feed(self):
self.ingest({"schema_version": 1, "observations": []}, configure=True)
# Fixture-only network evidence, to exercise the coverage gate without sockets.
with sqlite3.connect(self.database) as database:
database.execute("UPDATE sources SET verified=1,last_error=NULL WHERE id='fixture'")
database.execute("INSERT INTO source_checks VALUES('fixture','demo-house-01','counts',?)", (self.now,))
result = subprocess.run([str(INGEST), "--database", str(self.database), "activate", "fixture"], capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
with sqlite3.connect(self.database) as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM coverage").fetchone()[0], 0)
def test_candidate_watch_deduplicates_and_detects_return_to_previous_payload(self):
config = {"id": "watch", "kind": "candidate-watch", "url": "https://example.org/official-candidates", "catalog_id": "demo-2026", "races": []}
self.command(INGEST, "configure", self.file("watch.json", config))
first = self.file("first.json", {"version": 1})
second = self.file("second.json", {"version": 2})
self.command(INGEST, "replay", "watch", first)
with sqlite3.connect(self.database) as database:
database.execute("UPDATE sources SET last_changed=123,etag='external-etag' WHERE id='watch'")
self.command(INGEST, "replay", "watch", first)
with sqlite3.connect(self.database) as database:
self.assertEqual(database.execute("SELECT last_changed FROM sources WHERE id='watch'").fetchone()[0], 123)
self.command(INGEST, "replay", "watch", second)
with sqlite3.connect(self.database) as database:
database.execute("UPDATE sources SET last_changed=456 WHERE id='watch'")
self.command(INGEST, "replay", "watch", first)
with sqlite3.connect(self.database) as database:
self.assertGreater(database.execute("SELECT last_changed FROM sources WHERE id='watch'").fetchone()[0], 456)
self.assertEqual(database.execute("SELECT COUNT(*) FROM source_payloads").fetchone()[0], 2)
self.assertEqual(database.execute("SELECT verified FROM sources WHERE id='watch'").fetchone()[0], 0)
def test_catalog_documents_survive_migration_and_backup(self):
backup = self.path / "backup.sqlite"
self.command(ADMIN, "backup", backup)
with sqlite3.connect(backup) as database:
self.assertEqual(database.execute("PRAGMA integrity_check").fetchone()[0], "ok")
self.assertEqual(database.execute("SELECT COUNT(*) FROM documents").fetchone()[0], 3)
hashes = database.execute("SELECT password_hash FROM users").fetchall()
self.assertEqual(len(set(row[0] for row in hashes)), 3)
self.assertTrue(all(row[0].startswith("scrypt$131072$8$1$") for row in hashes))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -165,7 +165,7 @@ class core_tests(unittest.TestCase):
self.assertEqual(self.preview(), original)
with contextlib.closing(sqlite3.connect(self.database)) as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM documents").fetchone()[0], 7)
self.assertEqual(database.execute("SELECT COUNT(*) FROM _noto_schema_migrations").fetchone()[0], 1)
self.assertEqual(database.execute("SELECT COUNT(*) FROM _noto_schema_migrations").fetchone()[0], 2)
def test_idempotency_and_frozen_ratings(self):
self.assertFalse(self.run_admin("import", DEMO / "ratings.json")["imported"])
self.ratings["entries"][0]["rating"] = "toss_up"
+37 -2
View File
@@ -67,7 +67,7 @@ class server_tests(unittest.TestCase):
self.assertEqual(json.loads(body)["code"], "not_found")
self.assertEqual(request(port, "/api/health", "POST")[0], 404)
def test_catalog_rules_and_validation_routes(self):
with running_server() as port:
with running_server("--allow-demo") as port:
self.assertEqual(json.loads(request(port, "/api/catalog")[2])["payload"], {"documents": []})
self.assertEqual(len(json.loads(request(port, "/api/rules")[2])["payload"]["slots"]), 12)
status, _, body = request(port, "/api/capacity", "POST", {"catalog_id": "missing", "players": 12})
@@ -81,7 +81,7 @@ class server_tests(unittest.TestCase):
database = pathlib.Path(directory) / "league.sqlite"
result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "import", str(demo / "catalog.json"), str(demo / "ratings.json"), str(demo / "results-3.json")], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
with running_server("--database", database) as port:
with running_server("--database", database, "--allow-demo") as port:
catalog = json.loads(request(port, "/api/catalog")[2])["payload"]
self.assertEqual(len(catalog["documents"]), 3)
status, _, body = request(port, "/api/capacity", "POST", json.loads((demo / "capacity.json").read_text()))
@@ -90,6 +90,41 @@ class server_tests(unittest.TestCase):
status, _, body = request(port, "/api/score-preview", "POST", json.loads((demo / "preview.json").read_text()))
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["payload"]["total"], 528.24)
def test_authenticated_cookie_headers_origin_and_query(self):
with tempfile.TemporaryDirectory(prefix="fpol_auth_http_") as directory:
database = pathlib.Path(directory) / "league.sqlite"
result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "create-user", "alice"], input="a strong test password\n", capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
with running_server("--database", database, "--origin", "https://game.example", "--secure-cookies") as port:
def call(path, method="GET", body=None, headers=None):
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
try:
connection.request(method, path, body=json.dumps(body) if body is not None else None, headers={"Content-Type": "application/json", **(headers or {})})
response = connection.getresponse()
return response.status, dict(response.getheaders()), json.loads(response.read())
finally:
connection.close()
login = {"username": "alice", "password": "a strong test password"}
self.assertEqual(call("/api/auth/login", "POST", login)[0], 403)
headers = {"Origin": "https://game.example", "X-Fpol-Request": "1"}
status, response, body = call("/api/auth/login", "POST", login, headers)
self.assertEqual(status, 200)
cookie = response["Set-Cookie"]
self.assertIn("HttpOnly", cookie)
self.assertIn("SameSite=Lax", cookie)
self.assertIn("Secure", cookie)
self.assertEqual(response["Cache-Control"], "no-store")
self.assertNotIn("session_token", body)
headers["Cookie"] = cookie.split(";", 1)[0]
self.assertEqual(call("/api/auth/me", headers=headers)[2]["payload"]["username"], "alice")
self.assertEqual(call("/api/candidates?search=two+words&limit=1", headers=headers)[0], 200)
self.assertEqual(call("/api/candidates?search=%ZZ", headers=headers)[0], 400)
self.assertEqual(call("/api/candidates?limit=1&limit=2", headers=headers)[0], 400)
self.assertEqual(call("/api/auth/logout", "POST", {}, headers)[0], 403)
headers["X-CSRF-Token"] = body["payload"]["csrf"]
self.assertEqual(call("/api/auth/logout", "POST", {}, headers)[0], 200)
self.assertEqual(call("/api/auth/me", headers=headers)[0], 401)
self.assertEqual(call("/api/catalog", headers=headers)[0], 404)
def test_static_assets_and_route_boundaries(self):
with tempfile.TemporaryDirectory(prefix="fpol_web_") as directory:
root = pathlib.Path(directory)
+29
View File
@@ -0,0 +1,29 @@
"""Build this checkout against the sibling library checkouts, without publishing."""
import argparse
import pathlib
import subprocess
ROOT = pathlib.Path(__file__).resolve().parents[1]
def run(*args):
subprocess.run(list(map(str, args)), cwd=ROOT, check=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--build-type", choices=("Debug", "Release"), default="Debug")
parser.add_argument("--test", action="store_true")
options = parser.parse_args()
build = ROOT / "build" / ("workspace_" + options.build_type.lower())
for name in ("modulus", "noto", "ligo"):
source = ROOT.parent / name
if not (source / "conanfile.py").exists():
parser.error(f"Missing sibling repository: {source}")
run("conan", "export", source)
run("conan", "install", ROOT, "--output-folder", build / "dependencies", "--build=missing", "-s", "compiler.cppstd=23", "-s", "build_type=" + options.build_type, "-o", "&:with_tests=False")
run("cmake", "-S", ROOT, "-B", build, "-G", "Ninja", "-DCMAKE_TOOLCHAIN_FILE=" + str(build / "dependencies/conan_toolchain.cmake"), "-DCMAKE_BUILD_TYPE=" + options.build_type, "-DBUILD_TESTING=ON")
run("cmake", "--build", build, "--parallel", "2")
if options.test:
run("ctest", "--test-dir", build, "--output-on-failure")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
"""Reproduce reviewed public candidate facts and the national contest inventory.
NC: official state-only November 3 list, report dated September 21, 2026,
pages 1–3. Ballot names and parties only; no contact/address information.
Re-running does not fetch or imply a more recent review.
"""
import csv
import json
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parents[1]
SOURCE = {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z",
}
# Reviewed transcription. Party order follows the official list.
CONTESTS = {
"senate": "GRE:Michael Dublin|DEM:Roy Cooper|LIB:Shannon W. Bray|REP:Michael Whatley",
"house-01": "DEM:Don Davis|REP:Laurie Buckhout|LIB:Tom Bailey",
"house-02": "REP:Eugene F. Douglass|DEM:Deborah K. Ross|LIB:Matthew Laszacs",
"house-03": "LIB:Daniel Cavender|DEM:Raymond Smith|REP:Greg Murphy",
"house-04": "DEM:Valerie P. Foushee|LIB:Guy Meilleur|REP:Mahesh (Max) Ganorkar",
"house-05": "REP:Virginia Foxx|LIB:Robert B. Luffman|DEM:Chuck Hubbard",
"house-06": "REP:Addison McDowell|DEM:Cyril Jefferson",
"house-07": "LIB:Maad Abu-Ghazalah|REP:David Rouzer|DEM:Kimberly Hardy",
"house-08": "GRE:Bo Whitehead|DEM:Colby Watson|REP:Mark Harris",
"house-09": "DEM:Richard N. Ojeda II|REP:Richard Hudson",
"house-10": "LIB:Steven Feldman|DEM:Ashley Bell|REP:Pat Harrigan",
"house-11": "REP:Jennifer Capps Balkcom|DEM:Jamie Ager|LIB:Travis Groo",
"house-12": "REP:Jack Codiga|DEM:Alma S. Adams",
"house-13": "DEM:Paul Barringer|LIB:Steven Swinton|REP:Brad Knott",
"house-14": "DEM:Lakesha Womack|REP:Tim Moore",
}
SEATS = dict(zip("AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY".split(),
[7, 1, 9, 4, 52, 8, 5, 1, 28, 14, 2, 2, 17, 9, 4, 4, 6, 6, 2, 8, 9, 13, 8, 4, 8, 2, 3, 4, 2, 12, 3, 26, 14, 1, 15, 5, 6, 17, 2, 7, 1, 9, 38, 4, 1, 11, 10, 2, 8, 1]))
def write(path, value):
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n")
def main():
catalog = {"schema_version": 1, "kind": "catalog", "id": "nc-2026-10-04", "mode": "real",
"label": "North Carolina official list · partial US catalog", "as_of": SOURCE["retrieved_at"], "source": SOURCE,
"complete": False, "races": [], "candidates": [],
"notes": "Reviewed from official PDF pages 1–3, report dated 2026-09-21. Retrieval has day precision; midnight is a date placeholder. Federal contests only; NC has no scheduled 2026 governor race. Refresh against official changes before freezing."}
for contest, candidates in CONTESTS.items():
race = "2026-nc-" + contest + "-general"
catalog["races"].append({"id": race, "state": "NC", "office": contest.split("-")[0], "round": "general", "name": "North Carolina " + contest.replace("-", " ").title()})
for row in candidates.split("|"):
party, name = row.split(":", 1)
candidate_id = race + "-" + re.sub("[^a-z0-9]+", "-", name.lower()).strip("-")
catalog["candidates"].append({"id": candidate_id, "race_id": race, "name": name, "party": party,
"affiliation": {"DEM": "democrat", "REP": "republican"}.get(party, "other"), "source": SOURCE, "qualified": True, "ballot_lines": [party], "provider_ids": {}})
write(ROOT / "data/real/nc-2026-10-04.json", catalog)
combined = json.loads((ROOT / "data/real/mi-2026-10-04.json").read_text())
combined.update(id="us-2026-10-04-partial", label="Michigan + North Carolina · partial US catalog",
notes="Combined reviewed official lists. Each candidacy retains its original source and ID. Retrieval timestamps have day precision. 48 states still need imports; no live result feed is verified.",
sources=[combined["source"], SOURCE])
combined["races"] += catalog["races"]
combined["candidates"] += catalog["candidates"]
write(ROOT / "data/real/us-2026-10-04-partial.json", combined)
rows = list(csv.DictReader((ROOT / "data/coverage_2026.csv").open()))
inventory = []
for row in rows:
if row["state"] == "NC" and row["office"] in ("house", "senate"):
row["catalog"] = "official_partial"
if row["scheduled_2026"] != "yes":
continue
state, office = row["state"], row["office"]
for district in range(1, SEATS[state] + 1) if office == "house" else [None]:
seat = f"-{district:02}" if district else ""
special = office == "senate" and state in ("FL", "OH")
inventory.append({"id": f"2026-{state.lower()}-{office}{seat}-{'special' if special else 'general'}",
"state": state, "office": office, "district": district, "election_date": "2026-11-03", "contest_type": "special" if special else "general", "scoring_round": "november_first"})
with (ROOT / "data/coverage_2026.csv").open("w", newline="") as file:
writer = csv.DictWriter(file, fieldnames=rows[0].keys(), lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
assert len(inventory) == 506, len(inventory)
write(ROOT / "data/contests_2026.json", {"reviewed_at": "2026-10-04", "scope": "50 states; 435 House, 35 Senate (including FL/OH special), 36 governor; November contests only",
"sources": ["https://www.census.gov/data/tables/2020/dec/2020-apportionment-data.html", "https://www.senate.gov/senators/Class_II.htm", "https://about.usps.com/what/government-services/election-mail/pdf/2026-election-mail-kit.pdf"], "contests": inventory})
lines = ['#pragma once', '#include <array>', '#include <string_view>', '', 'namespace fpol {', 'inline constexpr auto national_races = std::array{']
lines += [' std::string_view{"' + race["id"] + '"},' for race in inventory]
lines += ['};', '}']
(ROOT / "src/national_races.hpp").write_text("\n".join(lines) + "\n")
if __name__ == "__main__":
main()