commit b88791a0b77323f0835523163c668ba9ef815891 Author: ilia Date: Thu Aug 6 16:43:06 2026 -0400 Add Stork family name board MVP with invite auth and votes. Includes multilingual etymology fields, seed data for starter names, and CI-ready Python project layout. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e72db10 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..d1ee41c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..737dafc --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..1d64149 --- /dev/null +++ b/.gitleaks.toml @@ -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$''', +] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cd632de --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d4dadb2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96c887d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c44ebf8 --- /dev/null +++ b/Makefile @@ -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 . diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ebf27c --- /dev/null +++ b/README.md @@ -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`. diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..2e9abea --- /dev/null +++ b/bootstrap.sh @@ -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 [--public] +set -euo pipefail + +NAME="${1:?usage: bootstrap.sh [--public]}" +LANE="${2:?usage: bootstrap.sh [--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 " diff --git a/data/seed-names.example.json b/data/seed-names.example.json new file mode 100644 index 0000000..89eb9cf --- /dev/null +++ b/data/seed-names.example.json @@ -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." + } + } + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..564da61 --- /dev/null +++ b/docker-compose.yml @@ -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}" diff --git a/docs/writing-docs.md b/docs/writing-docs.md new file mode 100644 index 0000000..efec301 --- /dev/null +++ b/docs/writing-docs.md @@ -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/`), 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` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f0e4c8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..fd53f84 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.3.5 +httpx==0.28.1 +ruff==0.11.5 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8e4382f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +pydantic==2.11.3 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..9c671d0 --- /dev/null +++ b/ruff.toml @@ -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"] diff --git a/scripts/seed_names.py b/scripts/seed_names.py new file mode 100644 index 0000000..ad35769 --- /dev/null +++ b/scripts/seed_names.py @@ -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()) diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..042801b --- /dev/null +++ b/static/index.html @@ -0,0 +1,323 @@ + + + + + + Stork — family name board + + + +
+

Stork

+

Family name ideas — vote, add notes in English, Russian, and Hebrew.

+ +
+ + + + + +
+ +
+
+ + +
+ + + diff --git a/stork/__init__.py b/stork/__init__.py new file mode 100644 index 0000000..2d259cb --- /dev/null +++ b/stork/__init__.py @@ -0,0 +1,3 @@ +"""Stork — family baby-name board with votes and multilingual notes.""" + +__version__ = "0.1.0" diff --git a/stork/app.py b/stork/app.py new file mode 100644 index 0000000..954d372 --- /dev/null +++ b/stork/app.py @@ -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) diff --git a/stork/db.py b/stork/db.py new file mode 100644 index 0000000..b598b76 --- /dev/null +++ b/stork/db.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2d06366 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..dbe6c79 --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..d189fc4 --- /dev/null +++ b/tests/test_store.py @@ -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()