From c0c102da7cbedb8f864f7f2f128c326479be0766 Mon Sep 17 00:00:00 2001 From: ilia Date: Thu, 6 Aug 2026 16:45:53 -0400 Subject: [PATCH] Allow header-based invite auth for seeding over HTTP. Secure cookies blocked the seed script against the LAN port; X-Stork-Invite + display-name headers fix that. --- scripts/seed_names.py | 15 ++++++--------- stork/app.py | 11 +++++++++-- tests/test_api.py | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/scripts/seed_names.py b/scripts/seed_names.py index ad35769..ba2e38e 100644 --- a/scripts/seed_names.py +++ b/scripts/seed_names.py @@ -1,12 +1,7 @@ """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-)" \\ + STORK_URL=http://10.0.10.45:8094 STORK_INVITE=... \\ .venv/bin/python scripts/seed_names.py """ @@ -31,9 +26,11 @@ def main() -> int: 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() + headers = { + "X-Stork-Invite": invite, + "X-Stork-Display-Name": display, + } + with httpx.Client(base_url=base, timeout=30.0, headers=headers) as client: for item in names: res = client.post("/api/names", json=item) if res.status_code == 400 and "already exists" in res.text: diff --git a/stork/app.py b/stork/app.py index 954d372..4db267d 100644 --- a/stork/app.py +++ b/stork/app.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import os import secrets from pathlib import Path @@ -67,12 +68,18 @@ def _session( stork_voter: str | None = Cookie(default=None), stork_name: str | None = Cookie(default=None), x_stork_invite: str | None = Header(default=None), + x_stork_display_name: str | None = Header(default=None), + x_stork_voter: 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() + voter = (x_stork_voter or stork_voter or "").strip() + name = (x_stork_display_name or stork_name or "").strip() + if name and not voter: + # API/script clients without cookies (e.g. seed over HTTP while Secure cookies are on). + digest = hashlib.sha256(name.lower().encode()).hexdigest()[:24] + voter = f"hdr-{digest}" 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} diff --git a/tests/test_api.py b/tests/test_api.py index dbe6c79..32e44a0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -103,3 +103,19 @@ def test_second_voter_ranking(client: TestClient, authed: TestClient) -> None: listed = client.get("/api/names?kind=first").json()["items"] assert listed[0]["score"] == 2 assert listed[0]["vote_count"] == 2 + + +def test_header_auth_without_cookies(client: TestClient) -> None: + headers = { + "X-Stork-Invite": "test-invite-token", + "X-Stork-Display-Name": "Seed Bot", + } + res = client.post( + "/api/names", + headers=headers, + json={"kind": "first", "spelling": "HeaderOnly"}, + ) + assert res.status_code == 200 + listed = client.get("/api/names?kind=first", headers=headers) + assert listed.status_code == 200 + assert any(n["spelling"] == "HeaderOnly" for n in listed.json()["items"])