Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ data/
venv/
.env
.env.local
*.har
.coverage
htmlcov/
.mypy_cache/
Expand Down
68 changes: 68 additions & 0 deletions .github/scripts/check_module_coverage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Enforce the >=90% coverage target for "pure modules" documented in CLAUDE.md.

CLAUDE.md's Testing section calls out parsers.py, effective_config.py,
decision.py, and humanizer.py as pure functions that should carry >=90%
coverage — stricter than the >=85% project-wide gate (see `pytest.ini`
options in pyproject.toml). Coverage.py has no built-in per-file threshold,
so this reads the `coverage.json` report (produced by `pytest --cov-report
json` in the same CI step) and checks each module individually — a single
low-effort module can't hide behind the other three being at 100%.

Run after `pytest --cov=kudosy --cov-report=json`, from the repo root.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

_THRESHOLD = 90.0
_PURE_MODULES = [
"src/kudosy/parsers.py",
"src/kudosy/effective_config.py",
"src/kudosy/decision.py",
"src/kudosy/humanizer.py",
]


def main() -> int:
report_path = Path("coverage.json")
if not report_path.exists():
print(f"ERROR: {report_path} not found — run pytest with --cov-report=json first.")
return 1

data = json.loads(report_path.read_text(encoding="utf-8"))
files = data.get("files", {})

results: dict[str, float | None] = dict.fromkeys(_PURE_MODULES)
for raw_path, info in files.items():
normalised = raw_path.replace("\\", "/")
if normalised in results:
results[normalised] = info["summary"]["percent_covered"]

missing = [m for m, pct in results.items() if pct is None]
if missing:
print("ERROR: expected module(s) not found in coverage.json (renamed/moved?):")
for m in missing:
print(f" - {m}")
return 1

failed = []
for module, pct in results.items():
marker = "OK" if pct >= _THRESHOLD else "FAIL"
print(f"[{marker}] {module}: {pct:.1f}% (threshold {_THRESHOLD:.0f}%)")
if pct < _THRESHOLD:
failed.append(module)

if failed:
print(f"\nPure-module coverage gate failed for: {', '.join(failed)}")
return 1

print("\nPure-module coverage gate passed.")
return 0


if __name__ == "__main__":
sys.exit(main())
11 changes: 7 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
cache: "pip"
Expand All @@ -36,10 +36,13 @@ jobs:
run: mypy src/

- name: pytest — run with coverage
run: pytest --cov=kudosy --cov-fail-under=85 --cov-report=xml -q
run: pytest --cov=kudosy --cov-fail-under=85 --cov-report=xml --cov-report=json -q

- name: Coverage — pure-module gate (≥90%, see CLAUDE.md)
run: python .github/scripts/check_module_coverage.py

- name: Upload coverage report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: coverage-xml
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/docker-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,26 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ inputs.ref }}

- name: Set up QEMU (for multi-arch builds)
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
Expand All @@ -59,7 +59,7 @@ jobs:
type=sha,prefix=sha-

- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: .
platforms: linux/amd64,linux/arm64
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
major: ${{ steps.release.outputs.major }}
minor: ${{ steps.release.outputs.minor }}
steps:
- uses: googleapis/release-please-action@v4
- uses: googleapis/release-please-action@8b8fd2cc23b2e18957157a9d923d75aa0c6f6ad5 # v4
id: release
with:
release-type: python
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ env/
.coverage.*
htmlcov/
coverage.xml
coverage.json

# mypy
.mypy_cache/
Expand All @@ -48,3 +49,6 @@ dmypy.json
# Browser network captures (may contain session cookies / PII)
*.har
.tokensave

