Add Stork family name board MVP with invite auth and votes.
CI / python-ci (push) Successful in 1m39s
CI / secret-scan (push) Successful in 32s

Includes multilingual etymology fields, seed data for starter names, and CI-ready Python project layout.
This commit is contained in:
2026-08-06 16:43:06 -04:00
commit b88791a0b7
25 changed files with 1601 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Copy to .env on the deploy host (never commit real tokens).
STORK_INVITE_TOKEN=change-me-long-random
STORK_ADMIN_TOKEN=change-me-admin-random
STORK_COOKIE_SECURE=false
STORK_DATA=./data
+48
View File
@@ -0,0 +1,48 @@
---
# Homelab CI — Python lane + secret scan.
# POLICY: lint and tests are HARD gates. Never add `|| true` to them.
name: CI
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
jobs:
python-ci:
runs-on: [homelab, self-hosted, linux, python]
container:
image: node:20-bookworm
steps:
- uses: actions/checkout@v4
- name: Install Python tooling
run: |
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv
python3 -m pip install --upgrade pip --break-system-packages
if [ -f requirements.txt ]; then pip install -r requirements.txt --break-system-packages; fi
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt --break-system-packages; fi
pip install pytest ruff --break-system-packages
- name: Ruff lint (hard gate)
run: ruff check .
- name: Pytest (hard gate)
run: pytest -q
# Advisory-only scanners (allowed to be soft — they're noisy, not gates)
- name: Bandit (advisory)
run: pip install bandit --break-system-packages && bandit -r . -q || true
secret-scan:
runs-on: [homelab, self-hosted, linux, heavy]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks
run: |
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
detect --source /repo --config /repo/.gitleaks.toml --no-banner --redact
+55
View File
@@ -0,0 +1,55 @@
# --- OS / editor ---
.DS_Store
Thumbs.db
.vscode/
.idea/
*.swp
# --- Secrets & env (real values never in git) ---
.env
.env.*
!.env.example
*.pem
*.key
*password*
*credential*
!*example*
# --- Real data (commit *.example.* fixtures instead) ---
data/
!data/
data/*
!data/*.example.*
*.sqlite3
*.local.yml
*.local.json
# --- Python ---
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# --- Node ---
node_modules/
dist/
coverage/
npm-debug.log*
# --- Outputs / scratch ---
out/
tmp/
.tmp/
scratch/
*.log
# --- Agent tooling (local, not for the repo) ---
.cursor/
.claude/
.codex/
.agents/
.opencode/
+36
View File
@@ -0,0 +1,36 @@
# Repo gitleaks config — same rules as the global fallback
# (~/.config/git/gitleaks.toml) so local hook and CI agree.
title = "repo gitleaks"
[extend]
useDefault = true
[[rules]]
id = "env-file-committed"
description = "Raw .env file (or backup/variant) staged for commit"
path = '''(^|/)\.env(\.(backup|bak|old|orig|save|local|prod|production|dev|development|staging))?$'''
[[rules]]
id = "password-or-credential-file"
description = "File name indicates stored password/credential material"
path = '''(?i)(^|/)[^/]*(password|passwd|credential)[^/]*\.(txt|json|ya?ml|env|cfg|ini)$'''
[[rules]]
id = "private-key-file"
description = "Private key or keystore file staged for commit"
path = '''(^|/)(id_rsa|id_ed25519|id_ecdsa)[^/]*$|\.(pem|p12|pfx|key)$'''
[[rules]]
id = "dotfile-secret-token"
description = "Hidden standalone secret/token file"
path = '''(^|/)\.[^/]*(secret|token)[^/]*$'''
[allowlist]
description = "Legitimate non-secret files matching the patterns above"
paths = [
'''(?i)\.(example|sample|template|dist)$''',
'''(^|/)\.env\.example[^/]*$''',
'''(^|/)[^/]*\.pub$''',
'''(^|/)package(-lock)?\.json$''',
]
+16
View File
@@ -0,0 +1,16 @@
# AGENTS.md — stork
Short orientation for Cursor agents.
## Defaults
- Family baby-name board (votes + en/ru/he notes). Run/install: `README.md`.
- Lint/tests are CI hard gates: `make lint` / `make test` — never `|| true`.
- Secrets: Infisical `/apps/stork` or gitignored `.env`. Never commit tokens.
- Deploy/DNS/Caddy/Kuma live in `~/Documents/code/ansible` (`make deploy-stork`).
## Close-out
1. `make test` and `make lint` before claiming done.
2. Do not put LAN IPs or real invites in this repo.
3. Merge only when the user asks.
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY stork /app/stork
COPY static /app/static
ENV STORK_DATA=/data
ENV STORK_COOKIE_SECURE=true
EXPOSE 8094
CMD ["uvicorn", "stork.app:app", "--host", "0.0.0.0", "--port", "8094"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ilia Dobkin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+28
View File
@@ -0,0 +1,28 @@
.PHONY: help install test lint run docker-build seed
help: ## Show targets
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-16s %s\n", $$1, $$2}'
install: ## Install runtime + dev deps into .venv (prefers python3.12)
@PY=$$(command -v python3.12 || command -v python3); \
$$PY -m venv .venv
.venv/bin/pip install -U pip
.venv/bin/pip install -r requirements-dev.txt
lint: ## Ruff lint (CI hard gate)
ruff check .
test: ## Pytest (CI hard gate)
pytest -q
run: ## Local uvicorn on :8094 (needs .env)
@test -f .env || (echo "Copy .env.example to .env first" && exit 1)
set -a && . ./.env && set +a && uvicorn stork.app:app --reload --host 127.0.0.1 --port 8094
seed: ## Seed example first names (usage: make seed STORK_INVITE=... [STORK_URL=...])
@test -n "$(STORK_INVITE)" || (echo "Set STORK_INVITE=..." && exit 1)
STORK_URL="$(or $(STORK_URL),http://127.0.0.1:8094)" STORK_INVITE="$(STORK_INVITE)" \
.venv/bin/python scripts/seed_names.py
docker-build: ## Build local image
docker build -t homelab-stork:latest .
+42
View File
@@ -0,0 +1,42 @@
# Stork
Private family board for baby **first** and **middle** name ideas: suggest
names, vote, and keep origin / meaning / pronunciation in English, Russian,
and Hebrew.
Homelab deploy lives in the ansible repo (`make deploy-stork`). This repo is
the app only — no LAN IPs or production secrets here.
## Quick start (local)
```bash
cp .env.example .env # set STORK_INVITE_TOKEN (and optional STORK_ADMIN_TOKEN)
make install
make test
make run
```
Open http://127.0.0.1:8094 — enter the invite code and your display name.
Share `http://127.0.0.1:8094/?invite=YOUR_TOKEN` with family for one-tap entry.
## API sketch
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/health` | Liveness |
| POST | `/api/session` | `{invite, display_name}` → cookies |
| GET | `/api/names?kind=first\|middle` | Ranked list |
| POST | `/api/names` | Add name + optional locales |
| PATCH | `/api/names/{id}` | Update locale notes |
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` |
| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
## Secrets
- Local: gitignored `.env`
- Prod: Infisical `/apps/stork` (or host `.env` written by deploy) — never git
## Production shape
Same host pattern as Compare: Docker on automationlab LXC **225**, public
HTTPS via Caddy (`stork.levkin.ca`). See ansible `docs/guides/stork-deploy.md`.
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Create the Gitea repo for this project with gates preconfigured:
# private repo, delete-branch-after-merge, branch protection on main
# requiring the CI contexts, initial push.
#
# Usage: ./bootstrap.sh <repo-name> <python|node> [--public]
set -euo pipefail
NAME="${1:?usage: bootstrap.sh <repo-name> <python|node> [--public]}"
LANE="${2:?usage: bootstrap.sh <repo-name> <python|node> [--public]}"
PRIVATE=true
[[ "${3:-}" == "--public" ]] && PRIVATE=false
GITEA="https://git.levkin.ca"
OWNER="ilia"
# shellcheck disable=SC1090
source "$HOME/.config/hermes/gitea.env" # provides GITEA_TOKEN
api() {
local method="$1" path="$2" body="${3:-}"
curl -sf -X "$method" -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" ${body:+-d "$body"} "${GITEA}/api/v1${path}"
}
case "$LANE" in
python) CONTEXTS='["CI / python-ci (pull_request)","CI / secret-scan (pull_request)"]' ;;
node) CONTEXTS='["CI / node-ci (pull_request)","CI / secret-scan (pull_request)"]' ;;
*) echo "lane must be python or node" >&2; exit 1 ;;
esac
if [[ ! -f ".gitea/workflows/ci.yml" ]]; then
echo "ERROR: .gitea/workflows/ci.yml missing — copy one from ci-templates/ first." >&2
exit 1
fi
echo "Creating ${OWNER}/${NAME} (private=${PRIVATE})..."
api POST /user/repos "{\"name\":\"${NAME}\",\"private\":${PRIVATE},\"default_branch\":\"main\"}" >/dev/null
echo "Enabling delete-branch-after-merge..."
api PATCH "/repos/${OWNER}/${NAME}" '{"default_delete_branch_after_merge": true}' >/dev/null
echo "Pushing main..."
git remote add origin "${GITEA}/${OWNER}/${NAME}.git" 2>/dev/null || true
git push "https://${OWNER}:${GITEA_TOKEN}@git.levkin.ca/${OWNER}/${NAME}.git" main
echo "Adding branch protection (required contexts: ${CONTEXTS})..."
api POST "/repos/${OWNER}/${NAME}/branch_protections" \
"{\"branch_name\":\"main\",\"rule_name\":\"main\",\"enable_push\":false,\"enable_status_check\":true,\"status_check_contexts\":${CONTEXTS},\"required_approvals\":0}" >/dev/null
echo "Done: ${GITEA}/${OWNER}/${NAME}"
echo "Work via PRs from now on; merge with:"
echo " GITEA_REPO=${NAME} bash ~/Documents/code/hermes/scripts/gitea-merge-when-green.sh <pr>"
+109
View File
@@ -0,0 +1,109 @@
{
"names": [
{
"kind": "first",
"spelling": "Shai",
"locales": {
"en": {
"pronunciation": "SHY (rhymes with sky)",
"origin": "Hebrew (שי)",
"meaning": "Gift / present; sometimes a short form of Isaiah (Yeshayahu). Variants: Shay, Shai."
},
"ru": {
"pronunciation": "Шай",
"origin": "иврит",
"meaning": "«дар», «подарок»; иногда уменьшительное от Исайи. Варианты: Shay, Shai."
},
"he": {
"pronunciation": "שַׁי — shai",
"origin": "עברית",
"meaning": "שי — מתנה / מנחה. לפעמים קיצור של ישעיהו. כתיבים: שי, Shay."
}
}
},
{
"kind": "first",
"spelling": "Roze",
"locales": {
"en": {
"pronunciation": "ROHZ",
"origin": "Latin rosa / flower name (also Germanic Rose lineage)",
"meaning": "Rose (the flower); love and beauty. Spelling variant of Rose. Related: Rose, Rosa, Roza (RU/PL), Rosie, Rožė (LT), Roze (LV)."
},
"ru": {
"pronunciation": "Ро́уз / Ро́за",
"origin": "латинское rosa; славянская форма Роза",
"meaning": "роза (цветок). Варианты: Rose, Rosa, Roza, Rosie."
},
"he": {
"pronunciation": "רוֹז",
"origin": "לטינית / שם פרח",
"meaning": "ורד / רוזה. כתיבים קרובים: Rose, Rosa, Roza; בעברית גם ורד (Vered)."
}
}
},
{
"kind": "first",
"spelling": "Odet",
"locales": {
"en": {
"pronunciation": "oh-DET",
"origin": "French diminutive (Odette) from Germanic od-/ot- “wealth”",
"meaning": "Wealth / prosperity. Short form of Odette (Swan Lake). Variants: Odette, Odetta, Ode, Oda."
},
"ru": {
"pronunciation": "Оде́т",
"origin": "французское Odette ← германское «богатство»",
"meaning": "богатство / достаток. Варианты: Odette, Одетта, Odetta."
},
"he": {
"pronunciation": "אוֹדֶט",
"origin": "צרפתית (Odette) משורש גרמאני",
"meaning": "עושר / שפע. צורות: Odette, Odetta, Odet."
}
}
},
{
"kind": "first",
"spelling": "Riva",
"locales": {
"en": {
"pronunciation": "REE-vah",
"origin": "Hebrew / Yiddish short form of Rivka (Rebecca)",
"meaning": "Short for Rivka — “to bind / tie; captivating.” Related: Rivka, Rebecca, Rebekah, Rifka, Reba."
},
"ru": {
"pronunciation": "Ри́ва",
"origin": "уменьшительное от Ривка / Ревекка",
"meaning": "краткое от Ривка («связывать», «пленить»). Варианты: Rivka, Rebecca, Rifka."
},
"he": {
"pronunciation": "רִיבָה",
"origin": "קיצור של רבקה",
"meaning": "צורת חיבה של רבקה. קשור ל־Rivka / Rebecca."
}
}
},
{
"kind": "first",
"spelling": "Rivka",
"locales": {
"en": {
"pronunciation": "riv-KAH / REEV-kah",
"origin": "Hebrew biblical (רִבְקָה) — wife of Isaac",
"meaning": "From root “to bind / tie / join”; often glossed captivating. English: Rebecca, Rebekah. Short forms: Riva, Rifka, Becky."
},
"ru": {
"pronunciation": "Ри́вка",
"origin": "библейское еврейское имя (Ревекка)",
"meaning": "«связывать», «соединять»; библейская Ревекка. Варианты: Rebecca, Rebekah, Riva, Rifka."
},
"he": {
"pronunciation": "רִבְקָה — rivká",
"origin": "תנ״ך — אשת יצחק",
"meaning": "שורש ר־ב־ק (לקשור / לחבר). באנגלית Rebecca. קיצורים: Riva, Rifka."
}
}
}
]
}
+18
View File
@@ -0,0 +1,18 @@
# Stork — family baby-name board (local / generic bind).
# Production bind + Caddy live in ansible deploy/stork/.
services:
stork:
build: .
image: homelab-stork:latest
container_name: stork
restart: unless-stopped
ports:
- "8094:8094"
volumes:
- ./data:/data
env_file:
- .env
environment:
STORK_DATA: /data
STORK_COOKIE_SECURE: "${STORK_COOKIE_SECURE:-false}"
+138
View File
@@ -0,0 +1,138 @@
# Writing READMEs and guides
House style for Markdown in Levkin repos. Goal: docs that read like a
competent human wrote them for another engineer, not like a product page
or a chatbot reply.
Agents: also follow `~/.cursor/rules/docs-writing.mdc` and the humanizer
skill when rewriting prose.
## What belongs where
| Doc | Job |
|-----|-----|
| `README.md` | What it is, how to run it, where config and deeper docs live |
| `docs/guides/*.md` | How-to for one task (deploy, upgrade, recover) |
| `docs/reference/*.md` | Stable facts (URLs, ports, schema, CLI flags) |
| `AGENTS.md` / `.cursorrules` | Short orientation for agents, not a second README |
| `CHANGELOG.md` | What changed, by version |
| Scratch (`*_PLAN`, `*_SUMMARY`, `FIX_*`, `HANDOFF`, `PROGRESS_*`) | Temporary. Archive or delete when done; do not polish as product docs |
Do not paste the same wall of text into README and three guides. Link once.
## README checklist
Every repo README should answer these, in roughly this order:
1. **What** — one or two plain sentences. Name the audience if it is not obvious.
2. **Status** — active, beta, archived, research-only. Put archived status in a blockquote at the top.
3. **Run it** — install or `make` entrypoint and one command that works.
4. **Config** — `.env.example`, Infisical path (`/apps/<repo>`), or vault keys. Never real secrets.
5. **Pointers** — links to guides; do not duplicate them.
6. **License** — or say the repo is private / personal.
Add when useful: short architecture sketch, known limits, deploy note
("ops runbooks live in the ansible repo").
Skip in READMEs: emoji decoration, marketing feature walls, welcome blurbs,
kanban ceremony, and change-narration ("this was added to replace…").
## Guide checklist
A how-to guide should:
1. State the goal in the first sentence ("Deploy X on LXC Y").
2. List prerequisites (access, packages, secrets location).
3. Give ordered steps with copy-pasteable commands.
4. End with a verify step and what "done" looks like.
5. Link related runbooks instead of restating them.
One job per guide. Split if you need more than one primary verb.
## Voice and formatting
### Do
- Write in sentence case for headings: `## Getting started`, not `## Getting Started`.
- Prefer short paragraphs and concrete nouns (paths, hosts, commands).
- Use tables for env vars, ports, and host inventories.
- Use fenced code blocks with a language tag when it helps.
- Prefer `is` / `has` / `runs` over `serves as` / `boasts` / `features`.
- Keep bold for rare emphasis (a warning, a path). Not for every label.
### Do not
- Decorate headings or bullets with emoji (`🚀`, `✅`, `💡`, …). If a CLI
prints emoji, quote that output in a code block; do not restyle the doc around it.
- Use bold-colon list items as fake headings:
`- **Web-Based**: Modern React frontend…` → write a normal sentence or table.
- Stuff feature lists with adjectives: *state-of-the-art*, *seamless*,
*robust*, *powerful*, *comprehensive*, *cutting-edge*, *production-ready*,
*unlock*, *leverage*, *delve*, *landscape*, *tapestry*, *testament*.
- Open with chatbot filler: "Welcome to…", "Here's what you need to know",
"Let's dive in", "It is important to note that…".
- Overuse em dashes. Prefer a period, comma, colon, or parentheses.
- Title-Case Every Heading Like A Brochure.
- Invent facts, URLs, version numbers, or "experts say" attributions while editing.
### Examples
Bad:
```markdown
# 🚀 PunimTag
**Modern Photo Management and Facial Recognition System**
A fast, simple, and modern web application using state-of-the-art DeepFace AI.
## Key Features
- **Web-Based**: Modern React frontend with FastAPI backend
- **Privacy-First**: All data stored locally, no cloud dependencies
```
Good:
```markdown
# PunimTag
Local photo library with face recognition (DeepFace / ArcFace). Admin UI
(React) and viewer (Next.js) share one monorepo and a PostgreSQL backend.
## Features
- Face detect and match with RetinaFace + ArcFace (or other listed models)
- Search by person, date, tag, or folder
- Runs on your machine; no cloud dependency for core data
```
## Public vs private repos
Public READMEs and guides:
- No LAN IPs, no `10.0.10.x`, no root SSH one-liners.
- No absolute home paths (`/Users/…`).
- No other people's PII.
Private homelab docs (ansible, hermes, levkin) may name hosts and IPs.
Still skip emoji and marketing tone.
Repos under `Gitilia/*` are push mirrors from Gitea. After merging a release or
public README/docs change, sync and verify from the ansible repo:
`make github-mirror-sync` then `make github-mirror-health` (see
`ansible/docs/guides/github-mirrors.md`).
## When rewriting existing docs
1. Preserve every real fact (commands, paths, versions, URLs).
2. Cut fluff; keep the procedure.
3. Fix formatting to match this guide in the same pass.
4. Leave code blocks, frontmatter, and link targets intact unless the link is wrong.
5. Scratch/status files: delete or move under `docs/archive/` rather than
"humanizing" a dead plan.
## Related
- Project skeleton: this repo's root `README.md`
- Hard gates (CI, secrets, LICENSE): `~/.cursor/rules/new-project-standards.mdc`
- Tone scrub: Cursor skill `humanizer`
+3
View File
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest==8.3.5
httpx==0.28.1
ruff==0.11.5
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
pydantic==2.11.3
+7
View File
@@ -0,0 +1,7 @@
# Lint gate config — enforced in CI (no || true). Start pragmatic; tighten
# per-project by extending `select`, never by muting the CI step.
target-version = "py311"
line-length = 120
[lint]
select = ["E4", "E7", "E9", "F", "I"]
+48
View File
@@ -0,0 +1,48 @@
"""Seed starter first names into a local or remote Stork instance.
Usage:
# against local make run (cookies via invite):
STORK_URL=http://127.0.0.1:8094 STORK_INVITE=local-dev-invite \\
.venv/bin/python scripts/seed_names.py
# against prod after deploy:
STORK_URL=https://stork.levkin.ca STORK_INVITE="$(ssh root@10.0.10.45 grep STORK_INVITE_TOKEN /opt/stork/.env | cut -d= -f2-)" \\
.venv/bin/python scripts/seed_names.py
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parents[1]
SEED = ROOT / "data" / "seed-names.example.json"
def main() -> int:
base = os.environ.get("STORK_URL", "http://127.0.0.1:8094").rstrip("/")
invite = os.environ.get("STORK_INVITE", "").strip()
display = os.environ.get("STORK_DISPLAY_NAME", "Ilia").strip() or "Ilia"
if not invite:
print("Set STORK_INVITE", file=sys.stderr)
return 1
names = json.loads(SEED.read_text())["names"]
with httpx.Client(base_url=base, timeout=30.0) as client:
session = client.post("/api/session", json={"invite": invite, "display_name": display})
session.raise_for_status()
for item in names:
res = client.post("/api/names", json=item)
if res.status_code == 400 and "already exists" in res.text:
print(f"skip {item['spelling']} (exists)")
continue
res.raise_for_status()
print(f"added {item['kind']}: {item['spelling']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+323
View File
@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stork — family name board</title>
<style>
:root {
--bg: #f3efe6;
--ink: #1c2430;
--muted: #5c6674;
--card: #fffdf8;
--line: #d9d0c0;
--accent: #2f6f6a;
--up: #2f6f6a;
--down: #9a4a3a;
--focus: #c9a227;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
color: var(--ink);
background:
radial-gradient(1200px 500px at 10% -10%, #e8dfc8 0%, transparent 55%),
radial-gradient(900px 400px at 100% 0%, #d7e4df 0%, transparent 50%),
var(--bg);
min-height: 100vh;
}
main { max-width: 720px; margin: 0 auto; padding: 1.25rem 1rem 3rem; }
h1 { font-size: 2rem; margin: 0 0 0.25rem; letter-spacing: 0.02em; }
.sub { color: var(--muted); margin: 0 0 1.25rem; }
.panel {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 1rem;
margin-bottom: 1rem;
}
label { display: block; font-size: 0.9rem; color: var(--muted); margin-bottom: 0.35rem; }
input, select, textarea, button {
font: inherit;
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
}
textarea { min-height: 3.2rem; resize: vertical; }
button {
width: auto;
cursor: pointer;
background: var(--accent);
color: #fff;
border-color: transparent;
padding: 0.65rem 1rem;
}
button.secondary { background: transparent; color: var(--ink); border-color: var(--line); }
button.vote { min-width: 2.5rem; padding: 0.4rem 0.55rem; }
button.vote.active-up { background: var(--up); color: #fff; }
button.vote.active-down { background: var(--down); color: #fff; }
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; }
.row > * { flex: 1 1 140px; }
.tabs { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.tabs button { flex: 1; }
.tabs button.active { outline: 2px solid var(--focus); }
.name {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
align-items: start;
padding: 0.85rem 0;
border-top: 1px solid var(--line);
}
.name:first-child { border-top: 0; }
.score {
font-variant-numeric: tabular-nums;
font-weight: 700;
min-width: 2rem;
text-align: center;
padding-top: 0.35rem;
}
.spelling { font-size: 1.25rem; margin: 0 0 0.2rem; }
.meta { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.4rem; }
.langs details { margin-top: 0.35rem; }
.langs summary { cursor: pointer; color: var(--accent); }
.lang-block { margin: 0.4rem 0; padding-left: 0.2rem; }
.lang-block strong { display: inline-block; min-width: 2rem; }
.votes { display: flex; flex-direction: column; gap: 0.35rem; }
.error { color: var(--down); margin: 0.5rem 0 0; }
.hidden { display: none !important; }
.grid-3 { display: grid; gap: 0.5rem; }
@media (min-width: 640px) {
.grid-3 { grid-template-columns: repeat(3, 1fr); }
}
</style>
</head>
<body>
<main>
<h1>Stork</h1>
<p class="sub">Family name ideas — vote, add notes in English, Russian, and Hebrew.</p>
<section id="gate" class="panel">
<label for="invite">Invite code</label>
<input id="invite" autocomplete="off" />
<label for="display" style="margin-top:0.75rem">Your name (shown with your ideas/votes)</label>
<input id="display" autocomplete="nickname" />
<p class="error hidden" id="gate-error"></p>
<div class="row" style="margin-top:0.75rem">
<button id="enter" type="button">Enter</button>
</div>
</section>
<section id="board" class="hidden">
<div class="panel">
<p class="meta" id="who"></p>
<div class="tabs">
<button type="button" class="secondary active" data-kind="first">First names</button>
<button type="button" class="secondary" data-kind="middle">Middle names</button>
</div>
<div id="list"></div>
</div>
<div class="panel">
<h2 style="margin:0 0 0.75rem; font-size:1.15rem">Suggest a name</h2>
<label for="spelling">Spelling</label>
<input id="spelling" />
<div class="grid-3" style="margin-top:0.75rem">
<div>
<label>English pronunciation</label>
<input data-loc="en" data-field="pronunciation" />
<label style="margin-top:0.5rem">Origin</label>
<input data-loc="en" data-field="origin" />
<label style="margin-top:0.5rem">Meaning</label>
<textarea data-loc="en" data-field="meaning"></textarea>
</div>
<div>
<label>Russian pronunciation</label>
<input data-loc="ru" data-field="pronunciation" />
<label style="margin-top:0.5rem">Origin</label>
<input data-loc="ru" data-field="origin" />
<label style="margin-top:0.5rem">Meaning</label>
<textarea data-loc="ru" data-field="meaning"></textarea>
</div>
<div>
<label>Hebrew pronunciation</label>
<input data-loc="he" data-field="pronunciation" />
<label style="margin-top:0.5rem">Origin</label>
<input data-loc="he" data-field="origin" />
<label style="margin-top:0.5rem">Meaning</label>
<textarea data-loc="he" data-field="meaning"></textarea>
</div>
</div>
<p class="error hidden" id="add-error"></p>
<div class="row" style="margin-top:0.75rem">
<button id="add" type="button">Add to board</button>
</div>
</div>
</section>
</main>
<script>
const state = { kind: "first" };
function params() {
return new URLSearchParams(location.search);
}
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { detail: text }; }
if (!res.ok) {
const detail = data && (data.detail || data.message) || res.statusText;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return data;
}
function localePayload() {
const locales = { en: {}, ru: {}, he: {} };
document.querySelectorAll("[data-loc][data-field]").forEach((el) => {
locales[el.dataset.loc][el.dataset.field] = el.value.trim();
});
return locales;
}
function clearForm() {
document.getElementById("spelling").value = "";
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.value = ""; });
}
function renderLangs(locales) {
return ["en", "ru", "he"].map((lang) => {
const L = (locales && locales[lang]) || {};
const bits = [
L.pronunciation && `say: ${L.pronunciation}`,
L.origin && `origin: ${L.origin}`,
L.meaning && L.meaning,
].filter(Boolean);
if (!bits.length) return "";
return `<div class="lang-block"><strong>${lang}</strong> ${bits.join(" · ")}</div>`;
}).join("");
}
function renderList(items) {
const root = document.getElementById("list");
if (!items.length) {
root.innerHTML = `<p class="meta">No names yet — add the first idea below.</p>`;
return;
}
root.innerHTML = items.map((n) => {
const langs = renderLangs(n.locales);
return `
<article class="name" data-id="${n.id}">
<div class="score">${n.score}</div>
<div>
<p class="spelling">${escapeHtml(n.spelling)}</p>
<p class="meta">suggested by ${escapeHtml(n.created_by || "someone")} · ${n.vote_count} vote(s)</p>
${langs ? `<div class="langs"><details><summary>Origin & pronunciation</summary>${langs}</details></div>` : `<p class="meta">No notes yet</p>`}
</div>
<div class="votes">
<button type="button" class="vote secondary ${n.my_vote === 1 ? "active-up" : ""}" data-vote="1" aria-label="Upvote">▲</button>
<button type="button" class="vote secondary ${n.my_vote === -1 ? "active-down" : ""}" data-vote="-1" aria-label="Downvote">▼</button>
</div>
</article>`;
}).join("");
root.querySelectorAll("button[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const id = btn.closest(".name").dataset.id;
let value = Number(btn.dataset.vote);
const item = items.find((x) => String(x.id) === String(id));
if (item && item.my_vote === value) value = 0;
await api(`/api/names/${id}/vote`, { method: "POST", body: JSON.stringify({ value }) });
await refresh();
});
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
async function refresh() {
const data = await api(`/api/names?kind=${encodeURIComponent(state.kind)}`);
renderList(data.items || []);
}
async function showBoard(displayName) {
document.getElementById("gate").classList.add("hidden");
document.getElementById("board").classList.remove("hidden");
document.getElementById("who").textContent = `Signed in as ${displayName}`;
await refresh();
}
document.querySelectorAll(".tabs button").forEach((btn) => {
btn.addEventListener("click", async () => {
state.kind = btn.dataset.kind;
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b === btn));
await refresh();
});
});
document.getElementById("enter").addEventListener("click", async () => {
const err = document.getElementById("gate-error");
err.classList.add("hidden");
try {
const invite = document.getElementById("invite").value.trim();
const display_name = document.getElementById("display").value.trim();
const data = await api("/api/session", {
method: "POST",
body: JSON.stringify({ invite, display_name }),
});
const url = new URL(location.href);
url.searchParams.delete("invite");
history.replaceState({}, "", url.pathname);
await showBoard(data.display_name);
} catch (e) {
err.textContent = e.message;
err.classList.remove("hidden");
}
});
document.getElementById("add").addEventListener("click", async () => {
const err = document.getElementById("add-error");
err.classList.add("hidden");
try {
await api("/api/names", {
method: "POST",
body: JSON.stringify({
kind: state.kind,
spelling: document.getElementById("spelling").value.trim(),
locales: localePayload(),
}),
});
clearForm();
await refresh();
} catch (e) {
err.textContent = e.message;
err.classList.remove("hidden");
}
});
(async () => {
const invite = params().get("invite");
if (invite) document.getElementById("invite").value = invite;
try {
const session = await api("/api/session");
if (session.authenticated) {
await showBoard(session.display_name);
}
} catch (_) { /* stay on gate */ }
})();
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
"""Stork — family baby-name board with votes and multilingual notes."""
__version__ = "0.1.0"
+206
View File
@@ -0,0 +1,206 @@
"""Stork HTTP API + invite-gated family UI."""
from __future__ import annotations
import os
import secrets
from pathlib import Path
from typing import Any
from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Response
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from stork.db import KINDS, LANGS, Store
DATA_DIR = Path(os.environ.get("STORK_DATA", "./data"))
INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip()
COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
STATIC = Path(__file__).resolve().parent.parent / "static"
app = FastAPI(title="Stork", docs_url=None, redoc_url=None)
store = Store(DATA_DIR / "stork.sqlite3")
if STATIC.is_dir():
app.mount("/static", StaticFiles(directory=STATIC), name="static")
class LocaleIn(BaseModel):
pronunciation: str = ""
origin: str = ""
meaning: str = ""
class NameIn(BaseModel):
kind: str = Field(pattern="^(first|middle)$")
spelling: str = Field(min_length=1, max_length=80)
locales: dict[str, LocaleIn] = Field(default_factory=dict)
class LocalesPatch(BaseModel):
locales: dict[str, LocaleIn]
class VoteIn(BaseModel):
value: int = Field(ge=-1, le=1)
class SessionIn(BaseModel):
invite: str = Field(min_length=1, max_length=200)
display_name: str = Field(min_length=1, max_length=80)
def _require_invite_configured() -> None:
if not INVITE_TOKEN:
raise HTTPException(503, "STORK_INVITE_TOKEN not configured")
def _invite_ok(invite: str | None) -> bool:
_require_invite_configured()
return bool(invite) and secrets.compare_digest(invite, INVITE_TOKEN)
def _session(
stork_invite: str | None = Cookie(default=None),
stork_voter: str | None = Cookie(default=None),
stork_name: str | None = Cookie(default=None),
x_stork_invite: str | None = Header(default=None),
) -> dict[str, str]:
invite = x_stork_invite or stork_invite
if not _invite_ok(invite):
raise HTTPException(401, "Invite required")
voter = (stork_voter or "").strip()
name = (stork_name or "").strip()
if not voter or not name:
raise HTTPException(401, "Session required — open the invite link and enter your name")
return {"voter_key": voter, "display_name": name}
def _admin(
x_stork_admin: str | None = Header(default=None),
) -> None:
if not ADMIN_TOKEN:
raise HTTPException(503, "Admin not configured")
if not x_stork_admin or not secrets.compare_digest(x_stork_admin, ADMIN_TOKEN):
raise HTTPException(403, "Admin token required")
@app.get("/api/health")
def health() -> dict[str, Any]:
return {"ok": True, "service": "stork"}
@app.get("/api/meta")
def meta() -> dict[str, Any]:
return {
"langs": list(LANGS),
"kinds": list(KINDS),
"invite_required": True,
}
@app.post("/api/session")
def create_session(body: SessionIn, response: Response) -> dict[str, Any]:
if not _invite_ok(body.invite.strip()):
raise HTTPException(403, "Invalid invite")
voter = secrets.token_urlsafe(16)
display = body.display_name.strip()
cookie_kwargs: dict[str, Any] = {
"httponly": True,
"samesite": "lax",
"secure": COOKIE_SECURE,
"max_age": 60 * 60 * 24 * 400,
"path": "/",
}
response.set_cookie("stork_invite", INVITE_TOKEN, **cookie_kwargs)
response.set_cookie("stork_voter", voter, **cookie_kwargs)
response.set_cookie("stork_name", display, **{**cookie_kwargs, "httponly": False})
return {"ok": True, "display_name": display}
@app.get("/api/session")
def get_session(
stork_invite: str | None = Cookie(default=None),
stork_voter: str | None = Cookie(default=None),
stork_name: str | None = Cookie(default=None),
x_stork_invite: str | None = Header(default=None),
) -> dict[str, Any]:
invite = x_stork_invite or stork_invite
authed = _invite_ok(invite) if INVITE_TOKEN else False
return {
"authenticated": authed and bool(stork_voter) and bool(stork_name),
"display_name": stork_name or "",
"has_invite": authed,
}
@app.get("/api/names")
def list_names(kind: str = "first", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
if kind not in KINDS:
raise HTTPException(400, "kind must be first or middle")
return {"items": store.list_names(kind, voter_key=session["voter_key"])}
@app.post("/api/names")
def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
locales = {k: v.model_dump() for k, v in body.locales.items()}
try:
return store.add_name(
kind=body.kind,
spelling=body.spelling,
created_by=session["display_name"],
locales=locales,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@app.patch("/api/names/{name_id}")
def patch_name(
name_id: int,
body: LocalesPatch,
session: dict[str, str] = Depends(_session),
) -> dict[str, Any]:
_ = session
locales = {k: v.model_dump() for k, v in body.locales.items()}
updated = store.update_locales(name_id, locales)
if not updated:
raise HTTPException(404, "Name not found")
return updated
@app.post("/api/names/{name_id}/vote")
def vote_name(
name_id: int,
body: VoteIn,
session: dict[str, str] = Depends(_session),
) -> dict[str, Any]:
try:
updated = store.vote(
name_id=name_id,
voter_key=session["voter_key"],
voter_name=session["display_name"],
value=body.value,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if not updated:
raise HTTPException(404, "Name not found")
return updated
@app.delete("/api/names/{name_id}")
def delete_name(name_id: int, _: None = Depends(_admin)) -> dict[str, Any]:
if not store.delete_name(name_id):
raise HTTPException(404, "Name not found")
return {"ok": True}
@app.get("/")
def home() -> FileResponse:
index = STATIC / "index.html"
if not index.is_file():
raise HTTPException(404, "UI missing")
return FileResponse(index)
+265
View File
@@ -0,0 +1,265 @@
"""SQLite persistence for names, locales, and votes."""
from __future__ import annotations
import json
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any
LANGS = ("en", "ru", "he")
KINDS = ("first", "middle")
_SCHEMA = """
CREATE TABLE IF NOT EXISTS names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK (kind IN ('first', 'middle')),
spelling TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
UNIQUE (kind, spelling COLLATE NOCASE)
);
CREATE TABLE IF NOT EXISTS locales (
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
lang TEXT NOT NULL CHECK (lang IN ('en', 'ru', 'he')),
pronunciation TEXT NOT NULL DEFAULT '',
origin TEXT NOT NULL DEFAULT '',
meaning TEXT NOT NULL DEFAULT '',
PRIMARY KEY (name_id, lang)
);
CREATE TABLE IF NOT EXISTS votes (
name_id INTEGER NOT NULL REFERENCES names(id) ON DELETE CASCADE,
voter_key TEXT NOT NULL,
voter_name TEXT NOT NULL DEFAULT '',
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
created_at REAL NOT NULL,
PRIMARY KEY (name_id, voter_key)
);
CREATE INDEX IF NOT EXISTS idx_names_kind ON names(kind);
CREATE INDEX IF NOT EXISTS idx_votes_name ON votes(name_id);
"""
class Store:
def __init__(self, path: Path) -> None:
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA foreign_keys = ON")
with self._lock:
self._conn.executescript(_SCHEMA)
self._conn.commit()
def close(self) -> None:
with self._lock:
self._conn.close()
def _empty_locales(self) -> dict[str, dict[str, str]]:
return {lang: {"pronunciation": "", "origin": "", "meaning": ""} for lang in LANGS}
def _normalize_locales(self, locales: dict[str, Any] | None) -> dict[str, dict[str, str]]:
out = self._empty_locales()
if not locales:
return out
for lang in LANGS:
raw = locales.get(lang) or {}
if not isinstance(raw, dict):
continue
out[lang] = {
"pronunciation": str(raw.get("pronunciation") or "").strip()[:200],
"origin": str(raw.get("origin") or "").strip()[:200],
"meaning": str(raw.get("meaning") or "").strip()[:500],
}
return out
def _row_to_name(self, row: sqlite3.Row, score: int, vote_count: int, my_vote: int | None) -> dict[str, Any]:
locales = self._empty_locales()
cur = self._conn.execute(
"SELECT lang, pronunciation, origin, meaning FROM locales WHERE name_id = ?",
(row["id"],),
)
for loc in cur.fetchall():
locales[loc["lang"]] = {
"pronunciation": loc["pronunciation"],
"origin": loc["origin"],
"meaning": loc["meaning"],
}
return {
"id": row["id"],
"kind": row["kind"],
"spelling": row["spelling"],
"created_by": row["created_by"],
"created_at": row["created_at"],
"locales": locales,
"score": score,
"vote_count": vote_count,
"my_vote": my_vote,
}
def list_names(self, kind: str, voter_key: str | None = None) -> list[dict[str, Any]]:
if kind not in KINDS:
raise ValueError("invalid kind")
with self._lock:
rows = self._conn.execute(
"SELECT * FROM names WHERE kind = ? ORDER BY spelling COLLATE NOCASE",
(kind,),
).fetchall()
out: list[dict[str, Any]] = []
for row in rows:
score_row = self._conn.execute(
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
(row["id"],),
).fetchone()
my_vote = None
if voter_key:
vote = self._conn.execute(
"SELECT value FROM votes WHERE name_id = ? AND voter_key = ?",
(row["id"], voter_key),
).fetchone()
if vote:
my_vote = int(vote["value"])
out.append(
self._row_to_name(
row,
int(score_row["score"]),
int(score_row["vote_count"]),
my_vote,
)
)
out.sort(key=lambda n: (-n["score"], n["spelling"].casefold()))
return out
def get_name(self, name_id: int, voter_key: str | None = None) -> dict[str, Any] | None:
with self._lock:
row = self._conn.execute("SELECT * FROM names WHERE id = ?", (name_id,)).fetchone()
if not row:
return None
score_row = self._conn.execute(
"SELECT COALESCE(SUM(value), 0) AS score, COUNT(*) AS vote_count FROM votes WHERE name_id = ?",
(name_id,),
).fetchone()
my_vote = None
if voter_key:
vote = self._conn.execute(
"SELECT value FROM votes WHERE name_id = ? AND voter_key = ?",
(name_id, voter_key),
).fetchone()
if vote:
my_vote = int(vote["value"])
return self._row_to_name(
row,
int(score_row["score"]),
int(score_row["vote_count"]),
my_vote,
)
def add_name(
self,
kind: str,
spelling: str,
created_by: str,
locales: dict[str, Any] | None = None,
) -> dict[str, Any]:
if kind not in KINDS:
raise ValueError("invalid kind")
spelling = spelling.strip()
if not spelling or len(spelling) > 80:
raise ValueError("spelling required (1–80 chars)")
created_by = (created_by or "").strip()[:80]
locs = self._normalize_locales(locales)
now = time.time()
with self._lock:
try:
cur = self._conn.execute(
"INSERT INTO names (kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?)",
(kind, spelling, created_by, now),
)
except sqlite3.IntegrityError as exc:
raise ValueError("name already exists for this kind") from exc
name_id = int(cur.lastrowid)
for lang, payload in locs.items():
self._conn.execute(
"INSERT INTO locales (name_id, lang, pronunciation, origin, meaning) VALUES (?, ?, ?, ?, ?)",
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
)
self._conn.commit()
result = self.get_name(name_id)
assert result is not None
return result
def update_locales(self, name_id: int, locales: dict[str, Any]) -> dict[str, Any] | None:
locs = self._normalize_locales(locales)
with self._lock:
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row:
return None
for lang, payload in locs.items():
self._conn.execute(
"""
INSERT INTO locales (name_id, lang, pronunciation, origin, meaning)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(name_id, lang) DO UPDATE SET
pronunciation = excluded.pronunciation,
origin = excluded.origin,
meaning = excluded.meaning
""",
(name_id, lang, payload["pronunciation"], payload["origin"], payload["meaning"]),
)
self._conn.commit()
return self.get_name(name_id)
def vote(
self,
name_id: int,
voter_key: str,
voter_name: str,
value: int,
) -> dict[str, Any] | None:
if value not in (-1, 0, 1):
raise ValueError("vote must be -1, 0, or 1")
voter_key = voter_key.strip()
if not voter_key or len(voter_key) > 64:
raise ValueError("invalid voter")
voter_name = (voter_name or "").strip()[:80]
with self._lock:
row = self._conn.execute("SELECT id FROM names WHERE id = ?", (name_id,)).fetchone()
if not row:
return None
if value == 0:
self._conn.execute(
"DELETE FROM votes WHERE name_id = ? AND voter_key = ?",
(name_id, voter_key),
)
else:
self._conn.execute(
"""
INSERT INTO votes (name_id, voter_key, voter_name, value, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(name_id, voter_key) DO UPDATE SET
value = excluded.value,
voter_name = excluded.voter_name,
created_at = excluded.created_at
""",
(name_id, voter_key, voter_name, value, time.time()),
)
self._conn.commit()
return self.get_name(name_id, voter_key=voter_key)
def delete_name(self, name_id: int) -> bool:
with self._lock:
cur = self._conn.execute("DELETE FROM names WHERE id = ?", (name_id,))
self._conn.commit()
return cur.rowcount > 0
def export_snapshot(self) -> str:
"""Debug helper for tests."""
with self._lock:
names = [dict(r) for r in self._conn.execute("SELECT * FROM names").fetchall()]
return json.dumps(names)
+34
View File
@@ -0,0 +1,34 @@
"""Shared fixtures for Stork API tests."""
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setenv("STORK_DATA", str(tmp_path / "data"))
monkeypatch.setenv("STORK_INVITE_TOKEN", "test-invite-token")
monkeypatch.setenv("STORK_ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("STORK_COOKIE_SECURE", "false")
import stork.app as app_mod
importlib.reload(app_mod)
with TestClient(app_mod.app) as test_client:
yield test_client
app_mod.store.close()
@pytest.fixture()
def authed(client: TestClient) -> TestClient:
res = client.post(
"/api/session",
json={"invite": "test-invite-token", "display_name": "Aunt Mira"},
)
assert res.status_code == 200
return client
+105
View File
@@ -0,0 +1,105 @@
"""API tests for Stork MVP."""
from __future__ import annotations
from fastapi.testclient import TestClient
def test_health(client: TestClient) -> None:
res = client.get("/api/health")
assert res.status_code == 200
assert res.json()["ok"] is True
def test_session_rejects_bad_invite(client: TestClient) -> None:
res = client.post("/api/session", json={"invite": "nope", "display_name": "Ilia"})
assert res.status_code == 403
def test_names_require_session(client: TestClient) -> None:
res = client.get("/api/names")
assert res.status_code == 401
def test_add_vote_and_rank(authed: TestClient) -> None:
a = authed.post(
"/api/names",
json={
"kind": "first",
"spelling": "Noa",
"locales": {
"he": {"pronunciation": "No-ah", "origin": "Hebrew", "meaning": "motion / movement"},
"en": {"pronunciation": "NO-uh", "origin": "Hebrew", "meaning": "movement"},
},
},
)
assert a.status_code == 200
noa_id = a.json()["id"]
b = authed.post("/api/names", json={"kind": "first", "spelling": "Levi"})
assert b.status_code == 200
levi_id = b.json()["id"]
assert authed.post(f"/api/names/{noa_id}/vote", json={"value": 1}).status_code == 200
assert authed.post(f"/api/names/{levi_id}/vote", json={"value": -1}).status_code == 200
listed = authed.get("/api/names?kind=first").json()["items"]
assert [n["spelling"] for n in listed] == ["Noa", "Levi"]
assert listed[0]["score"] == 1
assert listed[0]["my_vote"] == 1
assert listed[1]["score"] == -1
# clear vote
cleared = authed.post(f"/api/names/{noa_id}/vote", json={"value": 0})
assert cleared.status_code == 200
assert cleared.json()["my_vote"] is None
assert cleared.json()["score"] == 0
def test_duplicate_name_rejected(authed: TestClient) -> None:
assert authed.post("/api/names", json={"kind": "first", "spelling": "Maya"}).status_code == 200
dup = authed.post("/api/names", json={"kind": "first", "spelling": "maya"})
assert dup.status_code == 400
def test_middle_names_separate(authed: TestClient) -> None:
assert authed.post("/api/names", json={"kind": "first", "spelling": "Ari"}).status_code == 200
assert authed.post("/api/names", json={"kind": "middle", "spelling": "Ari"}).status_code == 200
first = authed.get("/api/names?kind=first").json()["items"]
middle = authed.get("/api/names?kind=middle").json()["items"]
assert len(first) == 1
assert len(middle) == 1
def test_patch_locales(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "first", "spelling": "Eden"}).json()
patched = authed.patch(
f"/api/names/{created['id']}",
json={"locales": {"ru": {"pronunciation": "Э-ден", "origin": "иврит", "meaning": "рай"}}},
)
assert patched.status_code == 200
assert patched.json()["locales"]["ru"]["meaning"] == "рай"
def test_admin_delete(authed: TestClient) -> None:
created = authed.post("/api/names", json={"kind": "first", "spelling": "Temp"}).json()
denied = authed.delete(f"/api/names/{created['id']}")
assert denied.status_code == 403
ok = authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"})
assert ok.status_code == 200
assert authed.get("/api/names?kind=first").json()["items"] == []
def test_second_voter_ranking(client: TestClient, authed: TestClient) -> None:
name = authed.post("/api/names", json={"kind": "first", "spelling": "Shai"}).json()
authed.post(f"/api/names/{name['id']}/vote", json={"value": 1})
other = client.post(
"/api/session",
json={"invite": "test-invite-token", "display_name": "Uncle Dan"},
)
assert other.status_code == 200
client.post(f"/api/names/{name['id']}/vote", json={"value": 1})
listed = client.get("/api/names?kind=first").json()["items"]
assert listed[0]["score"] == 2
assert listed[0]["vote_count"] == 2
+20
View File
@@ -0,0 +1,20 @@
"""Unit tests for Store ranking helpers."""
from __future__ import annotations
from pathlib import Path
from stork.db import Store
def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
store = Store(tmp_path / "t.sqlite3")
a = store.add_name("first", "Zed", "x")
store.add_name("first", "Ann", "x")
c = store.add_name("first", "Bo", "x")
store.vote(a["id"], "v1", "A", 1)
store.vote(c["id"], "v1", "A", 1)
store.vote(c["id"], "v2", "B", 1)
ranked = store.list_names("first")
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
store.close()