diff --git a/.gitignore b/.gitignore index f4bdeeb..f95abd7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.pyc .venv/ +.venvsource/ *.egg-info/ dist/ .pytest_cache/ diff --git a/README.md b/README.md index f5f35c7..7c0cfd1 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ Balanced team formation for hackathons, workshops, and study groups. Organizers ## What it does - **Nostr login** — NIP-07 extension, generated keypair, or existing `nsec`. -- **Events + registration** — create an event, share a QR/link, attendees register with no login. +- **Events + registration** — create an event, share a QR/link, attendees register with no login. Each attendee may add an optional **Lightning address**, auto-filled from their Nostr profile (`lud16`) when available. - **Taxonomy** — each attendee picks a Primary Strength (Technical, Design, Planning, Coordination, Communication, Research, Domain Expert, or Other free-text) and Experience level (Beginner/Intermediate/Advanced). - **Allocation** — one click distributes role diversity and experience evenly across teams. The engine is fully deterministic; Claude (Haiku) only normalizes free-text "Other" strengths when `ANTHROPIC_API_KEY` is set (deterministic fallback otherwise). - **Results** — public `/results/` link, "find my team" lookup, CSV/PDF export. +- **Lightning prize payouts** — an organizer marks the winning team, enters a prize in sats, and connects a wallet via **Nostr Wallet Connect (NIP-47)**. SquadSync splits the pot evenly and pays each winner's Lightning address (resolved via LNURL-pay). Members without an address are flagged before anything is sent, so a partial team is never charged; the public results page shows a redacted "prize paid" summary. The NWC credential is used per request and never stored. - **Feedback** — Settings feedback box; stored in the DB and optionally DM'd to the owner over Nostr (NIP-04) when a bot key is configured. ## Tech stack @@ -19,6 +20,7 @@ Balanced team formation for hackathons, workshops, and study groups. Organizers | Backend | FastAPI, SQLAlchemy 2, Alembic, Pydantic v2 | | Database | PostgreSQL (prod), SQLite (tests) | | Auth / Nostr | NIP-98 auth, NIP-04 DMs, Schnorr (`coincurve`) | +| Bitcoin / Lightning | LNURL-pay, Nostr Wallet Connect (NIP-47) prize payouts | | AI | Anthropic Claude Haiku (optional, strength normalization only) | **Deploy:** single `main` branch → backend + Postgres on Render (`render.yaml`), frontend on Vercel (`frontend/`). Full guide in `DEPLOYMENT.md`. diff --git a/backend/alembic/versions/0007_lightning_payout.py b/backend/alembic/versions/0007_lightning_payout.py new file mode 100644 index 0000000..233015a --- /dev/null +++ b/backend/alembic/versions/0007_lightning_payout.py @@ -0,0 +1,64 @@ +"""lightning payout + +Revision ID: 0007_lightning_payout +Revises: 0006_npub_and_team_notifications +Create Date: 2026-06-17 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0007_lightning_payout" +down_revision: Union[str, None] = "0006_npub_and_team_notifications" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("participants") as b: + b.add_column(sa.Column("lightning_address", sa.String(), nullable=True)) + + op.create_table( + "payouts", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("event_id", sa.Uuid(), nullable=False), + sa.Column("allocation_id", sa.Uuid(), nullable=False), + sa.Column("team_label", sa.String(), nullable=False), + sa.Column("total_sats", sa.Integer(), nullable=False), + sa.Column("status", sa.String(), nullable=False, server_default="pending"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["event_id"], ["events.id"]), + sa.ForeignKeyConstraint(["allocation_id"], ["allocations.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_payouts_event_id", "payouts", ["event_id"]) + op.create_index("ix_payouts_allocation_id", "payouts", ["allocation_id"]) + + op.create_table( + "payout_items", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("payout_id", sa.Uuid(), nullable=False), + sa.Column("participant_id", sa.Uuid(), nullable=False), + sa.Column("lightning_address", sa.String(), nullable=True), + sa.Column("amount_sats", sa.Integer(), nullable=False), + sa.Column("status", sa.String(), nullable=False, server_default="pending"), + sa.Column("bolt11", sa.String(), nullable=True), + sa.Column("preimage", sa.String(), nullable=True), + sa.Column("error", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["payout_id"], ["payouts.id"]), + sa.ForeignKeyConstraint(["participant_id"], ["participants.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_payout_items_payout_id", "payout_items", ["payout_id"]) + + +def downgrade() -> None: + op.drop_index("ix_payout_items_payout_id", table_name="payout_items") + op.drop_table("payout_items") + op.drop_index("ix_payouts_allocation_id", table_name="payouts") + op.drop_index("ix_payouts_event_id", table_name="payouts") + op.drop_table("payouts") + with op.batch_alter_table("participants") as b: + b.drop_column("lightning_address") diff --git a/backend/app/api/v1/payouts.py b/backend/app/api/v1/payouts.py new file mode 100644 index 0000000..d457b49 --- /dev/null +++ b/backend/app/api/v1/payouts.py @@ -0,0 +1,66 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.allocation import Allocation +from app.models.payout import Payout, PayoutItem +from app.models.team import Team +from app.models.user import User +from app.schemas.payout import PayoutCreate, PayoutRetry, PayoutOut +from app.services.event_service import assert_allocation_organizer +from app.services import payout_service + +router = APIRouter() + + +def _payout_out(db: Session, payout: Payout) -> PayoutOut: + items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all() + return PayoutOut( + id=payout.id, event_id=payout.event_id, allocation_id=payout.allocation_id, + team_label=payout.team_label, total_sats=payout.total_sats, status=payout.status, + items=items, + ) + + +@router.post("/{allocation_id}/payouts", response_model=PayoutOut, + status_code=status.HTTP_201_CREATED) +def create_payout( + allocation_id: UUID, + req: PayoutCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + allocation: Allocation = assert_allocation_organizer(db, allocation_id, current_user.id) + team = db.query(Team).filter(Team.id == req.team_id, Team.allocation_id == allocation_id).first() + if not team: + raise HTTPException(status_code=404, detail="Team not found in this allocation") + + # Pre-flight: split + verify every member has an address BEFORE spending anything. + try: + splits = payout_service.preflight(db, team.id, req.total_sats, req.addresses) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + payout = Payout(event_id=allocation.event_id, allocation_id=allocation_id, + team_label=team.name, total_sats=req.total_sats, status="pending") + db.add(payout) + db.flush() + payout = payout_service.execute_payout(db, payout, splits, req.nwc) + return _payout_out(db, payout) + + +@router.post("/payouts/{payout_id}/retry", response_model=PayoutOut) +def retry_payout( + payout_id: UUID, + req: PayoutRetry, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + payout = db.query(Payout).filter(Payout.id == payout_id).first() + if not payout: + raise HTTPException(status_code=404, detail="Payout not found") + assert_allocation_organizer(db, payout.allocation_id, current_user.id) + payout = payout_service.retry_failed(db, payout, req.nwc) + return _payout_out(db, payout) diff --git a/backend/app/api/v1/public.py b/backend/app/api/v1/public.py index 53f46fa..2aa80c6 100644 --- a/backend/app/api/v1/public.py +++ b/backend/app/api/v1/public.py @@ -8,7 +8,8 @@ from app.models.allocation import Allocation from app.models.participant import Participant from app.models.team import Team, TeamMember -from app.schemas.allocation import FindTeamRequest, PublicAllocationOut, PublicTeam, PublicTeamMember +from app.models.payout import Payout, PayoutItem +from app.schemas.allocation import FindTeamRequest, PublicAllocationOut, PublicPayoutSummary, PublicTeam, PublicTeamMember router = APIRouter() @@ -50,7 +51,14 @@ def public_allocation(allocation_id: UUID, db: Session = Depends(get_db)): fairness_score=team.fairness_score, members=[PublicTeamMember.model_validate(m) for m in members], )) - return PublicAllocationOut(id=allocation.id, status=allocation.status, teams=teams) + payouts = [] + for p in db.query(Payout).filter(Payout.allocation_id == allocation.id).all(): + items = db.query(PayoutItem).filter(PayoutItem.payout_id == p.id).all() + payouts.append(PublicPayoutSummary( + team_label=p.team_label, total_sats=p.total_sats, status=p.status, + paid_count=sum(1 for i in items if i.status == "paid"), member_count=len(items), + )) + return PublicAllocationOut(id=allocation.id, status=allocation.status, teams=teams, payouts=payouts) @router.post("/allocations/{allocation_id}/find-team", response_model=PublicTeam) diff --git a/backend/app/main.py b/backend/app/main.py index 0862afc..a1ae08a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings -from app.api.v1 import auth, events, participants, allocation, teams, export, public, feedback +from app.api.v1 import auth, events, participants, allocation, teams, export, public, feedback, payouts import app.models # noqa: F401 @@ -31,6 +31,7 @@ async def lifespan(_: FastAPI): app.include_router(export.router, prefix="/api/v1/allocations", tags=["export"]) app.include_router(public.router, prefix="/api/v1/public", tags=["public"]) app.include_router(feedback.router, prefix="/api/v1/feedback", tags=["feedback"]) +app.include_router(payouts.router, prefix="/api/v1/allocations", tags=["payouts"]) @app.get("/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c8db741..cfcb868 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,9 +6,10 @@ from app.models.used_event import UsedAuthEvent from app.models.feedback import Feedback from app.models.team_notification import TeamNotification +from app.models.payout import Payout, PayoutItem __all__ = [ "User", "Event", "EventCoOrganizer", "Participant", "AllocationConfig", "Allocation", "Team", "TeamMember", - "UsedAuthEvent", "Feedback", "TeamNotification", + "UsedAuthEvent", "Feedback", "TeamNotification", "Payout", "PayoutItem", ] diff --git a/backend/app/models/participant.py b/backend/app/models/participant.py index bc4e2f2..09a0943 100644 --- a/backend/app/models/participant.py +++ b/backend/app/models/participant.py @@ -32,6 +32,7 @@ class Participant(Base): normalized_strength = Column(String, nullable=True) strength_source = Column(String, nullable=False, default="preset") npub = Column(String, nullable=True) + lightning_address = Column(String, nullable=True) tech_stack = Column(JSON, nullable=False, default=list) interests = Column(JSON, nullable=False, default=list) composite_score = Column(Float, nullable=True) diff --git a/backend/app/models/payout.py b/backend/app/models/payout.py new file mode 100644 index 0000000..b74fd86 --- /dev/null +++ b/backend/app/models/payout.py @@ -0,0 +1,34 @@ +import uuid +from sqlalchemy import Column, String, Integer, ForeignKey, DateTime, Uuid +from sqlalchemy.sql import func + +from app.core.database import Base + + +class Payout(Base): + __tablename__ = "payouts" + + id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + event_id = Column(Uuid(as_uuid=True), ForeignKey("events.id"), nullable=False, index=True) + allocation_id = Column(Uuid(as_uuid=True), ForeignKey("allocations.id"), nullable=False, index=True) + team_label = Column(String, nullable=False) + total_sats = Column(Integer, nullable=False) + # pending | partial | complete | failed + status = Column(String, nullable=False, default="pending") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class PayoutItem(Base): + __tablename__ = "payout_items" + + id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + payout_id = Column(Uuid(as_uuid=True), ForeignKey("payouts.id"), nullable=False, index=True) + participant_id = Column(Uuid(as_uuid=True), ForeignKey("participants.id"), nullable=False) + lightning_address = Column(String, nullable=True) + amount_sats = Column(Integer, nullable=False) + # pending | paid | failed + status = Column(String, nullable=False, default="pending") + bolt11 = Column(String, nullable=True) + preimage = Column(String, nullable=True) + error = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/schemas/allocation.py b/backend/app/schemas/allocation.py index 31e34f0..06b97ca 100644 --- a/backend/app/schemas/allocation.py +++ b/backend/app/schemas/allocation.py @@ -73,10 +73,19 @@ class PublicTeam(BaseModel): members: list[PublicTeamMember] = [] +class PublicPayoutSummary(BaseModel): + team_label: str + total_sats: int + status: str + paid_count: int + member_count: int + + class PublicAllocationOut(BaseModel): id: UUID status: str teams: list[PublicTeam] = [] + payouts: list[PublicPayoutSummary] = [] class FindTeamRequest(BaseModel): diff --git a/backend/app/schemas/participant.py b/backend/app/schemas/participant.py index f3d07b4..23ada16 100644 --- a/backend/app/schemas/participant.py +++ b/backend/app/schemas/participant.py @@ -18,9 +18,22 @@ class ParticipantRegister(BaseModel): strength_other: Optional[str] = Field(default=None, max_length=120) experience_level: ExperienceLevel npub: Optional[str] = None + lightning_address: Optional[str] = Field(default=None, max_length=255) tech_stack: list[str] = [] interests: list[str] = [] + @field_validator("lightning_address", mode="before") + @classmethod + def _normalize_lightning_address(cls, v): + if v is None: + return None + v = str(v).strip().lower() + if not v: + return None + if v.count("@") != 1 or not all(v.split("@")): + raise ValueError("Lightning address must look like name@domain") + return v + @model_validator(mode="after") def _require_other_text(self): if self.primary_strength == "other" and not (self.strength_other and self.strength_other.strip()): @@ -50,6 +63,7 @@ class ParticipantOut(BaseModel): strength_source: str experience_level: str npub: Optional[str] + lightning_address: Optional[str] composite_score: Optional[float] model_config = {"from_attributes": True} diff --git a/backend/app/schemas/payout.py b/backend/app/schemas/payout.py new file mode 100644 index 0000000..0809435 --- /dev/null +++ b/backend/app/schemas/payout.py @@ -0,0 +1,40 @@ +from typing import Optional +from uuid import UUID +from pydantic import BaseModel, Field + + +class PayoutCreate(BaseModel): + team_id: UUID + total_sats: int = Field(gt=0) + nwc: str = Field(min_length=1) + # Optional per-member address overrides: {str(participant_id): "name@domain"}. + # Lets the organizer fill/correct a missing address in the payout modal. + addresses: Optional[dict[str, str]] = None + + +class PayoutRetry(BaseModel): + nwc: str = Field(min_length=1) + + +class PayoutItemOut(BaseModel): + id: UUID + participant_id: UUID + lightning_address: Optional[str] + amount_sats: int + status: str + preimage: Optional[str] + error: Optional[str] + + model_config = {"from_attributes": True} + + +class PayoutOut(BaseModel): + id: UUID + event_id: UUID + allocation_id: UUID + team_label: str + total_sats: int + status: str + items: list[PayoutItemOut] + + model_config = {"from_attributes": True} diff --git a/backend/app/services/lnurl_service.py b/backend/app/services/lnurl_service.py new file mode 100644 index 0000000..e82fd91 --- /dev/null +++ b/backend/app/services/lnurl_service.py @@ -0,0 +1,60 @@ +"""LNURL-pay client: resolve a `name@domain` Lightning address to a bolt11 invoice. + +Two steps per LNURL spec (LUD-06/LUD-16): + 1. GET https://{domain}/.well-known/lnurlp/{name} -> {callback, minSendable, maxSendable, ...} + 2. GET {callback}?amount={msat} -> {pr: } + +All errors raise LnurlError; the caller marks that payout item failed and continues. +""" +import httpx + +_TIMEOUT = 10.0 + + +class LnurlError(Exception): + """Any failure resolving an address or fetching an invoice.""" + + +def lud16_to_url(address: str) -> str: + address = address.strip().lower() + if address.count("@") != 1: + raise LnurlError(f"malformed lightning address: {address!r}") + name, domain = address.split("@") + if not name or not domain: + raise LnurlError(f"malformed lightning address: {address!r}") + return f"https://{domain}/.well-known/lnurlp/{name}" + + +def resolve_lnurl(address: str) -> dict: + """Return the LNURL-pay params dict for a `name@domain` address.""" + url = lud16_to_url(address) + try: + resp = httpx.get(url, timeout=_TIMEOUT, follow_redirects=True) + resp.raise_for_status() + data = resp.json() + except Exception as exc: # noqa: BLE001 — normalize to LnurlError + raise LnurlError(f"failed to resolve {address}: {exc}") from exc + if "callback" not in data or "minSendable" not in data or "maxSendable" not in data: + raise LnurlError(f"invalid LNURL-pay response for {address}") + return data + + +def request_invoice(params: dict, amount_sats: int) -> str: + """Request a bolt11 invoice for `amount_sats` from a resolved LNURL params dict.""" + amount_msat = amount_sats * 1000 + if amount_msat < params["minSendable"] or amount_msat > params["maxSendable"]: + raise LnurlError( + f"amount {amount_sats} sat outside payable range " + f"[{params['minSendable'] // 1000}, {params['maxSendable'] // 1000}] sat" + ) + try: + resp = httpx.get(params["callback"], params={"amount": amount_msat}, + timeout=_TIMEOUT, follow_redirects=True) + resp.raise_for_status() + data = resp.json() + except Exception as exc: # noqa: BLE001 + raise LnurlError(f"invoice request failed: {exc}") from exc + pr = data.get("pr") + if not pr: + raise LnurlError("LNURL callback returned no invoice") + return pr diff --git a/backend/app/services/nwc_service.py b/backend/app/services/nwc_service.py new file mode 100644 index 0000000..e7055b3 --- /dev/null +++ b/backend/app/services/nwc_service.py @@ -0,0 +1,132 @@ +"""Nostr Wallet Connect (NIP-47) client — pay_invoice only. + +Reuses nostr_service's NIP-04 encryption. The NWC URI carries the client's +secret key and the wallet service pubkey + relay: + nostr+walletconnect://?relay=&secret= + +Flow over one short-lived websocket: subscribe (REQ) for the wallet's kind-23195 +response tagged with our request id, publish the kind-23194 request, read frames +until the response arrives or we time out, decrypt it, return the preimage. + +The NWC secret is a spend credential: it is used transiently and never persisted. +""" +import hashlib +import json +import logging +import time +from dataclasses import dataclass +from urllib.parse import urlparse, parse_qs + +from coincurve import PrivateKey + +from app.services.nostr_service import encrypt_nip04, decrypt_nip04 + +logger = logging.getLogger(__name__) + +_REQUEST_KIND = 23194 +_RESPONSE_KIND = 23195 +_TIMEOUT = 30.0 + + +class NwcError(Exception): + """Any NWC failure: bad URI, wallet error response, or relay timeout.""" + + +@dataclass +class NwcConnection: + wallet_pubkey_hex: str # x-only hex + relay: str + secret_bytes: bytes + + +def parse_nwc_uri(uri: str) -> NwcConnection: + uri = uri.strip() + if not uri.startswith("nostr+walletconnect://"): + raise NwcError("not a nostr+walletconnect URI") + parsed = urlparse(uri) + wallet_pubkey = parsed.netloc or parsed.path.lstrip("/") + qs = parse_qs(parsed.query) + relay = (qs.get("relay") or [None])[0] + secret_hex = (qs.get("secret") or [None])[0] + if not wallet_pubkey or not relay or not secret_hex: + raise NwcError("NWC URI missing wallet pubkey, relay, or secret") + try: + secret_bytes = bytes.fromhex(secret_hex) + except ValueError as exc: + raise NwcError("NWC secret is not valid hex") from exc + return NwcConnection(wallet_pubkey_hex=wallet_pubkey.lower(), relay=relay, secret_bytes=secret_bytes) + + +def build_pay_invoice_request(secret_bytes: bytes, wallet_xonly: bytes, bolt11: str) -> dict: + """Build a signed, NIP-04-encrypted kind-23194 pay_invoice request event.""" + privkey = PrivateKey(secret_bytes) + pubkey_hex = privkey.public_key_xonly.format().hex() + created_at = int(time.time()) + body = json.dumps({"method": "pay_invoice", "params": {"invoice": bolt11}}, + separators=(",", ":")) + content = encrypt_nip04(secret_bytes, wallet_xonly, body) + tags = [["p", wallet_xonly.hex()]] + serialized = json.dumps( + [0, pubkey_hex, created_at, _REQUEST_KIND, tags, content], + separators=(",", ":"), ensure_ascii=False, + ) + event_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest() + sig = privkey.sign_schnorr(bytes.fromhex(event_id)).hex() + return {"id": event_id, "pubkey": pubkey_hex, "created_at": created_at, + "kind": _REQUEST_KIND, "tags": tags, "content": content, "sig": sig} + + +def decode_response(secret_bytes: bytes, wallet_xonly: bytes, content: str) -> dict: + """Decrypt a kind-23195 response. Return {'preimage': ...} or raise NwcError.""" + plaintext = decrypt_nip04(secret_bytes, wallet_xonly, content) + data = json.loads(plaintext) + err = data.get("error") + if err: + # NIP-47 models error as {code, message}, but some wallets return a bare string. + msg = err.get("message") if isinstance(err, dict) else str(err) + raise NwcError(msg or "wallet returned an error") + result = data.get("result") or {} + preimage = result.get("preimage") + if not preimage: + raise NwcError("wallet response had no preimage") + return {"preimage": preimage} + + +def _round_trip(conn: NwcConnection, request_event: dict) -> str: + """Publish the request and read the wallet's response content. Monkeypatched in tests. + + Subscribe BEFORE publishing so we cannot miss a fast response. Returns the + raw (still-encrypted) response event content; raises NwcError on timeout. + """ + from websockets.sync.client import connect + + sub_id = request_event["id"][:16] + req = json.dumps(["REQ", sub_id, { + "kinds": [_RESPONSE_KIND], + "authors": [conn.wallet_pubkey_hex], + "#e": [request_event["id"]], + }]) + event_msg = json.dumps(["EVENT", request_event]) + deadline = time.time() + _TIMEOUT + try: + with connect(conn.relay, open_timeout=10, close_timeout=5) as ws: + ws.send(req) + ws.send(event_msg) + while time.time() < deadline: + frame = json.loads(ws.recv(timeout=max(1.0, deadline - time.time()))) + if frame[0] == "EVENT" and frame[1] == sub_id: + return frame[2]["content"] + except NwcError: + raise + except Exception as exc: # noqa: BLE001 + raise NwcError(f"NWC relay round-trip failed: {exc}") from exc + raise NwcError("timed out waiting for wallet response") + + +def pay_invoice(uri: str, bolt11: str) -> str: + """Pay `bolt11` via the wallet in `uri`. Return the payment preimage or raise NwcError.""" + conn = parse_nwc_uri(uri) + wallet_xonly = bytes.fromhex(conn.wallet_pubkey_hex) + request_event = build_pay_invoice_request(conn.secret_bytes, wallet_xonly, bolt11) + content = _round_trip(conn, request_event) + return decode_response(conn.secret_bytes, wallet_xonly, content)["preimage"] diff --git a/backend/app/services/payout_service.py b/backend/app/services/payout_service.py new file mode 100644 index 0000000..8f639a6 --- /dev/null +++ b/backend/app/services/payout_service.py @@ -0,0 +1,120 @@ +"""Lightning payout: deterministic split + orchestration. + +`compute_split` is pure and reproducible: an integer even split with the +remainder assigned to the earliest members (by the order they are passed in). +""" +import logging +from typing import Sequence, TypeVar +from uuid import UUID + +from sqlalchemy.orm import Session + +from app.models.payout import Payout, PayoutItem +from app.models.participant import Participant +from app.models.team import Team, TeamMember +from app.services import lnurl_service, nwc_service + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +def compute_split(recipients: Sequence[T], total_sats: int) -> list[tuple[T, int]]: + """Split `total_sats` evenly across `recipients`, remainder to the first members. + + Raises ValueError if there are no recipients or fewer sats than recipients + (every member must receive at least 1 sat). + """ + n = len(recipients) + if n == 0: + raise ValueError("no recipients") + if total_sats < n: + raise ValueError("total_sats must be at least one sat per recipient") + base, remainder = divmod(total_sats, n) + return [(r, base + (1 if i < remainder else 0)) for i, r in enumerate(recipients)] + + +def _team_members(db: Session, team_id: UUID) -> list[Participant]: + """Members of a team, ordered by participant id for a reproducible split.""" + return ( + db.query(Participant) + .join(TeamMember, Participant.id == TeamMember.participant_id) + .filter(TeamMember.team_id == team_id) + .order_by(Participant.id) + .all() + ) + + +def preflight( + db: Session, team_id: UUID, total_sats: int, overrides: dict[str, str] | None = None, +) -> list[tuple[Participant, str, int]]: + """Resolve each member's address + split. + + `overrides` maps `str(participant_id) -> lightning_address` and lets the organizer + supply/correct addresses in the payout modal. The override wins over the + registration value. Raise ValueError listing any member who still has no address. + Returns (participant, address, amount_sats) tuples. + """ + overrides = overrides or {} + members = _team_members(db, team_id) + resolved = [(m, overrides.get(str(m.id)) or m.lightning_address) for m in members] + missing = [m.name for m, addr in resolved if not addr] + if missing: + raise ValueError(f"missing lightning address for: {', '.join(missing)}") + # compute_split preserves order, so zip the amounts back onto (participant, address). + amounts = compute_split([m for m, _ in resolved], total_sats) + return [(m, addr, amount) for (m, addr), (_, amount) in zip(resolved, amounts)] + + +def execute_payout( + db: Session, payout: Payout, splits: list[tuple[Participant, str, int]], nwc: str, +) -> Payout: + """Pay each member, recording per-item status. Rolls payout.status up at the end.""" + paid = 0 + for participant, address, amount_sats in splits: + item = PayoutItem(payout_id=payout.id, participant_id=participant.id, + lightning_address=address, amount_sats=amount_sats, status="pending") + db.add(item) + db.flush() + try: + params = lnurl_service.resolve_lnurl(address) + bolt11 = lnurl_service.request_invoice(params, amount_sats) + item.bolt11 = bolt11 + item.preimage = nwc_service.pay_invoice(nwc, bolt11) + item.status = "paid" + paid += 1 + except Exception as exc: # noqa: BLE001 — record + continue + item.status = "failed" + item.error = str(exc) + logger.warning("payout item %s failed: %s", item.id, exc) + # Commit per item: a sent payment's preimage must be durable the moment it + # lands, so a later crash/commit failure can never lose a record of real money + # already moved (which would risk a double-pay on re-run). + db.commit() + payout.status = "complete" if paid == len(splits) else ("partial" if paid else "failed") + db.commit() + db.refresh(payout) + return payout + + +def retry_failed(db: Session, payout: Payout, nwc: str) -> Payout: + """Retry only the failed items of an existing payout.""" + items = db.query(PayoutItem).filter(PayoutItem.payout_id == payout.id).all() + for item in items: + if item.status != "failed": + continue + try: + params = lnurl_service.resolve_lnurl(item.lightning_address) + bolt11 = lnurl_service.request_invoice(params, item.amount_sats) + item.bolt11 = bolt11 + item.preimage = nwc_service.pay_invoice(nwc, bolt11) + item.status, item.error = "paid", None + except Exception as exc: # noqa: BLE001 + item.error = str(exc) + logger.warning("payout retry item %s failed: %s", item.id, exc) + db.commit() # durable per item (see execute_payout) + paid = sum(1 for i in items if i.status == "paid") + payout.status = "complete" if paid == len(items) else ("partial" if paid else "failed") + db.commit() + db.refresh(payout) + return payout diff --git a/backend/tests/test_lnurl_service.py b/backend/tests/test_lnurl_service.py new file mode 100644 index 0000000..f777658 --- /dev/null +++ b/backend/tests/test_lnurl_service.py @@ -0,0 +1,45 @@ +import pytest +from app.services import lnurl_service +from app.services.lnurl_service import LnurlError, lud16_to_url + + +def test_lud16_to_url(): + assert lud16_to_url("ada@getalby.com") == "https://getalby.com/.well-known/lnurlp/ada" + + +def test_lud16_to_url_rejects_malformed(): + with pytest.raises(LnurlError): + lud16_to_url("not-an-address") + + +def test_request_invoice_amount_below_min_raises(monkeypatch): + params = {"callback": "https://getalby.com/lnurlp/ada/callback", + "minSendable": 100_000, "maxSendable": 1_000_000} # 100..1000 sat + + def fake_get(url, **kwargs): + raise AssertionError("callback should not be hit when amount is out of bounds") + + monkeypatch.setattr(lnurl_service.httpx, "get", fake_get) + with pytest.raises(LnurlError): + lnurl_service.request_invoice(params, amount_sats=50) # 50 sat < 100 sat min + + +def test_request_invoice_returns_bolt11(monkeypatch): + params = {"callback": "https://getalby.com/lnurlp/ada/callback", + "minSendable": 1_000, "maxSendable": 1_000_000} + + class FakeResp: + def raise_for_status(self): pass + def json(self): return {"pr": "lnbc100n1fakeinvoice"} + + captured = {} + + def fake_get(url, params=None, **kwargs): + captured["url"] = url + captured["params"] = params + return FakeResp() + + monkeypatch.setattr(lnurl_service.httpx, "get", fake_get) + bolt11 = lnurl_service.request_invoice(params, amount_sats=100) + assert bolt11 == "lnbc100n1fakeinvoice" + assert captured["params"]["amount"] == 100_000 # msat diff --git a/backend/tests/test_nwc_service.py b/backend/tests/test_nwc_service.py new file mode 100644 index 0000000..e344eaa --- /dev/null +++ b/backend/tests/test_nwc_service.py @@ -0,0 +1,75 @@ +import json +import pytest +from coincurve import PrivateKey + +from app.services import nwc_service +from app.services.nwc_service import NwcError, parse_nwc_uri, build_pay_invoice_request, decode_response +from app.services.nostr_service import encrypt_nip04 + + +def _make_uri(secret_hex: str, wallet_pub_hex: str, relay="wss://relay.example") -> str: + return f"nostr+walletconnect://{wallet_pub_hex}?relay={relay}&secret={secret_hex}" + + +def test_parse_nwc_uri(): + secret = PrivateKey() + wallet = PrivateKey() + wallet_xonly = wallet.public_key_xonly.format().hex() + uri = _make_uri(secret.to_hex(), wallet_xonly) + parsed = parse_nwc_uri(uri) + assert parsed.wallet_pubkey_hex == wallet_xonly + assert parsed.relay == "wss://relay.example" + assert parsed.secret_bytes == secret.secret + + +def test_parse_nwc_uri_rejects_garbage(): + with pytest.raises(NwcError): + parse_nwc_uri("https://not-nwc") + + +def test_build_pay_invoice_request_is_signed_and_encrypted(): + secret = PrivateKey() + wallet = PrivateKey() + wallet_xonly = wallet.public_key_xonly.format().hex() + event = build_pay_invoice_request(secret.secret, bytes.fromhex(wallet_xonly), "lnbc1fake") + assert event["kind"] == 23194 + assert ["p", wallet_xonly] in event["tags"] + # wallet decrypts the request with its privkey + our x-only pubkey + our_xonly = secret.public_key_xonly.format() + from app.services.nostr_service import decrypt_nip04 + body = json.loads(decrypt_nip04(wallet.secret, our_xonly, event["content"])) + assert body["method"] == "pay_invoice" + assert body["params"]["invoice"] == "lnbc1fake" + + +def test_decode_response_success(): + secret = PrivateKey() + wallet = PrivateKey() + our_xonly = secret.public_key_xonly.format() + payload = json.dumps({"result_type": "pay_invoice", "result": {"preimage": "deadbeef"}}) + content = encrypt_nip04(wallet.secret, our_xonly, payload) + result = decode_response(secret.secret, wallet.public_key_xonly.format(), content) + assert result == {"preimage": "deadbeef"} + + +def test_decode_response_error_raises(): + secret = PrivateKey() + wallet = PrivateKey() + our_xonly = secret.public_key_xonly.format() + payload = json.dumps({"error": {"code": "INSUFFICIENT_BALANCE", "message": "no funds"}}) + content = encrypt_nip04(wallet.secret, our_xonly, payload) + with pytest.raises(NwcError) as exc: + decode_response(secret.secret, wallet.public_key_xonly.format(), content) + assert "no funds" in str(exc.value) + + +def test_decode_response_string_error_raises(): + # Some wallets return `error` as a bare string instead of {code, message}. + secret = PrivateKey() + wallet = PrivateKey() + our_xonly = secret.public_key_xonly.format() + payload = json.dumps({"error": "payment failed"}) + content = encrypt_nip04(wallet.secret, our_xonly, payload) + with pytest.raises(NwcError) as exc: + decode_response(secret.secret, wallet.public_key_xonly.format(), content) + assert "payment failed" in str(exc.value) diff --git a/backend/tests/test_payout_endpoint.py b/backend/tests/test_payout_endpoint.py new file mode 100644 index 0000000..9ad5c03 --- /dev/null +++ b/backend/tests/test_payout_endpoint.py @@ -0,0 +1,114 @@ +def _setup_team(client, auth_headers, all_have_addresses): + """Register 4 participants, allocate into 2 teams, return the first team. + + Returns (event_id, allocation_id, team_id, members) where `members` is the + list of member dicts (each has 'id' and 'name') on teams[0]. + """ + e = client.post("/api/v1/events", headers=auth_headers, + json={"title": "BTC++ Payout", "team_count": 2}).json() + client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"}) + strengths = ["technical", "design", "planning", "coordination"] + for i in range(4): + body = {"name": f"P{i}", "email": f"p{i}@t.com", + "primary_strength": strengths[i], "experience_level": "intermediate"} + if all_have_addresses: + body["lightning_address"] = f"p{i}@getalby.com" + r = client.post(f"/api/v1/events/{e['registration_slug']}/register", json=body) + assert r.status_code in (200, 201), r.text + a = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers).json() + teams = client.get(f"/api/v1/allocations/{a['id']}/teams", headers=auth_headers).json() + assert teams and teams[0]["members"], "expected a non-empty first team" + return e["id"], a["id"], teams[0]["id"], teams[0]["members"] + + +def _stub_lightning(monkeypatch): + """Stub LNURL + NWC so no real network happens. Returns the list of paid bolt11s.""" + from app.services import lnurl_service, nwc_service + monkeypatch.setattr(lnurl_service, "resolve_lnurl", + lambda addr: {"callback": "https://x/cb", "minSendable": 1000, "maxSendable": 10_000_000}) + monkeypatch.setattr(lnurl_service, "request_invoice", lambda params, amount_sats: f"lnbc{amount_sats}fake") + paid = [] + monkeypatch.setattr(nwc_service, "pay_invoice", + lambda uri, bolt11: (paid.append(bolt11), "preimage_" + bolt11)[1]) + return paid + + +def test_payout_pays_team_and_records_results(client, auth_headers, monkeypatch): + _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True) + paid = _stub_lightning(monkeypatch) + + res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={ + "team_id": str(team_id), "total_sats": 210, + "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", + }) + + assert res.status_code == 201, res.text + body = res.json() + assert body["status"] == "complete" + assert len(body["items"]) == len(members) + assert sum(i["amount_sats"] for i in body["items"]) == 210 # full pot paid, no sats lost + assert all(i["status"] == "paid" and i["preimage"] for i in body["items"]) + assert len(paid) == len(members) + + +def test_payout_422_when_member_missing_address(client, auth_headers): + # No participant has an address, so any team triggers the pre-flight 422. + _, allocation_id, team_id, _ = _setup_team(client, auth_headers, all_have_addresses=False) + res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={ + "team_id": str(team_id), "total_sats": 210, + "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", + }) + assert res.status_code == 422 + assert "missing" in res.text.lower() + + +def test_payout_address_override_fills_missing(client, auth_headers, monkeypatch): + _, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=False) + _stub_lightning(monkeypatch) + # Supply an address for every member of teams[0] via the override map. + overrides = {m["id"]: f"{m['name']}@getalby.com" for m in members} + + res = client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={ + "team_id": str(team_id), "total_sats": 210, + "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", + "addresses": overrides, + }) + assert res.status_code == 201, res.text + assert res.json()["status"] == "complete" + + +def test_public_results_include_payout_summary(client, auth_headers, monkeypatch): + event_id, allocation_id, team_id, members = _setup_team(client, auth_headers, all_have_addresses=True) + _stub_lightning(monkeypatch) + client.post(f"/api/v1/allocations/{allocation_id}/payouts", headers=auth_headers, json={ + "team_id": str(team_id), "total_sats": 210, + "nwc": "nostr+walletconnect://abc?relay=wss://r&secret=00", + }) + # Public results are published-only. + client.post(f"/api/v1/events/{event_id}/allocations/{allocation_id}/publish", headers=auth_headers) + + res = client.get(f"/api/v1/public/allocations/{allocation_id}") + assert res.status_code == 200, res.text + summary = res.json()["payouts"] + assert summary[0]["team_label"] + assert summary[0]["total_sats"] == 210 + assert summary[0]["paid_count"] == len(members) + assert summary[0]["member_count"] == len(members) + # never leak the credential or invoice/preimage secrets + for leaked in ("nwc", "preimage", "bolt11", "lightning_address"): + assert leaked not in res.text.lower() + + +def test_payout_models_importable_and_persist(db): + import uuid + from app.models.payout import Payout, PayoutItem + + payout = Payout(event_id=uuid.uuid4(), allocation_id=uuid.uuid4(), + team_label="Team Satoshi", total_sats=210, status="pending") + db.add(payout) + db.flush() + item = PayoutItem(payout_id=payout.id, participant_id=uuid.uuid4(), + lightning_address="ada@getalby.com", amount_sats=105, status="pending") + db.add(item) + db.commit() + assert payout.id is not None and item.payout_id == payout.id diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index 9bfacdc..0130d4b 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -147,3 +147,16 @@ def test_register_omitted_npub_stored_none(client, auth_headers): res = _register(client, e["registration_slug"], email="omit@t.com") assert res.status_code == 201 assert res.json()["npub"] is None + + +def test_register_accepts_lightning_address(client, auth_headers): + e = client.post("/api/v1/events", headers=auth_headers, + json={"title": "BTC++ Demo", "team_count": 2}).json() + client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"}) + res = client.post(f"/api/v1/events/{e['registration_slug']}/register", json={ + "name": "Ada", "email": "ada@example.com", + "primary_strength": "technical", "experience_level": "advanced", + "lightning_address": "ada@getalby.com", + }) + assert res.status_code in (200, 201) + assert res.json()["lightning_address"] == "ada@getalby.com" diff --git a/backend/tests/test_split.py b/backend/tests/test_split.py new file mode 100644 index 0000000..b78f6c2 --- /dev/null +++ b/backend/tests/test_split.py @@ -0,0 +1,27 @@ +import pytest +from app.services.payout_service import compute_split + + +def test_even_split_no_remainder(): + # 300 sats over 3 members -> 100 each, order preserved + assert compute_split(["a", "b", "c"], 300) == [("a", 100), ("b", 100), ("c", 100)] + + +def test_remainder_goes_to_earliest_members(): + # 100 sats over 3 -> 34, 33, 33 (remainder 1 to the first member) + assert compute_split(["a", "b", "c"], 100) == [("a", 34), ("b", 33), ("c", 33)] + + +def test_single_member_gets_everything(): + assert compute_split(["a"], 210) == [("a", 210)] + + +def test_zero_members_raises(): + with pytest.raises(ValueError): + compute_split([], 100) + + +def test_total_below_member_count_raises(): + # cannot give every member at least 1 sat + with pytest.raises(ValueError): + compute_split(["a", "b", "c"], 2) diff --git a/docs/audits/2026-06-13-squadsync-launch-readiness-audit.md b/docs/audits/2026-06-13-squadsync-launch-readiness-audit.md deleted file mode 100644 index 1a50fd4..0000000 --- a/docs/audits/2026-06-13-squadsync-launch-readiness-audit.md +++ /dev/null @@ -1,467 +0,0 @@ -# SquadSync Production Audit — 2026-06-13 - -**Prepared by:** Claude Code audit pass -**Date:** 2026-06-13 -**Branch:** `feat/ci-dark-mode-mobile` -**Timeline:** Real users in 1–2 weeks -**Infra baseline:** Zero — Supabase, Render, Vercel not yet provisioned - ---- - -## Table of Contents - -1. [Audit Method](#1-audit-method) -2. [Critical Blockers](#2-critical-blockers) -3. [Security Findings](#3-security-findings) -4. [Deployment Readiness Gaps](#4-deployment-readiness-gaps) -5. [Major Gaps](#5-major-gaps) -6. [Low-Risk Cleanup](#6-low-risk-cleanup) -7. [Verified Non-Issues](#7-verified-non-issues) -8. [Production Implementation Plan](#8-production-implementation-plan) - ---- - -## 1. Audit Method - -Every finding is marked **VERIFIED** (direct file read or command output inspected) or **INFERRED** (conclusion drawn from multiple verified inputs — cross-checked but not directly observed). - -All file references include line numbers as read during this audit. Findings are ordered by deploy-blocking severity. - ---- - -## 2. Critical Blockers - -### CB-01 — Alembic migration schema mismatch - -**Status:** VERIFIED -**Evidence:** `backend/alembic/versions/9898ba081ca1_initial_schema.py` lines 22–34 creates: - -```sql -CREATE TABLE users ( - id UUID PRIMARY KEY, - name VARCHAR NOT NULL, - email VARCHAR UNIQUE NOT NULL, - hashed_password VARCHAR, - provider ENUM('local','google') DEFAULT 'local', - provider_id VARCHAR, - created_at TIMESTAMP -); -``` - -Current model at `backend/app/models/user.py` lines 8–12: - -```python -class User(Base): - __tablename__ = "users" - id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - pubkey = Column(String(64), unique=True, nullable=False, index=True) - created_at = Column(DateTime(timezone=True), server_default=func.now()) -``` - -**Risk:** `alembic upgrade head` on a fresh production Postgres creates the old email/password schema. `POST /auth/nostr` then fails with `column "pubkey" does not exist` — the app is non-functional from first deploy. - -**Reproduction:** Spin up a blank Postgres instance, run `alembic upgrade head`, then `POST /auth/nostr` with a valid Nostr keypair — guaranteed 500. - -**Remediation:** -1. Delete `backend/alembic/versions/9898ba081ca1_initial_schema.py` -2. `alembic revision --autogenerate -m "nostr-user-schema"` -3. Verify generated file: `pubkey VARCHAR(64) NOT NULL UNIQUE` present, no `name/email/password/provider` columns -4. `alembic upgrade head` against SQLite — confirm clean run - ---- - -### CB-02 — Docker build fails on `coincurve` C extension - -**Status:** VERIFIED -**Evidence:** `backend/Dockerfile` lines 1–10: - -```dockerfile -FROM python:3.12-slim -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -COPY . . -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] -``` - -`backend/requirements.txt` line 8: `coincurve==20.0.0` - -`python:3.12-slim` contains no C build toolchain. `coincurve` is a C extension wrapping `libsecp256k1` — it requires `gcc`, `libffi-dev`, `libssl-dev`, and `python3-dev` at build time. No `apt-get install` step exists anywhere in the Dockerfile. - -**Risk:** `docker build` exits with a pip build error. No Render deployment is possible. Nostr signature verification is unavailable without `coincurve`. - -**Reproduction:** `docker build -t squadsync-backend ./backend` — will fail at the pip install step. - -**Remediation:** Add before the `pip install` line: - -```dockerfile -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc build-essential libffi-dev libssl-dev python3-dev \ - && rm -rf /var/lib/apt/lists/* -``` - ---- - -### CB-03 — CORS blocks all production frontend requests - -**Status:** VERIFIED -**Evidence:** `backend/app/core/config.py` line 9: - -```python -FRONTEND_URL: str = "http://localhost:3000" -``` - -`backend/app/main.py` passes this directly to: - -```python -CORSMiddleware(allow_origins=[settings.FRONTEND_URL]) -``` - -**Risk:** Every API call from the Vercel deployment (`https://*.vercel.app`) receives a `403 CORS` response. The app appears to load but all data fetching and auth fail silently in the browser. - -**Reproduction:** Deploy frontend to Vercel with `NEXT_PUBLIC_API_URL` pointing at the Render backend. Open devtools → Network tab → any `fetch()` to the backend → `Access-Control-Allow-Origin` header absent → browser blocks response. - -**Remediation:** Set the `FRONTEND_URL` environment variable in Render to the exact Vercel production URL (e.g., `https://squadsync.vercel.app`). No code change required — `config.py` reads from env. - ---- - -### CB-04 — `(dashboard)` route deletions not committed - -**Status:** VERIFIED -**Evidence:** Session-start git status shows: - -``` -D frontend/app/(dashboard)/events/[eventId]/attendees/page.tsx -D frontend/app/(dashboard)/events/[eventId]/configure/page.tsx -D frontend/app/(dashboard)/events/[eventId]/engine/page.tsx -D frontend/app/(dashboard)/events/[eventId]/page.tsx -D frontend/app/(dashboard)/layout.tsx -D frontend/app/(dashboard)/page.tsx -``` - -The working tree has deleted these files but the deletion is unstaged. Meanwhile, `frontend/app/dashboard/` (no parentheses) is the active route group. Both route groups are live in the git HEAD that CI checks out. - -**Risk:** CI checkout has both `(dashboard)/` and `dashboard/` route groups simultaneously. `npx next build` may fail with duplicate-route conflicts, or pass but deploy with the dead routes included. Either way the branch is not clean for a production cut. - -**Reproduction:** `git stash` (restores deleted files from HEAD) → `npx next build` → observe duplicate-route warnings or errors. - -**Remediation:** -1. `git add frontend/app/\(dashboard\)/` -2. `git commit -m "chore: remove deprecated (dashboard) route group"` -3. Confirm `npx next build` succeeds after commit - ---- - -## 3. Security Findings - -### SF-01 — NIP-98 URL and method tags not validated server-side - -**Status:** VERIFIED -**Evidence:** `backend/app/services/auth_service.py` — `verify_nostr_event()` only: -- Recomputes the event ID hash (SHA-256 of canonical JSON) -- Verifies the Schnorr signature with `coincurve` - -It never inspects `event["tags"]`. NIP-98 requires the server to validate that tags contain `["u", ]` and `["method", "POST"]`. `backend/app/api/v1/auth.py` calls `nostr_login()`, which calls `verify_nostr_event()` without passing the expected URL. - -**Risk:** A valid NIP-98 event signed for a different endpoint (e.g., a third-party service) could be replayed to `/auth/nostr`. The 60-second freshness window (`created_at` check in `nostr_login()`) partially mitigates replay across sessions, but does not prevent within-window cross-endpoint replay. - -**Reproduction:** Obtain a valid kind-27235 event signed for URL `https://other-service.com/auth`, POST it to `/auth/nostr` with a matching pubkey — it succeeds. - -**Remediation:** In `verify_nostr_event()`, add a parameter `expected_url: str` and validate: - -```python -u_tags = [t for t in event.get("tags", []) if t[0] == "u"] -method_tags = [t for t in event.get("tags", []) if t[0] == "method"] -if not u_tags or u_tags[0][1] != expected_url: - return False -if not method_tags or method_tags[0][1] != "POST": - return False -``` - -Pass `expected_url` from `nostr_login()` using the request's actual URL. - ---- - -### SF-02 — Nostr private key stored in plaintext localStorage - -**Status:** VERIFIED -**Evidence:** `frontend/components/auth/nostr-connect.tsx` line 137: - -```typescript -localStorage.setItem(SK_KEY, generated.skHex) -``` - -**Risk:** Any XSS vulnerability in the application exposes the user's Nostr private key directly. Unlike a password (which can be rotated server-side), a Nostr private key IS the identity — a stolen key cannot be invalidated. - -**Severity:** Acceptable for MVP given the scope, but must be documented as a known risk and addressed before significant user growth. Consider encrypting the stored key with a PIN, or prompting users to use a browser extension (nos2x, Alby) which never expose the private key to the page. - ---- - -## 4. Deployment Readiness Gaps - -No production infrastructure has been provisioned. These are not software defects — they are missing environment setup required before any deployment attempt. - -| Gap | Detail | -|-----|--------| -| Supabase PostgreSQL | No project created; no `DATABASE_URL` for production | -| Render Web Service | No service configured; no backend URL exists | -| Vercel Project | No project imported; no frontend URL exists | -| Production secrets | `SECRET_KEY`, `NEXTAUTH_SECRET` not generated or stored | -| CORS wiring | `FRONTEND_URL` env var needs Vercel URL — only available after first Vercel deploy | - -See Phase 3 of the implementation plan for provisioning steps. - ---- - -## 5. Major Gaps - -### MG-01 — CI does not run Vitest - -**Status:** VERIFIED -**Evidence:** `.github/workflows/ci.yml` frontend job ends at: - -```yaml -- name: Type check - run: npx tsc --noEmit - working-directory: frontend -``` - -No `npm test` step. Frontend component tests exist (`frontend/tests/components/minimal-config-test.tsx`) and would catch UI regressions, but they never run in CI. - -**Remediation:** Add after the type check step: - -```yaml -- name: Run tests - run: npm test - working-directory: frontend - env: - NEXT_PUBLIC_API_URL: http://localhost:8000 - NEXTAUTH_SECRET: ci-secret -``` - -### MG-02 — CI has no Docker build verification - -**Status:** INFERRED from Dockerfile contents and CI yaml -**Evidence:** CI yaml has no Docker build step. CB-02 (coincurve build failure) would go undetected in CI and only surface on first Render deploy. - -**Remediation:** Add a third CI job: - -```yaml -docker-build: - name: Docker Build (Backend) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build backend image - run: docker build ./backend -``` - ---- - -## 6. Low-Risk Cleanup - -These files are unused and safe to delete but are not deploy blockers. - -| File | Status | Action | -|------|--------|--------| -| `frontend/components/auth/login-form.tsx` | Not imported anywhere (verified with grep) | `git rm` | -| `frontend/components/auth/register-form.tsx` | Not imported anywhere (verified with grep) | `git rm` | - ---- - -## 7. Verified Non-Issues - -These were initially flagged as potential risks. Direct inspection confirmed they are not blockers. - -| Item | Finding | -|------|---------| -| `backend/tests/test_auth.py` | Fully updated for Nostr — uses `coincurve.PrivateKey`, tests kind/pubkey/expiry/sig. Not a blocker. | -| `backend/tests/conftest.py` | `make_nostr_event()` and `auth_headers` fixture correctly construct NIP-98 events. Not a blocker. | -| `nostr-tools` npm package | Present in `frontend/package.json` as `"nostr-tools": "^2.23.5"`. Not missing. | -| Google OAuth routes | Completely removed from `backend/app/api/v1/auth.py` and `frontend/lib/auth.ts`. No dead code risk. | -| NIP-98 freshness check | `abs(time.time() - event.get("created_at", 0)) > 60` is present and correct in `nostr_login()`. | - ---- - -## 8. Production Implementation Plan - -### Phase 1 — Launch Blockers *(fix before any deploy attempt)* - -**Step 1 — Regenerate Alembic migration (CB-01)** - -```bash -cd backend -rm alembic/versions/9898ba081ca1_initial_schema.py -DATABASE_URL=sqlite:///./test.db alembic revision --autogenerate -m "nostr-user-schema" -# Verify: generated file has pubkey VARCHAR(64) NOT NULL UNIQUE -# Verify: NO name, email, hashed_password, provider columns -alembic upgrade head -``` - -Commit: `fix: regenerate Alembic migration for Nostr user schema` - -**Step 2 — Add NIP-98 URL/method tag validation (SF-01)** - -In `backend/app/services/auth_service.py`, update `verify_nostr_event()` to accept `expected_url: str` and validate `["u", expected_url]` and `["method", "POST"]` tags. Update `nostr_login()` to pass the request URL. - -Run `pytest tests/test_auth.py -v` — all tests must pass. - -Commit: `fix: enforce NIP-98 URL and method tag validation` - -**Step 3 — Fix Dockerfile for coincurve build (CB-02)** - -Add to `backend/Dockerfile` before the `pip install` line: - -```dockerfile -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc build-essential libffi-dev libssl-dev python3-dev \ - && rm -rf /var/lib/apt/lists/* -``` - -Verify locally: `docker build -t squadsync-backend ./backend` must succeed end-to-end. - -Commit: `fix: add C build deps to Dockerfile for coincurve` - -**Step 4 — Commit route deletions (CB-04)** - -```bash -git add "frontend/app/(dashboard)/" -git status # confirm all 6 D entries are staged -git commit -m "chore: remove deprecated (dashboard) route group" -npx next build # run in frontend/ — must complete clean -``` - -**Step 5 — Remove dead auth components** - -```bash -git rm frontend/components/auth/login-form.tsx -git rm frontend/components/auth/register-form.tsx -git commit -m "chore: remove unused email/password auth components" -``` - ---- - -### Phase 2 — CI Hardening *(merge before Phase 3)* - -**Step 6 — Add Vitest to CI frontend job (MG-01)** - -In `.github/workflows/ci.yml`, add after the type check step in the `frontend` job: - -```yaml -- name: Run tests - run: npm test - working-directory: frontend - env: - NEXT_PUBLIC_API_URL: http://localhost:8000 - NEXTAUTH_SECRET: ci-secret -``` - -**Step 7 — Add Docker build CI job (MG-02)** - -Add a third job to `.github/workflows/ci.yml`: - -```yaml -docker-build: - name: Docker Build (Backend) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build backend image - run: docker build ./backend -``` - -**Step 8 — Add migration smoke test to backend CI job** - -In the `backend` job, add after `pytest tests/ -v`: - -```yaml -- name: Verify migration - run: alembic upgrade head - working-directory: backend - env: - DATABASE_URL: sqlite:///./test.db -``` - -Commit Steps 6–8 together: `ci: add vitest, Docker build, and migration verification` - ---- - -### Phase 3 — Infrastructure Provisioning - -**Step 9 — Provision Supabase PostgreSQL** - -1. Create project at supabase.com -2. Copy the **Direct connection** `postgresql://` URI (not the pooler URI — Alembic needs direct connections) -3. Set locally as `DATABASE_URL` in `backend/.env` -4. `cd backend && alembic upgrade head` — verify all tables created, especially `users(id, pubkey, created_at)` - -**Step 10 — Deploy to Render** - -1. Create new Web Service → Docker → connect GitHub repo → Root Dir: `backend/` -2. Set environment variables: - - `DATABASE_URL` → Supabase direct URI - - `SECRET_KEY` → generate: `openssl rand -hex 32` - - `FRONTEND_URL` → placeholder (`http://localhost:3000` until Step 12) -3. Trigger first deploy → verify `/health` returns `{"status":"ok"}` - -**Step 11 — Deploy to Vercel** - -1. Import repo at vercel.com → Framework: Next.js → Root Dir: `frontend/` -2. Set environment variables: - - `NEXT_PUBLIC_API_URL` → Render backend URL from Step 10 - - `NEXTAUTH_SECRET` → generate: `openssl rand -hex 32` - - `NEXTAUTH_URL` → Vercel production URL (available after first deploy) -3. Trigger first deploy → verify login page loads - -**Step 12 — Wire CORS (CB-03)** - -In Render environment variables, update `FRONTEND_URL` to the Vercel production URL (e.g., `https://squadsync.vercel.app`). Trigger Render redeploy. - ---- - -### Phase 4 — Release Validation - -**Step 13 — End-to-end smoke test on live URLs** - -Golden path, in order: -1. Open Vercel URL in a fresh browser (no cached state) -2. Generate new Nostr identity on login page → sign in → confirm redirect to `/dashboard` -3. Create event → verify event slug generated, event appears in list -4. Open QR URL on a mobile device → complete participant registration form -5. Switch to desktop → run team allocation → verify team grid renders -6. Publish teams → export CSV → verify file downloads correctly -7. Copy share link → open in incognito tab → verify public results page loads without auth - -**Step 14 — Auth replay test** - -1. POST to `/auth/nostr` with a valid NIP-98 event; record the exact request body -2. Wait 61 seconds -3. POST the same body again → expect `400 Event expired` -4. POST with same body but wrong `u` tag URL → expect `401 Invalid event` - -**Step 15 — CORS verification** - -Open browser devtools on the Vercel URL → trigger any API call → confirm `Access-Control-Allow-Origin` header present in response and no CORS errors in console. - -**Step 16 — Mobile and dark mode check** - -1. Open dashboard on a 375px-wide viewport (iPhone SE) — nav, event list, registration form must be usable without horizontal scroll -2. Toggle OS dark mode → verify `dark:bg-slate-950` class on dashboard background renders correctly - ---- - -## Risk Summary - -| ID | Severity | Status | Blocks Deploy | -|----|----------|--------|---------------| -| CB-01 | Critical | Verified | Yes | -| CB-02 | Critical | Verified | Yes | -| CB-03 | Critical | Verified | Yes (silently) | -| CB-04 | High | Verified | Yes (CI) | -| SF-01 | High | Verified | No, but ship before growth | -| SF-02 | Medium | Verified | No (MVP acceptable) | -| MG-01 | Medium | Verified | No | -| MG-02 | Medium | Inferred | No | - -**Minimum to deploy:** CB-01, CB-02, CB-03, CB-04 resolved + all of Phase 3. -**Minimum to ship to real users:** All of Phase 1 + Phase 3 + Phase 4 validation passed. diff --git a/docs/superpowers/plans/2026-06-16-ai-visibility-and-docs.md b/docs/superpowers/plans/2026-06-16-ai-visibility-and-docs.md deleted file mode 100644 index 1c14d4a..0000000 --- a/docs/superpowers/plans/2026-06-16-ai-visibility-and-docs.md +++ /dev/null @@ -1,555 +0,0 @@ -# Surface AI Categorization + Docs/Cleanup — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the AI categorization of free-text "Other" strengths visible (counts + badges + a guide step), fix stale post-PR#11 UI, rewrite the README, and remove redundant files. - -**Architecture:** Backend reports per-event `ai_normalized`/`auto_normalized` counts on the allocation response; the frontend surfaces them as a results-page note and Attendees source badges, and the guide gains an explainer step with a fresh screenshot. Small contained bugfix for the stale `TeamMember` type. Docs + cleanup round it out. - -**Tech Stack:** FastAPI + SQLAlchemy + pytest (backend); Next.js 16 + React + Vitest (frontend); Playwright (screenshot capture). - -**Spec:** `docs/superpowers/specs/2026-06-16-ai-visibility-and-docs-design.md` -**Branch:** `feat/ai-visibility-and-docs` (off `main`). - ---- - -## Phase 1 — Backend: normalization counts - -**Files:** -- Modify: `backend/app/services/categorization_service.py` -- Modify: `backend/app/schemas/allocation.py` -- Modify: `backend/app/api/v1/allocation.py` -- Test: `backend/tests/test_categorization.py` (extend), `backend/tests/test_allocation_counts.py` (new) - -- [ ] **Step 1: Update `normalize_pending` to return counts.** In `categorization_service.py`, change the function so it returns `{"ai": int, "fallback": int}`. Replace the final loop + `return`: - -```python -def normalize_pending(db: Session, event_id: UUID) -> dict[str, int]: - """Fill normalized_strength for un-normalized Other entries. Never raises. - - Returns counts of how many entries were set via AI vs deterministic fallback - in this call. - """ - counts = {"ai": 0, "fallback": 0} - pending = _pending(db, event_id) - if not pending: - return counts - event = db.query(Event).filter(Event.id == event_id).first() - - mapping: dict[str, str] = {} - if settings.ANTHROPIC_API_KEY: - try: - mapping = _classify(event, pending) - except Exception as exc: # noqa: BLE001 — AI is best-effort - logger.warning("Categorization AI failed, using fallback: %s", exc) - mapping = {} - - for p in pending: - ai_cat = mapping.get(str(p.id)) - if ai_cat: - p.normalized_strength = ai_cat - p.strength_source = "ai" - counts["ai"] += 1 - else: - p.normalized_strength = _slug(p.strength_other or "other") - p.strength_source = "fallback" - counts["fallback"] += 1 - db.commit() - return counts -``` - -- [ ] **Step 2: Add count fields to `AllocationOut`.** In `backend/app/schemas/allocation.py`, add two fields to `AllocationOut` (after `constraint_warnings`): - -```python -class AllocationOut(BaseModel): - id: UUID - event_id: UUID - snapshot_hash: str - status: str - constraint_warnings: dict - ai_normalized: int = 0 - auto_normalized: int = 0 - teams: list[TeamOut] = [] - - model_config = {"from_attributes": True} -``` - -- [ ] **Step 3: Populate counts in `_build_allocation_out`.** In `backend/app/api/v1/allocation.py`, inside `_build_allocation_out`, before the final `return`, count the event's participant sources and include them in the validated dict: - -```python - ai_n = db.query(Participant).filter( - Participant.event_id == allocation.event_id, - Participant.strength_source == "ai", - ).count() - auto_n = db.query(Participant).filter( - Participant.event_id == allocation.event_id, - Participant.strength_source == "fallback", - ).count() - return AllocationOut.model_validate({ - "id": allocation.id, - "event_id": allocation.event_id, - "snapshot_hash": allocation.snapshot_hash, - "status": allocation.status, - "constraint_warnings": allocation.constraint_warnings or {}, - "ai_normalized": ai_n, - "auto_normalized": auto_n, - "teams": teams_out, - }) -``` - -(`Participant` is already imported in this module.) - -- [ ] **Step 4: Extend the categorization test.** In `backend/tests/test_categorization.py`, update the two existing assertions to also check the return value. Add to `test_ai_maps_other_to_category` after the `normalize_pending` call: - -```python - with patch.object(cat, "_classify", return_value={str(p.id): "domain_expert"}): - counts = cat.normalize_pending(db, e.id) - assert counts == {"ai": 1, "fallback": 0} -``` - -And add to `test_fallback_when_no_key` after the call: - -```python - counts = cat.normalize_pending(db, e.id) - assert counts == {"ai": 0, "fallback": 1} -``` - -(Replace the existing bare `cat.normalize_pending(db, e.id)` lines in those two tests with the count-capturing versions above.) - -- [ ] **Step 5: New integration test for the response counts.** Create `backend/tests/test_allocation_counts.py` (uses the shared `client`/`auth_headers` fixtures from `conftest.py`; no Anthropic key in the test env → fallback path): - -```python -def _make_active_event(client, auth_headers): - e = client.post("/api/v1/events", headers=auth_headers, json={"title": "Counts", "team_count": 2}).json() - client.patch(f"/api/v1/events/{e['id']}", headers=auth_headers, json={"status": "active"}) - return e - - -def test_allocation_reports_auto_normalized_count(client, auth_headers): - e = _make_active_event(client, auth_headers) - slug = e["registration_slug"] - # one free-text "Other" + three presets so allocation has >= 2 per team - client.post(f"/api/v1/events/{slug}/register", json={ - "name": "Carol", "email": "carol@t.com", - "primary_strength": "other", "strength_other": "Agronomist", "experience_level": "advanced"}) - for i, s in enumerate(["technical", "design", "planning"]): - client.post(f"/api/v1/events/{slug}/register", json={ - "name": f"P{i}", "email": f"p{i}@t.com", - "primary_strength": s, "experience_level": "intermediate"}) - res = client.post(f"/api/v1/events/{e['id']}/allocate", headers=auth_headers) - assert res.status_code == 200 - body = res.json() - # No ANTHROPIC_API_KEY in tests -> the Other entry is categorized via fallback. - assert body["auto_normalized"] == 1 - assert body["ai_normalized"] == 0 -``` - -- [ ] **Step 6: Run backend tests** - -Run: `cd backend && rm -f *.db; DATABASE_URL="sqlite:///./test_squadsync.db" SECRET_KEY=test python -m pytest tests/test_categorization.py tests/test_allocation_counts.py -q; rm -f *.db` -Expected: all pass. - -- [ ] **Step 7: Commit** - -```bash -git add backend/app/services/categorization_service.py backend/app/schemas/allocation.py backend/app/api/v1/allocation.py backend/tests/test_categorization.py backend/tests/test_allocation_counts.py -git commit -m "feat(api): report ai_normalized / auto_normalized counts on allocation" -``` - ---- - -## Phase 2 — Frontend types + team-card bugfix - -**Files:** -- Modify: `frontend/hooks/use-allocation.ts` -- Modify: `frontend/components/engine/team-card.tsx` - -> Bug: after PR #11 the backend returns `normalized_strength`/`experience_level` per member, but the `TeamMember` type and `team-card` still read `role`/`skill_level` (now undefined). Fix while adding the count fields. - -- [ ] **Step 1: Update `use-allocation.ts` types.** Replace the `TeamMember` interface and add count fields to `Allocation`: - -```ts -export interface TeamMember { - id: string; - name: string; - email: string; - normalized_strength?: string; - experience_level: string; - composite_score?: number; -} -``` -```ts -export interface Allocation { - id: string; - event_id: string; - snapshot_hash: string; - status: string; - constraint_warnings: Record; - ai_normalized?: number; - auto_normalized?: number; - teams: Team[]; -} -``` - -- [ ] **Step 2: Fix `team-card.tsx`** to use the new fields. Replace the `roleColor` map, the `roleCounts` reducer, the chips block, and the member line: - -```tsx -const strengthColor: Record = { - technical: "bg-blue-100 text-blue-800", - design: "bg-pink-100 text-pink-800", - planning: "bg-indigo-100 text-indigo-800", - coordination: "bg-green-100 text-green-800", - communication: "bg-orange-100 text-orange-800", - research: "bg-purple-100 text-purple-800", - domain_expert: "bg-teal-100 text-teal-800", -}; -``` - -Reducer: -```tsx - const strengthCounts = team.members.reduce>((acc, m) => { - const key = m.normalized_strength ?? "other"; - acc[key] = (acc[key] ?? 0) + 1; - return acc; - }, {}); -``` - -Chips block: -```tsx -
- {Object.entries(strengthCounts).map(([strength, count]) => ( - - {strength.replace("_", " ")} ×{count} - - ))} -
-``` - -Member line (inside the `details` list): -```tsx - - {(m.normalized_strength ?? "—").replace("_", " ")} · {m.experience_level} - -``` - -- [ ] **Step 3: Typecheck + lint** - -Run: `cd frontend && npx tsc --noEmit && npm run lint` -Expected: tsc clean; 0 lint errors. - -- [ ] **Step 4: Commit** - -```bash -git add frontend/hooks/use-allocation.ts frontend/components/engine/team-card.tsx -git commit -m "fix(ui): team cards use normalized_strength/experience_level; add count types" -``` - ---- - -## Phase 3 — Attendees source badge - -**Files:** -- Create: `frontend/components/attendees/source-badge.tsx` -- Test: `frontend/tests/components/source-badge.test.tsx` -- Modify: `frontend/components/attendees/attendees-table.tsx` - -- [ ] **Step 1: Write the failing test** - -```tsx -// frontend/tests/components/source-badge.test.tsx -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { SourceBadge } from "@/components/attendees/source-badge"; - -describe("SourceBadge", () => { - it("labels each source", () => { - const cases: [string, string][] = [ - ["ai", "AI"], ["fallback", "Auto"], ["manual", "Manual"], ["preset", "preset"], - ]; - for (const [source, label] of cases) { - const { unmount } = render(); - expect(screen.getByText(label)).toBeInTheDocument(); - unmount(); - } - }); -}); -``` - -- [ ] **Step 2: Run it, expect FAIL** - -Run: `cd frontend && npx vitest run tests/components/source-badge.test.tsx` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement the badge** - -```tsx -// frontend/components/attendees/source-badge.tsx -const STYLES: Record = { - ai: { label: "AI", cls: "bg-violet-100 text-violet-800" }, - fallback: { label: "Auto", cls: "bg-slate-100 text-slate-700" }, - manual: { label: "Manual", cls: "bg-blue-100 text-blue-800" }, -}; - -export function SourceBadge({ source }: { source: string }) { - const s = STYLES[source]; - if (!s) return {source}; - return {s.label}; -} -``` - -- [ ] **Step 4: Run it, expect PASS** - -Run: `cd frontend && npx vitest run tests/components/source-badge.test.tsx` -Expected: PASS. - -- [ ] **Step 5: Use it in the attendees table.** In `frontend/components/attendees/attendees-table.tsx`, add the import: - -```tsx -import { SourceBadge } from "@/components/attendees/source-badge"; -``` - -and replace the Source cell (currently `{p.strength_source}`) with: - -```tsx - -``` - -- [ ] **Step 6: Typecheck + lint** - -Run: `cd frontend && npx tsc --noEmit && npm run lint` -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add frontend/components/attendees/source-badge.tsx frontend/tests/components/source-badge.test.tsx frontend/components/attendees/attendees-table.tsx -git commit -m "feat(ui): AI/Auto/Manual source badges in attendees table" -``` - ---- - -## Phase 4 — Engine surfacing (results note + run-panel copy) - -**Files:** -- Create: `frontend/lib/allocation-notes.ts` -- Test: `frontend/tests/lib/allocation-notes.test.ts` -- Modify: `frontend/components/engine/results-grid.tsx` -- Modify: `frontend/components/engine/run-panel.tsx` - -- [ ] **Step 1: Write the failing test** - -```ts -// frontend/tests/lib/allocation-notes.test.ts -import { describe, it, expect } from "vitest"; -import { normalizationNote } from "@/lib/allocation-notes"; - -describe("normalizationNote", () => { - it("reports AI count when present", () => { - expect(normalizationNote(2, 0)).toMatch(/AI categorized 2/i); - }); - it("reports fallback when no AI", () => { - expect(normalizationNote(0, 3)).toMatch(/categorized automatically/i); - }); - it("returns null when nothing was normalized", () => { - expect(normalizationNote(0, 0)).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run it, expect FAIL** - -Run: `cd frontend && npx vitest run tests/lib/allocation-notes.test.ts` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement the helper** - -```ts -// frontend/lib/allocation-notes.ts -// Human-readable note about how free-text "Other" strengths were categorized. -export function normalizationNote(aiNormalized = 0, autoNormalized = 0): string | null { - if (aiNormalized > 0) { - const s = aiNormalized === 1 ? "" : "s"; - return `🧠 AI categorized ${aiNormalized} free-text “Other” response${s}.`; - } - if (autoNormalized > 0) { - const s = autoNormalized === 1 ? "" : "s"; - return `${autoNormalized} “Other” response${s} categorized automatically — set ANTHROPIC_API_KEY for AI.`; - } - return null; -} -``` - -- [ ] **Step 4: Run it, expect PASS** - -Run: `cd frontend && npx vitest run tests/lib/allocation-notes.test.ts` -Expected: PASS (3 tests). - -- [ ] **Step 5: Render the note in `results-grid.tsx`.** Add the import: - -```tsx -import { normalizationNote } from "@/lib/allocation-notes"; -``` - -Just inside the returned top-level `
` (before the warnings block), add: - -```tsx - {normalizationNote(allocation.ai_normalized, allocation.auto_normalized) && ( -
- {normalizationNote(allocation.ai_normalized, allocation.auto_normalized)} -
- )} -``` - -- [ ] **Step 6: Fix stale copy + add AI line in `run-panel.tsx`.** Replace the `PASSES` array: - -```tsx -const PASSES = [ - "Pass 1 — Distributing anchors (Advanced)", - "Pass 2 — Core balance pipeline (Intermediate)", - "Pass 3 — Strength constraint enforcement", - "Pass 4 — Beginner fill", -]; -``` - -And replace the participant-count `