# Claude Code — personal local overrides (project-shared .claude/settings.json is tracked)
.claude/settings.local.json
72 changes: 58 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ pytest --cov=kudosy --cov-report=term-missing # with coverage
```

Coverage targets: ≥85% overall, ≥90% for pure modules (`parsers`, `effective_config`,
`decision`, `humanizer`).
`decision`, `humanizer`). Both are enforced in CI — the overall gate via `pytest
--cov-fail-under=85`, the per-module one via `.github/scripts/check_module_coverage.py`
(reads `coverage.json`, since coverage.py has no built-in per-file threshold).

### Lint & type-check

Expand Down Expand Up @@ -99,10 +101,30 @@ Known unavoidable external warnings must be added to the `filterwarnings` ignore
### Browser-based UI verification

When verifying frontend/UI changes visually, always use an isolated, dedicated browser
automation tool (e.g. a headless/dedicated Playwright or browser-agent instance) —
**never** drive the user's currently-running personal browser (Safari, Chrome, etc.) via
AppleScript/UI automation or by opening new windows/tabs in it. Doing so interferes with the
user's actual, in-progress work (open tabs, unsaved state).
automation tool — **never** drive the user's currently-running personal browser (Safari,
Chrome, etc.) via AppleScript/UI automation or by opening new windows/tabs in it. Doing so
interferes with the user's actual, in-progress work (open tabs, unsaved state).

**Use the `agent-browser` CLI** (`brew install agent-browser` or `npm i -g agent-browser`;
run `agent-browser skills get core --full` once for the full command reference). It launches
its own isolated Chrome instance via CDP — no Playwright/Puppeteer dependency, no interaction
with the user's browser session. Typical flow:

```bash
agent-browser open http://127.0.0.1:8080/
agent-browser snapshot -i # accessibility tree with @eN refs for interactive elements
agent-browser screenshot out.png # visual check — Read the PNG to actually look at it
agent-browser fill @e13 "value"
agent-browser click @e14
agent-browser console # check for JS errors / CSP violations
agent-browser close --all # clean up when done
```

Gotcha: `serve_index()` in `routes.py` serves JS/CSS with `?v={version}` and
`Cache-Control: max-age=31536000, immutable`. If you edit a static file and re-test in the
*same* browser session without bumping the package version, the browser serves the stale
cached copy under that identical URL — `agent-browser reload` is not enough to see your edit.
Run `agent-browser close --all` and re-`open` (a fresh session has no disk cache) instead.

If no such isolated browser tool is available in the environment, say so explicitly and
report verification as blocked/skipped rather than falling back to automating the user's
Expand Down Expand Up @@ -133,31 +155,37 @@ docker compose up --build # builds image, starts on :8080
| `scheduler.py` | APScheduler wrapper with jitter, reschedule, in-flight guard |
| `logging_conf.py` | stdout + `/data/last-run.log` handler setup |
| `app.py` | FastAPI app factory + lifespan |
| `routes.py` | All `/api/*` endpoints |
| `settings.py` | `pydantic-settings` env config (`KUDOSY_DATA_DIR`, `KUDOSY_PORT`, …) |
| `routes.py` | All `/api/*` endpoints (split into `public_router` + auth-gated `router`) |
| `settings.py` | `pydantic-settings` env config (`KUDOSY_DATA_DIR`, `KUDOSY_PORT`, `KUDOSY_AUTH_PASSWORD`, …) |
| `auth.py` | Optional login gate: `require_auth` dependency, signed session-cookie tokens (HMAC-SHA256), login lockout — no-op unless `KUDOSY_AUTH_PASSWORD` is set |

## API Endpoints

All endpoints match the legacy Node.js wrapper exactly (so the frontend works unchanged):

```
GET /api/config — read user config (includes catchAll)
PUT /api/config — write user config (empty cookie → 400)
GET /api/config — read user config (cookie redacted — see hasCookie/cookiePreview)
PUT /api/config — merge+validate user config (empty cookie keeps existing; 422 on invalid)
GET /api/settings — read scheduler/delay settings
PUT /api/settings — write settings + reschedule
GET /api/sport-types — list of Strava sport types
GET /api/sport-categories — active sport types grouped into the 5 Strava categories
GET /api/athletes/search?q=<name> — search athletes by name (requires cookie)
GET /api/athletes/{id} — lookup athlete name by ID (requires cookie)
GET /api/athletes/{id} — lookup athlete name by ID (requires cookie; id must be numeric)
GET /api/athlete-labels — all cached athlete names {id → name}
GET /api/athlete-avatars — all cached athlete avatar URLs {id → url}
GET /api/feed — current following feed with give_kudos/reason
POST /api/kudos/{activity_id} — send kudos for a specific activity
POST /api/kudos/{activity_id} — send kudos for a specific activity (activity_id must be numeric)
POST /api/run — trigger a run (409 if already running)
GET /api/status — running state, lastRun, nextRunAt, version, authOk
GET /api/history?limit= — last N run-history entries (max 500, newest first)
GET /api/export — download config+settings as JSON (cookie excluded)
POST /api/import — restore config+settings from backup JSON
GET /api/log — last-run.log as text/plain
GET /api/log/stream — live log via Server-Sent Events
GET /api/auth-status — {authRequired, authenticated} — always reachable, no session needed
POST /api/login — {password} → sets session cookie (401 wrong password, 429 lockout)
POST /api/logout — clears the session cookie
```

**Brittleness note:** `GET /api/athletes/search` and `GET /api/feed` depend on Strava's
Expand All @@ -166,16 +194,32 @@ is isolated in `feed.py` (`StructuredFeedParser`) and `strava_client.py`.
`GET /api/athletes/search` HTML parsing is isolated in `strava_client.py` (`_extract_search_results`).
If Strava changes their response format, only these two modules need updating.

**Auth note:** all `/api/*` routes except `/api/auth-status`, `/api/login`, and `/api/logout`
(plus `GET /`, serving the frontend shell) go through `auth.require_auth` — a no-op unless
`KUDOSY_AUTH_PASSWORD` is set (see README "Access Control"). Enforced via two `APIRouter`s in
`routes.py`: `public_router` (no dependency) and `router` (`dependencies=[Depends(require_auth)]`).

## Security Notes

- **Never commit `/data/`**: it contains the real `_strava4_session` cookie and real athlete names.
`.gitignore` and `.dockerignore` both exclude it.
- **Cookie masking**: logs show only the first ~8 characters (e.g., `r2i8rfkf…`).
- **Never commit `/data/`**: it contains the real `_strava4_session` cookie, the session-signing
secret, and real athlete names. `.gitignore` and `.dockerignore` both exclude it.
- **Cookie masking**: logs show only the first ~8 characters (e.g., `r2i8rfkf…`); `GET /api/config`
never returns the raw cookie (only `hasCookie`/`cookiePreview`).
- **ToS grey area**: Kudosy uses the Strava web session (not the official API). Use it for personal,
non-commercial purposes only. Keep the interval generous and dry-run frequently.
- **No auth by default**: see README "Access Control" — set `KUDOSY_AUTH_PASSWORD` before exposing
Kudosy beyond your local machine.

## Git Conventions

> **GPG signing gotcha:** this repo's commits are GPG-signed (`commit.gpgsign=true`
> locally). Never wrap `git commit` in `timeout ...`, `bash -c "..."`, or other
> subshell/process-wrapping constructs — pinentry loses its controlling TTY in that
> case and `gpg-agent` hangs indefinitely waiting for a passphrase prompt that can
> never appear, instead of failing fast. Invoke `git commit` as a direct, top-level
> command. If it still hangs, the fix is restarting the agent (`gpgconf --kill
> gpg-agent`) outside the hung command, not adding `--no-gpg-sign`.

Conventional Commits: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:`.
Branch: `main`. Releases: `release-please` creates a PR on each push to `main`, cutting a
`vX.Y.Z` tag → GitHub Release → Docker image built+pushed to `ghcr.io/bin101/kudosy`.
Expand Down
15 changes: 8 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,6 @@ LABEL org.opencontainers.image.source="https://github.com/bin101/kudosy"

WORKDIR /app

# Runtime system deps (minimal)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*

# Copy and install the wheel (no dev deps, no src)
COPY --from=builder /build/dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl && rm /tmp/*.whl
Expand All @@ -47,15 +42,21 @@ VOLUME ["/data"]
# Default port
EXPOSE 8080
ENV KUDOSY_PORT=8080
# Must stay 0.0.0.0: this is the bind address *inside* the container, which
# Docker's port publishing (see docker-compose.yaml) NATs to. Restricting
# external reachability belongs in the `ports:` mapping / host firewall, not
# here — binding to 127.0.0.1 here would make the container unreachable even
# from its own published port. (The app's own default of 127.0.0.1, used for
# non-Docker `python -m kudosy` runs, is overridden by this env var.)
ENV KUDOSY_HOST=0.0.0.0
ENV KUDOSY_LOG_LEVEL=INFO

# Non-root user for security
RUN useradd --create-home --shell /bin/bash kudosy
USER kudosy

# Health check — polls /api/status every 30s
# Health check — polls /api/status every 30s (stdlib urllib, no extra package)
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -fsSL http://localhost:${KUDOSY_PORT}/api/status > /dev/null || exit 1
CMD python -c "import urllib.request as u; u.urlopen('http://localhost:${KUDOSY_PORT}/api/status', timeout=4)" || exit 1

CMD ["python", "-m", "kudosy"]
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,43 @@ on first boot and renamed to `defaults.yaml.migrated`.

See `data/config.example.yaml` for a fully annotated example.

## Access Control

By default, Kudosy's web UI and API have **no authentication** — anyone who can reach the
port can read/change your config (including whether a Strava cookie is set) and trigger runs.
The Docker image binds to all interfaces inside the container (required for Docker's port
publishing to work), and the `docker-compose.yaml` shipped here maps that to `127.0.0.1` on
the host by default, so it isn't reachable from your LAN out of the box.

If you do expose Kudosy beyond your local machine (LAN access, a reverse proxy, etc.), set a
password to require a login before any `/api/*` call works:

```bash
# docker-compose.yaml
environment:
KUDOSY_AUTH_PASSWORD: "choose-a-strong-password"
```

or for a non-Docker run:

```bash
KUDOSY_AUTH_PASSWORD=choose-a-strong-password KUDOSY_HOST=0.0.0.0 python -m kudosy
```

This shows a login screen in the UI; a signed session cookie (HMAC-SHA256, `HttpOnly`,
`SameSite=Lax`) keeps you logged in for `session_ttl_hours` (default 30 days). A few related
environment variables:

| Variable | Default | Purpose |
|---|---|---|
| `KUDOSY_AUTH_PASSWORD` | unset (no login) | Setting this enables the login screen |
| `KUDOSY_SESSION_TTL_HOURS` | `720` (30 days) | How long a login stays valid |
| `KUDOSY_SECRET_KEY` | auto-generated, persisted to `/data/session-secret` | HMAC key signing session cookies; set explicitly to invalidate all sessions on demand |
| `KUDOSY_COOKIE_SECURE` | `false` | Send the session cookie with `Secure` — only enable this behind HTTPS (a TLS-terminating reverse proxy), otherwise the browser silently drops the cookie |

Leaving `KUDOSY_AUTH_PASSWORD` unset keeps today's behavior (no login) — this is opt-in and
does not affect existing installs.

## Human-Like Timing

Kudosy has several layers of randomness and restraint to avoid a detectable machine pattern:
Expand Down
Loading
Loading