` with two lines: - -```tsx -

- {participantCount} participants ready for allocation. - The engine will run 4 passes to distribute teams fairly. -

-

- Free-text “Other” strengths are categorized by AI before allocation. -

-``` - -- [ ] **Step 7: Typecheck + lint + tests** - -Run: `cd frontend && npx tsc --noEmit && npm run lint && npm test` -Expected: clean; all tests pass. - -- [ ] **Step 8: Commit** - -```bash -git add frontend/lib/allocation-notes.ts frontend/tests/lib/allocation-notes.test.ts frontend/components/engine/results-grid.tsx frontend/components/engine/run-panel.tsx -git commit -m "feat(ui): results-page normalization note + engine copy fixes" -``` - ---- - -## Phase 5 — Guide step + screenshot - -**Files:** -- Modify: `frontend/lib/guide-steps.ts` -- Modify: `frontend/tests/lib/guide-steps.test.ts` -- Modify: `scripts/capture-guide.mjs` -- Create: `frontend/public/guide/09-ai-category.png` - -- [ ] **Step 1: Append the guide step.** In `frontend/lib/guide-steps.ts`, add as the LAST element of `GUIDE_STEPS`: - -```ts - { - id: "ai-categorize", - title: "9. Behind the scenes: SquadSync sorts free-text answers", - caption: "When someone picks “Other” and types their own strength, SquadSync categorizes it automatically before forming teams — by AI when an API key is set, deterministically otherwise. The Attendees table shows each person’s category and its source (AI / Auto / Manual), and you can override any of them.", - image: "/guide/09-ai-category.png", - }, -``` - -- [ ] **Step 2: Update the count assertion.** In `frontend/tests/lib/guide-steps.test.ts`, change `expect(GUIDE_STEPS.length).toBe(8);` to `expect(GUIDE_STEPS.length).toBe(9);`. - -- [ ] **Step 3: Run guide-steps test** - -Run: `cd frontend && npx vitest run tests/lib/guide-steps.test.ts` -Expected: PASS (the image file need not exist yet for this test). - -- [ ] **Step 4: Extend the capture script.** In `scripts/capture-guide.mjs`, after the `08-published` shot and before `await browser.close();`, add: - -```js - // 09 attendees after allocation — shows categorized "Other" + Source badge - await page.goto(`${BASE}/dashboard/events/${eventId}/attendees`, { waitUntil: "domcontentloaded" }); - await page.waitForSelector("text=Registration QR Code", { timeout: 45000 }); - await shot(page, "09-ai-category"); -``` - -- [ ] **Step 5: Capture the screenshot.** (Controller runs this — needs the live stack.) Start the migrated backend on :8000 and `npm run dev` on :3000, then: - -Run: `node scripts/capture-guide.mjs` -Expected: all `📸` lines including `09-ai-category.png`; confirm `frontend/public/guide/09-ai-category.png` exists. (Locally it shows the "Auto" badge since no key is set — that's expected.) - -- [ ] **Step 6: Commit** - -```bash -git add frontend/lib/guide-steps.ts frontend/tests/lib/guide-steps.test.ts scripts/capture-guide.mjs frontend/public/guide/09-ai-category.png -git commit -m "feat(guide): add AI-categorization step + screenshot" -``` - ---- - -## Phase 6 — README rewrite - -**Files:** -- Modify: `README.md` (root) - -- [ ] **Step 1: Rewrite `README.md`** to cover, in this order: one-line description; key features (universal Primary Strengths + free-text Other, AI-assisted **deterministic** team formation, registration QR, in-app guide, CSV/PDF export, Nostr login); architecture (Next.js frontend on Vercel, FastAPI + Postgres backend on Render, single `main` branch deploys both); local development (backend: venv, `pip install -r backend/requirements.txt`, `alembic upgrade head`, `uvicorn app.main:app`; frontend: `npm install`, `npm run dev`); environment variables (link to `DEPLOYMENT.md`, note `ANTHROPIC_API_KEY` optional → enables AI categorization, deterministic fallback otherwise); testing (`pytest`, `npm test`); and a pointer to `docs/superpowers/` for specs/plans. Keep it concise (under ~120 lines) and accurate to the current code. - -- [ ] **Step 2: Sanity check links/commands** — verify referenced paths exist (`backend/requirements.txt`, `DEPLOYMENT.md`, `frontend/`), and that commands match what the project actually uses (cross-check against `DEPLOYMENT.md`). - -- [ ] **Step 3: Commit** - -```bash -git add README.md -git commit -m "docs: rewrite README for current product + deploy model" -``` - ---- - -## Phase 7 — Cleanup redundant files - -**Files:** -- Delete: `screenshots/` (root, 14 PNGs) -- Delete: `golden-path.mjs` (root) - -- [ ] **Step 1: Confirm nothing references them** - -Run: `cd "C:/Users/mwang/squadsync" && grep -rn "golden-path\|/screenshots/\|\"screenshots\b" --include=*.ts --include=*.tsx --include=*.mjs --include=*.json --include=*.yml --include=*.md . | grep -v node_modules | grep -v "docs/superpowers"` -Expected: no functional references (matches only inside the deleted files themselves or historical docs are acceptable — if a workflow/package script references `golden-path.mjs`, stop and report). - -- [ ] **Step 2: Delete and commit** - -```bash -git rm -r screenshots golden-path.mjs -git commit -m "chore: remove redundant root screenshots/ and golden-path.mjs (superseded by scripts/capture-guide.mjs + frontend/public/guide)" -``` - ---- - -## Phase 8 — Full verification - -- [ ] **Step 1: Backend suite** - -Run: `cd backend && rm -f *.db; DATABASE_URL="sqlite:///./test_squadsync.db" SECRET_KEY=test python -m pytest -q; rm -f *.db` -Expected: all pass. - -- [ ] **Step 2: Frontend gates + build** - -Run: `cd frontend && npx tsc --noEmit && npm run lint && npm test && NEXT_PUBLIC_API_URL=http://localhost:8000 AUTH_SECRET=build-check npm run build` -Expected: tsc clean; lint no new errors; all tests pass; build succeeds. - -- [ ] **Step 3: Commit any fixes** (only if needed) - -```bash -git add -A && git commit -m "chore(ai-visibility): verification fixes" -``` - ---- - -## Self-Review (completed by author) - -- **Spec coverage:** counts API (P1), use-allocation types (P2), results note (P4), attendees badges (P3), engine copy fix (P4), guide step + screenshot (P5), README (P6), cleanup (P7), testing (each phase + P8). Plus the discovered `TeamMember` bugfix (P2). ✅ -- **Type consistency:** `ai_normalized`/`auto_normalized` used identically across `AllocationOut`, `_build_allocation_out`, `Allocation` type, `normalizationNote`, and `results-grid`; `normalize_pending` returns `{"ai","fallback"}` matching the test; `SourceBadge` labels (AI/Auto/Manual/preset) match `strength_source` values (`ai`/`fallback`/`manual`/`preset`). ✅ -- **Placeholders:** none — complete code per step; the one runtime step (P5 capture) is controller-run with exact commands. ✅ -- **YAGNI:** counts derived from existing data; pure-function/presentational units chosen specifically for reliable tests (no SWR/session wrestling). ✅ diff --git a/docs/superpowers/plans/2026-06-17-breadcrumb-navigation.md b/docs/superpowers/plans/2026-06-17-breadcrumb-navigation.md deleted file mode 100644 index 4d22fcf..0000000 --- a/docs/superpowers/plans/2026-06-17-breadcrumb-navigation.md +++ /dev/null @@ -1,466 +0,0 @@ -# Breadcrumb Navigation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add breadcrumb navigation to every nested dashboard page so users can see where they are and climb back to the parent event / section. - -**Architecture:** A generic presentational `Breadcrumb` component (semantic `