typefaster-cli 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.env.example +7 -0
  2. typefaster_cli-0.1.2/.github/dependabot.yml +16 -0
  3. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/workflows/ci.yml +15 -0
  4. typefaster_cli-0.1.2/.github/workflows/codeql.yml +23 -0
  5. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/workflows/release.yml +27 -0
  6. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.gitignore +1 -0
  7. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/PKG-INFO +1 -13
  8. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/README.md +0 -12
  9. typefaster_cli-0.1.2/SECURITY.md +28 -0
  10. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/__init__.py +1 -1
  11. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/cli.py +1 -0
  12. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/net/api.py +9 -0
  13. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/net/commands.py +95 -2
  14. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/net/token_store.py +4 -1
  15. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/online_race.py +103 -27
  16. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docker-compose.yml +4 -0
  17. typefaster_cli-0.1.2/docs/deploy-fly.md +61 -0
  18. typefaster_cli-0.1.2/docs/online-setup.md +106 -0
  19. typefaster_cli-0.1.2/fly.toml +38 -0
  20. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/infra/redis/redis.conf +5 -2
  21. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/pyproject.toml +1 -1
  22. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/config.py +5 -0
  23. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/deps.py +16 -0
  24. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/main.py +2 -1
  25. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/redis_keys.py +5 -0
  26. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/repositories.py +44 -0
  27. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/auth.py +12 -4
  28. typefaster_cli-0.1.2/server/app/routers/oauth.py +139 -0
  29. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/pyproject.toml +1 -0
  30. typefaster_cli-0.1.2/server/tests/test_oauth.py +37 -0
  31. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.dockerignore +0 -0
  32. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  33. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  34. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  35. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  36. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/CONTRIBUTING.md +0 -0
  37. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/LICENSE +0 -0
  38. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/Makefile +0 -0
  39. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/Dockerfile +0 -0
  40. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/__main__.py +0 -0
  41. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/assets/__init__.py +0 -0
  42. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/assets/quotes.json +0 -0
  43. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/__init__.py +0 -0
  44. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/anti_cheat.py +0 -0
  45. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/calculators.py +0 -0
  46. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/errors.py +0 -0
  47. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/ghost.py +0 -0
  48. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/models.py +0 -0
  49. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/domain/typing_engine.py +0 -0
  50. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/__init__.py +0 -0
  51. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/clock.py +0 -0
  52. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/config.py +0 -0
  53. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/db.py +0 -0
  54. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/migrations.py +0 -0
  55. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/paths.py +0 -0
  56. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/quote_loader.py +0 -0
  57. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/replay_store.py +0 -0
  58. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/repository.py +0 -0
  59. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/infra/sqlite_repository.py +0 -0
  60. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/net/__init__.py +0 -0
  61. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/__init__.py +0 -0
  62. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/container.py +0 -0
  63. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/daily_service.py +0 -0
  64. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/ghost_service.py +0 -0
  65. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/profile_service.py +0 -0
  66. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/race_service.py +0 -0
  67. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/services/stats_service.py +0 -0
  68. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/__init__.py +0 -0
  69. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/app.py +0 -0
  70. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/online_app.py +0 -0
  71. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/__init__.py +0 -0
  72. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/_base.py +0 -0
  73. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/daily.py +0 -0
  74. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/help.py +0 -0
  75. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/history.py +0 -0
  76. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/leaderboard.py +0 -0
  77. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/main_menu.py +0 -0
  78. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/practice.py +0 -0
  79. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/profile.py +0 -0
  80. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/race.py +0 -0
  81. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/results.py +0 -0
  82. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/settings.py +0 -0
  83. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/screens/stats.py +0 -0
  84. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/theme.py +0 -0
  85. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/__init__.py +0 -0
  86. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/bigtext.py +0 -0
  87. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/live_stats.py +0 -0
  88. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/progress_bars.py +0 -0
  89. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/client/typefaster/ui/widgets/typing_field.py +0 -0
  90. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/RELEASING.md +0 -0
  91. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/api-spec.md +0 -0
  92. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/architecture.md +0 -0
  93. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/deployment.md +0 -0
  94. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/redis-schema.md +0 -0
  95. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/roadmap.md +0 -0
  96. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/sqlite-schema.md +0 -0
  97. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/ui-design.md +0 -0
  98. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/docs/websocket-protocol.md +0 -0
  99. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/infra/README.md +0 -0
  100. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/infra/nginx/nginx.conf +0 -0
  101. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/packaging/homebrew/typefaster.rb +0 -0
  102. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/scripts/seed_quotes.py +0 -0
  103. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/Dockerfile +0 -0
  104. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/__init__.py +0 -0
  105. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/logging_config.py +0 -0
  106. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/quotes.py +0 -0
  107. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/__init__.py +0 -0
  108. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/health.py +0 -0
  109. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/leaderboards.py +0 -0
  110. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/routers/lobbies.py +0 -0
  111. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/security.py +0 -0
  112. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/ws/__init__.py +0 -0
  113. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/app/ws/manager.py +0 -0
  114. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/__init__.py +0 -0
  115. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/conftest.py +0 -0
  116. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_auth.py +0 -0
  117. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_health_leaderboards.py +0 -0
  118. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_lobbies.py +0 -0
  119. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_scoring_anticheat.py +0 -0
  120. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/server/tests/test_ws_race.py +0 -0
  121. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/pyproject.toml +0 -0
  122. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/__init__.py +0 -0
  123. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/anti_cheat.py +0 -0
  124. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/dto.py +0 -0
  125. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/events.py +0 -0
  126. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/shared/typefaster_shared/scoring.py +0 -0
  127. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/conftest.py +0 -0
  128. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/fixtures/.gitkeep +0 -0
  129. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_container_and_cli.py +0 -0
  130. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_migrations.py +0 -0
  131. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_profile_stats.py +0 -0
  132. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_race_service.py +0 -0
  133. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_race_timing.py +0 -0
  134. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_sqlite_repository.py +0 -0
  135. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/integration/test_ui_smoke.py +0 -0
  136. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_anti_cheat.py +0 -0
  137. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_calculators.py +0 -0
  138. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_config.py +0 -0
  139. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_ghost.py +0 -0
  140. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_net.py +0 -0
  141. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_quote_loader.py +0 -0
  142. {typefaster_cli-0.1.0 → typefaster_cli-0.1.2}/tests/unit/test_typing_engine.py +0 -0
@@ -11,3 +11,10 @@ TYPEFASTER_ACCESS_TOKEN_MINUTES=1440
11
11
 
12
12
  # Host port to expose the server on for local dev (nginx handles prod).
13
13
  SERVER_PORT=8000
14
+
15
+ # ── OAuth device-flow login (optional, all free) ────────────────────────
16
+ # Leave blank to disable a provider. See docs/online-setup.md for how to
17
+ # create the GitHub OAuth App and Google OAuth client.
18
+ TYPEFASTER_GITHUB_CLIENT_ID=
19
+ TYPEFASTER_GOOGLE_CLIENT_ID=
20
+ TYPEFASTER_GOOGLE_CLIENT_SECRET=
@@ -0,0 +1,16 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: "/" # client (root pyproject)
5
+ schedule: { interval: weekly }
6
+ open-pull-requests-limit: 5
7
+ - package-ecosystem: pip
8
+ directory: "/server"
9
+ schedule: { interval: weekly }
10
+ open-pull-requests-limit: 5
11
+ - package-ecosystem: pip
12
+ directory: "/shared"
13
+ schedule: { interval: weekly }
14
+ - package-ecosystem: github-actions
15
+ directory: "/"
16
+ schedule: { interval: weekly }
@@ -66,6 +66,21 @@ jobs:
66
66
  - name: Test (pytest)
67
67
  run: cd server && pytest
68
68
 
69
+ audit:
70
+ name: dependency audit (pip-audit)
71
+ runs-on: ubuntu-latest
72
+ steps:
73
+ - uses: actions/checkout@v4
74
+ - uses: actions/setup-python@v5
75
+ with:
76
+ python-version: "3.12"
77
+ cache: pip
78
+ - run: python -m pip install pip-audit
79
+ - name: Audit client deps
80
+ run: pip-audit --strict --progress-spinner off . || true
81
+ - name: Audit server deps
82
+ run: pip-audit --strict --progress-spinner off ./server || true
83
+
69
84
  build:
70
85
  name: build wheel
71
86
  runs-on: ubuntu-latest
@@ -0,0 +1,23 @@
1
+ name: CodeQL
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ schedule:
9
+ - cron: "0 6 * * 1" # weekly
10
+
11
+ jobs:
12
+ analyze:
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ security-events: write
16
+ actions: read
17
+ contents: read
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: github/codeql-action/init@v3
21
+ with:
22
+ languages: python
23
+ - uses: github/codeql-action/analyze@v3
@@ -13,6 +13,7 @@ on:
13
13
  permissions:
14
14
  contents: write # create the GitHub Release
15
15
  id-token: write # OIDC token for PyPI Trusted Publishing
16
+ packages: write # push the server image to GHCR
16
17
 
17
18
  jobs:
18
19
  build:
@@ -53,3 +54,29 @@ jobs:
53
54
  with:
54
55
  generate_release_notes: true
55
56
  files: dist/*
57
+
58
+ server-image:
59
+ name: build & push server image (GHCR)
60
+ needs: build
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - uses: actions/checkout@v4
64
+ - uses: docker/login-action@v3
65
+ with:
66
+ registry: ghcr.io
67
+ username: ${{ github.actor }}
68
+ password: ${{ secrets.GITHUB_TOKEN }}
69
+ - id: meta
70
+ uses: docker/metadata-action@v5
71
+ with:
72
+ images: ghcr.io/${{ github.repository_owner }}/typefaster-server
73
+ tags: |
74
+ type=semver,pattern={{version}}
75
+ type=raw,value=latest
76
+ - uses: docker/build-push-action@v6
77
+ with:
78
+ context: .
79
+ file: server/Dockerfile
80
+ push: true
81
+ tags: ${{ steps.meta.outputs.tags }}
82
+ labels: ${{ steps.meta.outputs.labels }}
@@ -29,3 +29,4 @@ typefaster.db*
29
29
  .idea/
30
30
  .vscode/
31
31
  .DS_Store
32
+ .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typefaster-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A terminal-first typing game inspired by MonkeyType and TypeRacer.
5
5
  Project-URL: Homepage, https://github.com/Anoshor/typefaster-cli
6
6
  Project-URL: Repository, https://github.com/Anoshor/typefaster-cli
@@ -49,18 +49,6 @@ typefaster
49
49
 
50
50
  ---
51
51
 
52
- ## Status
53
-
54
- | Phase | Scope | State |
55
- |-------|-------|-------|
56
- | **Phase 1** | Offline experience: races, ghosts, profile, stats, history, daily challenge | ✅ **Implemented & tested** |
57
- | **Phase 2** | Online multiplayer: FastAPI + Redis + WebSockets, auth, lobbies, leaderboards, anti-cheat, Docker | ✅ **Implemented & tested** |
58
-
59
- Both phases are implemented. Offline play needs only `pip install`; online play
60
- adds a Dockerized server (see [Online play](#online-play-phase-2)).
61
-
62
- ---
63
-
64
52
  ## What Phase 1 delivers
65
53
 
66
54
  - **Instant offline races** — random quote, live WPM / accuracy / progress / timer.
@@ -17,18 +17,6 @@ typefaster
17
17
 
18
18
  ---
19
19
 
20
- ## Status
21
-
22
- | Phase | Scope | State |
23
- |-------|-------|-------|
24
- | **Phase 1** | Offline experience: races, ghosts, profile, stats, history, daily challenge | ✅ **Implemented & tested** |
25
- | **Phase 2** | Online multiplayer: FastAPI + Redis + WebSockets, auth, lobbies, leaderboards, anti-cheat, Docker | ✅ **Implemented & tested** |
26
-
27
- Both phases are implemented. Offline play needs only `pip install`; online play
28
- adds a Dockerized server (see [Online play](#online-play-phase-2)).
29
-
30
- ---
31
-
32
20
  ## What Phase 1 delivers
33
21
 
34
22
  - **Instant offline races** — random quote, live WPM / accuracy / progress / timer.
@@ -0,0 +1,28 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+ The latest released version on PyPI (`typefaster-cli`) and the `main` branch
5
+ receive security fixes.
6
+
7
+ ## Reporting a vulnerability
8
+ Please **do not open a public issue** for security problems.
9
+
10
+ Report privately via GitHub's **Security advisories**:
11
+ <https://github.com/Anoshor/typefaster-cli/security/advisories/new>
12
+
13
+ (or email the maintainer listed on the GitHub profile). Include steps to
14
+ reproduce and impact. We aim to acknowledge within 72 hours and to ship a fix or
15
+ mitigation promptly, crediting reporters who wish to be named.
16
+
17
+ ## Hardening notes (for self-hosters)
18
+ - Run the server only behind TLS (the bundled nginx profile, or a TLS-terminating
19
+ platform like Fly.io). Never expose Redis publicly — keep it on the internal
20
+ network (`docker-compose.yml` does not publish 6379).
21
+ - Set a strong `TYPEFASTER_JWT_SECRET` (e.g. `openssl rand -hex 32`); rotating it
22
+ invalidates all existing sessions.
23
+ - Lock `TYPEFASTER_CORS_ORIGINS` to your real domain in production (default `*`).
24
+ - Credential endpoints (`/auth/register`, `/auth/login`, OAuth start) are
25
+ per-IP rate limited; keep `--proxy-headers` enabled so the limiter sees real
26
+ client IPs behind a proxy.
27
+ - Passwords are hashed with bcrypt; results are re-scored server-side with
28
+ anti-cheat before leaderboard writes.
@@ -1,3 +1,3 @@
1
1
  """TYPEFASTER-CLI — a terminal-first typing game."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -190,6 +190,7 @@ app.command("login")(_online.login)
190
190
  app.command("logout")(_online.logout)
191
191
  app.command("leaderboard")(_online.leaderboard)
192
192
  app.add_typer(_online.lobby_app, name="lobby")
193
+ app.add_typer(_online.config_app, name="config")
193
194
 
194
195
 
195
196
  if __name__ == "__main__":
@@ -64,6 +64,15 @@ class ApiClient:
64
64
  def logout(self) -> None:
65
65
  self._request("POST", "/auth/logout", headers=self._auth_headers())
66
66
 
67
+ # ── oauth device flow ──────────────────────────────────────────────
68
+ def oauth_start(self, provider: str) -> Any:
69
+ return self._request("POST", f"/auth/oauth/{provider}/start")
70
+
71
+ def oauth_poll(self, provider: str, device_code: str) -> Any:
72
+ return self._request(
73
+ "POST", f"/auth/oauth/{provider}/poll", json={"device_code": device_code}
74
+ )
75
+
67
76
  def me(self) -> Any:
68
77
  return self._request("GET", "/auth/me", headers=self._auth_headers())
69
78
 
@@ -6,7 +6,10 @@ The online race UI is launched via the Textual app.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import contextlib
9
10
  import getpass
11
+ import time
12
+ import webbrowser
10
13
 
11
14
  import typer
12
15
  from rich.console import Console
@@ -46,8 +49,18 @@ def register(
46
49
  client.close()
47
50
 
48
51
 
49
- def login(username: str = typer.Argument(..., help="Your username.")) -> None:
50
- """Log in to the server."""
52
+ def login(
53
+ username: str | None = typer.Argument(None, help="Your username (password login)."),
54
+ github: bool = typer.Option(False, "--github", help="Log in with GitHub (browser)."),
55
+ google: bool = typer.Option(False, "--google", help="Log in with Google (browser)."),
56
+ ) -> None:
57
+ """Log in to the server (password, or --github / --google)."""
58
+ if github or google:
59
+ _oauth_login("github" if github else "google")
60
+ return
61
+ if not username:
62
+ console.print("[red]Provide a username, or use[/] --github / --google.")
63
+ raise typer.Exit(1)
51
64
  password = getpass.getpass("Password: ")
52
65
  client, session = _client()
53
66
  try:
@@ -63,6 +76,54 @@ def login(username: str = typer.Argument(..., help="Your username.")) -> None:
63
76
  client.close()
64
77
 
65
78
 
79
+ def _oauth_login(provider: str) -> None:
80
+ """Run the OAuth device flow: show a code, open the browser, poll for token."""
81
+ client, session = _client()
82
+ try:
83
+ try:
84
+ start = client.oauth_start(provider)
85
+ except ApiError as exc:
86
+ console.print(f"[red]{provider.title()} login unavailable:[/] {exc.detail}")
87
+ raise typer.Exit(1) from exc
88
+
89
+ uri = start["verification_uri"]
90
+ code = start["user_code"]
91
+ device = start["device_code"]
92
+ interval = int(start.get("interval", 5))
93
+ deadline = time.time() + int(start.get("expires_in", 900))
94
+
95
+ console.print(
96
+ f"\n Open [bold underline]{uri}[/]\n"
97
+ f" Enter code: [bold cyan]{code}[/]\n"
98
+ )
99
+ with contextlib.suppress(Exception):
100
+ webbrowser.open(uri)
101
+ console.print("[grey58]Waiting for authorization…[/] (Ctrl-C to cancel)")
102
+
103
+ while time.time() < deadline:
104
+ time.sleep(interval)
105
+ try:
106
+ r = client.oauth_poll(provider, device)
107
+ except ApiError as exc:
108
+ console.print(f"[red]Login failed:[/] {exc.detail}")
109
+ raise typer.Exit(1) from exc
110
+ status_ = r.get("status")
111
+ if status_ == "pending":
112
+ continue
113
+ if status_ == "slow_down":
114
+ interval += 5
115
+ continue
116
+ session.token = r["access_token"]
117
+ session.username = r["username"]
118
+ session.save()
119
+ console.print(f"[green]Logged in as[/] [bold]{session.username}[/]")
120
+ return
121
+ console.print("[red]Login timed out — please try again.[/]")
122
+ raise typer.Exit(1)
123
+ finally:
124
+ client.close()
125
+
126
+
66
127
  def logout() -> None:
67
128
  """Log out and clear the local token."""
68
129
  client, session = _client()
@@ -105,6 +166,38 @@ def leaderboard(
105
166
  client.close()
106
167
 
107
168
 
169
+ # ── config subcommands ────────────────────────────────────────────────
170
+ config_app = typer.Typer(help="Client configuration (server URL, etc).", no_args_is_help=True)
171
+
172
+
173
+ @config_app.command("set-server")
174
+ def config_set_server(
175
+ url: str = typer.Argument(..., help="Server base URL, e.g. https://abc.trycloudflare.com"),
176
+ ) -> None:
177
+ """Point this client at a server (local, tunnel, or deployed)."""
178
+ session = Session.load()
179
+ new_url = url.rstrip("/")
180
+ if new_url != session.server_url:
181
+ # Tokens are server-specific — clear on change to avoid stale auth.
182
+ session.token = None
183
+ session.username = None
184
+ session.server_url = new_url
185
+ session.save()
186
+ console.print(f"[green]Server set to[/] [bold]{session.server_url}[/]. Now log in or register.")
187
+
188
+
189
+ @config_app.command("show")
190
+ def config_show() -> None:
191
+ """Show the current client configuration."""
192
+ s = Session.load()
193
+ table = Table(show_header=False)
194
+ table.add_column(style="grey58", justify="right")
195
+ table.add_column()
196
+ table.add_row("Server URL", s.server_url)
197
+ table.add_row("Logged in as", s.username or "[grey58]not logged in[/]")
198
+ console.print(table)
199
+
200
+
108
201
  # ── lobby subcommands ─────────────────────────────────────────────────
109
202
  lobby_app = typer.Typer(help="Multiplayer lobbies.", no_args_is_help=True)
110
203
 
@@ -15,7 +15,10 @@ def _auth_path() -> Path:
15
15
 
16
16
  @dataclass(slots=True)
17
17
  class Session:
18
- server_url: str = "http://localhost:8000"
18
+ # Default points at the public TYPEFASTER server so a fresh install can
19
+ # register/play with zero config. Override with `typefaster config set-server`
20
+ # (e.g. a local server or your own deployment).
21
+ server_url: str = "https://typefaster-cli.fly.dev"
19
22
  token: str | None = None
20
23
  username: str | None = None
21
24
 
@@ -1,8 +1,8 @@
1
- """Online multiplayer race screen.
1
+ """Online multiplayer lobby + race screen.
2
2
 
3
- Connects to the server over WebSocket, auto-readies, and lets the **server**
4
- drive countdown/start/finish. The client only renders state, reports progress,
5
- and submits a final result for server-side validation.
3
+ Flow: connect → wait in the lobby (press R to ready) the **server** starts the
4
+ race when everyone is ready, sends the same quote to all, relays progress, and
5
+ re-scores results server-side. The client only renders state and reports input.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -23,13 +23,14 @@ from textual.screen import Screen
23
23
  from textual.widgets import Static
24
24
 
25
25
  from ...domain.typing_engine import TypingEngine
26
+ from ..widgets import bigtext
26
27
  from ..widgets.live_stats import LiveStats
27
28
  from ..widgets.typing_field import TypingField
28
29
 
29
30
 
30
- def _bar(pct: float, width: int = 24) -> str:
31
+ def _bar(pct: float, width: int = 28) -> str:
31
32
  filled = max(0, min(width, round(pct / 100.0 * width)))
32
- return "#" * filled + "-" * (width - filled)
33
+ return "" * filled + "" * (width - filled)
33
34
 
34
35
 
35
36
  class OnlineRaceScreen(Screen[None]):
@@ -40,23 +41,28 @@ class OnlineRaceScreen(Screen[None]):
40
41
  self.ws_url = ws_url
41
42
  self.username = username
42
43
  self.mode_seconds = mode_seconds
44
+ # Extract the lobby code from .../ws/lobby/<code>?token=...
45
+ self.code = ws_url.split("/ws/lobby/", 1)[-1].split("?", 1)[0]
43
46
  self.engine: TypingEngine | None = None
44
47
  self._ws: Any = None
45
48
  self._start_ms: int | None = None
49
+ self._phase = "lobby" # lobby | racing | results
46
50
  self._typing = False
47
51
  self._finished = False
52
+ self._ready = False
53
+ self._roster: list[dict[str, Any]] = []
48
54
  self._opponents: dict[str, dict[str, float]] = {}
49
55
  self._status = "Connecting…"
50
56
  self._standings: list[dict[str, Any]] | None = None
51
57
 
52
58
  def compose(self) -> ComposeResult:
53
59
  with Vertical(id="race-wrap"):
54
- yield Static("", id="net-status", classes="dim")
60
+ yield Static("", id="net-status")
55
61
  yield LiveStats()
56
62
  yield TypingField()
57
63
  with VerticalScroll():
58
64
  yield Static("", id="bars")
59
- yield Static("esc to leave", classes="dim")
65
+ yield Static("R ready · esc leave", classes="dim")
60
66
 
61
67
  def on_mount(self) -> None:
62
68
  self.query_one(LiveStats).display = False
@@ -70,13 +76,12 @@ class OnlineRaceScreen(Screen[None]):
70
76
  try:
71
77
  async with websockets.connect(self.ws_url) as ws:
72
78
  self._ws = ws
73
- self._status = "Connected. Waiting for players to ready up…"
79
+ self._status = "In lobby press [bold]R[/] when ready."
74
80
  self._render_status()
75
- await ws.send(json.dumps({"type": "SET_READY", "data": {"ready": True}}))
76
81
  async for raw in ws:
77
82
  self._handle(json.loads(raw))
78
83
  except Exception as exc:
79
- self._status = f"Disconnected: {exc}"
84
+ self._status = f"[red]Disconnected:[/] {exc}"
80
85
  self._render_status()
81
86
 
82
87
  async def _send(self, type_: str, **data: Any) -> None:
@@ -87,10 +92,17 @@ class OnlineRaceScreen(Screen[None]):
87
92
  def _handle(self, msg: dict[str, Any]) -> None:
88
93
  etype = msg.get("type")
89
94
  data = msg.get("data", {})
90
- if etype == "RACE_COUNTDOWN":
95
+ if etype == "LOBBY_UPDATE":
96
+ self._roster = data.get("players", [])
97
+ # Only repaint the lobby in the lobby phase — never clobber the
98
+ # results screen with the post-race "reset to waiting" update.
99
+ if self._phase == "lobby":
100
+ self._render_lobby(data)
101
+ elif etype == "RACE_COUNTDOWN":
91
102
  self._status = f"Race starts in {data.get('count')}…"
92
103
  self._render_status()
93
104
  elif etype == "RACE_START":
105
+ self._phase = "racing"
94
106
  self._begin(data["text"])
95
107
  elif etype == "RACE_PROGRESS":
96
108
  user = data.get("username")
@@ -99,9 +111,11 @@ class OnlineRaceScreen(Screen[None]):
99
111
  "progress": float(data.get("progress", 0)),
100
112
  "wpm": float(data.get("wpm", 0)),
101
113
  }
102
- self._render_bars()
114
+ if self._typing:
115
+ self._render_bars()
103
116
  elif etype == "RACE_FINISHED":
104
117
  if data.get("final"):
118
+ self._phase = "results"
105
119
  self._standings = data.get("standings", [])
106
120
  self._show_standings()
107
121
  else:
@@ -109,16 +123,18 @@ class OnlineRaceScreen(Screen[None]):
109
123
  if user and user != self.username:
110
124
  self._opponents.setdefault(user, {})["progress"] = 100.0
111
125
  self._render_bars()
112
- elif etype == "CHAT_MESSAGE":
113
- pass # chat panel could render here
126
+ elif etype == "ERROR":
127
+ self._status = f"[red]{data.get('message', 'error')}[/]"
128
+ self._render_status()
114
129
 
115
130
  # ── race lifecycle ─────────────────────────────────────────────────
116
131
  def _begin(self, text: str) -> None:
117
132
  self.engine = TypingEngine(text)
118
133
  self._start_ms = int(time.time() * 1000)
119
134
  self._typing = True
120
- self._status = "GO!"
121
- self.query_one("#net-status", Static).display = False
135
+ self.query_one("#net-status", Static).update(
136
+ Text(bigtext.render("GO!"), justify="center", style="bold green")
137
+ )
122
138
  self.query_one(LiveStats).display = True
123
139
  self.query_one(TypingField).display = True
124
140
  self._render_field()
@@ -138,7 +154,6 @@ class OnlineRaceScreen(Screen[None]):
138
154
  seconds_left=seconds_left,
139
155
  )
140
156
  self._render_bars()
141
- # Report progress to the server.
142
157
  self.run_worker(
143
158
  self._send(
144
159
  "PROGRESS",
@@ -147,11 +162,25 @@ class OnlineRaceScreen(Screen[None]):
147
162
  ),
148
163
  name="progress",
149
164
  )
150
- if (elapsed >= self.mode_seconds * 1000 or self.engine.finished) and not self._finished:
165
+ if self._start_ms is not None and elapsed >= self.mode_seconds * 1000 and not self._finished:
151
166
  self._submit_finish()
152
167
 
153
168
  def on_key(self, event: events.Key) -> None:
154
- if not self._typing or self._finished or self.engine is None:
169
+ # Lobby phase: R toggles ready.
170
+ if self._phase == "lobby":
171
+ if event.key.lower() == "r":
172
+ self._ready = not self._ready
173
+ self.run_worker(self._send("SET_READY", ready=self._ready), name="ready")
174
+ event.stop()
175
+ return
176
+ # Results phase: R re-arms for another round.
177
+ if self._phase == "results":
178
+ if event.key.lower() == "r":
179
+ self._play_again()
180
+ event.stop()
181
+ return
182
+ # Race phase: type.
183
+ if self._finished or self.engine is None:
155
184
  return
156
185
  t = self._elapsed()
157
186
  if event.key == "backspace":
@@ -190,7 +219,34 @@ class OnlineRaceScreen(Screen[None]):
190
219
 
191
220
  # ── rendering ──────────────────────────────────────────────────────
192
221
  def _render_status(self) -> None:
193
- self.query_one("#net-status", Static).update(Text(self._status, justify="center"))
222
+ self.query_one("#net-status", Static).update(Text.from_markup(self._status, justify="center"))
223
+
224
+ def _render_lobby(self, data: dict[str, Any]) -> None:
225
+ name = data.get("name", "Lobby")
226
+ host = data.get("host", "")
227
+ table = Table(title=f"{name} ({self.mode_seconds}s)", title_style="bold", expand=True)
228
+ table.add_column("Player")
229
+ table.add_column("Ready", justify="center")
230
+ for p in self._roster:
231
+ who = p["username"] + (" 👑" if p["username"] == host else "")
232
+ who += " (you)" if p["username"] == self.username else ""
233
+ ready = "[green]✓[/]" if p.get("ready") else "[grey58]…[/]"
234
+ table.add_row(who, ready)
235
+ ready_n = sum(1 for p in self._roster if p.get("ready"))
236
+ hint = Text.from_markup(
237
+ f"\n{ready_n}/{len(self._roster)} ready · "
238
+ f"press [bold]R[/] to {'unready' if self._ready else 'ready'} · "
239
+ "race starts when everyone is ready",
240
+ justify="center",
241
+ )
242
+ self.query_one("#bars", Static).update(Group(table, hint))
243
+ self.query_one("#net-status", Static).update(
244
+ Text.from_markup(
245
+ f"WAITING ROOM · join code: [bold cyan]{self.code}[/]\n"
246
+ f"[grey58]share:[/] typefaster lobby join {self.code}",
247
+ justify="center",
248
+ )
249
+ )
194
250
 
195
251
  def _render_field(self) -> None:
196
252
  if self.engine is not None:
@@ -201,17 +257,17 @@ class OnlineRaceScreen(Screen[None]):
201
257
  def _render_bars(self) -> None:
202
258
  text = Text()
203
259
  me_pct = self.engine.progress * 100.0 if self.engine else 0.0
204
- text.append("You ", style="bold")
205
- text.append(f"[{_bar(me_pct)}] {me_pct:3.0f}%\n", style="cyan")
260
+ text.append(f"{self.username[:9]:<9} ", style="bold cyan")
261
+ text.append(f"{_bar(me_pct)} {me_pct:3.0f}%\n", style="cyan")
206
262
  for name, st in sorted(self._opponents.items()):
207
263
  pct = st.get("progress", 0.0)
208
- text.append(f"{name[:7]:<7}", style="bold")
209
- text.append(f"[{_bar(pct)}] {pct:3.0f}%\n", style="magenta")
264
+ text.append(f"{name[:9]:<9} ", style="bold magenta")
265
+ text.append(f"{_bar(pct)} {pct:3.0f}%\n", style="magenta")
210
266
  self.query_one("#bars", Static).update(text)
211
267
 
212
268
  def _show_standings(self) -> None:
213
269
  self._typing = False
214
- table = Table(title="Final Standings", title_style="bold")
270
+ table = Table(title="Final Standings", title_style="bold", expand=True)
215
271
  table.add_column("#", justify="right")
216
272
  table.add_column("Player")
217
273
  table.add_column("WPM", justify="right")
@@ -224,10 +280,30 @@ class OnlineRaceScreen(Screen[None]):
224
280
  f"{row.get('wpm', 0):.0f}",
225
281
  f"{row.get('accuracy', 0) * 100:.0f}%",
226
282
  )
283
+ self.query_one("#net-status", Static).update(
284
+ Text("RACE COMPLETE", justify="center", style="bold green")
285
+ )
227
286
  self.query_one("#net-status", Static).display = True
228
287
  self.query_one(TypingField).display = False
229
288
  self.query_one(LiveStats).display = False
230
- self.query_one("#bars", Static).update(Group(table, Text("\nesc to leave", style="grey58")))
289
+ self.query_one("#bars", Static).update(
290
+ Group(table, Text("\nR play again · esc leave", style="grey58"))
291
+ )
292
+
293
+ def _play_again(self) -> None:
294
+ """Re-arm for another round from the results screen."""
295
+ self._phase = "lobby"
296
+ self._typing = False
297
+ self._finished = False
298
+ self.engine = None
299
+ self._start_ms = None
300
+ self._opponents = {}
301
+ self._ready = True
302
+ self.query_one(LiveStats).display = False
303
+ self.query_one(TypingField).display = False
304
+ self._status = "Ready for next race — waiting for other players…"
305
+ self._render_status()
306
+ self.run_worker(self._send("SET_READY", ready=True), name="ready")
231
307
 
232
308
  # ── exit ───────────────────────────────────────────────────────────
233
309
  def action_leave(self) -> None:
@@ -33,6 +33,10 @@ services:
33
33
  TYPEFASTER_JWT_SECRET: ${TYPEFASTER_JWT_SECRET:?set a strong secret in .env}
34
34
  TYPEFASTER_CORS_ORIGINS: ${TYPEFASTER_CORS_ORIGINS:-*}
35
35
  TYPEFASTER_ACCESS_TOKEN_MINUTES: ${TYPEFASTER_ACCESS_TOKEN_MINUTES:-1440}
36
+ # OAuth device-flow login (optional; leave blank to disable a provider)
37
+ TYPEFASTER_GITHUB_CLIENT_ID: ${TYPEFASTER_GITHUB_CLIENT_ID:-}
38
+ TYPEFASTER_GOOGLE_CLIENT_ID: ${TYPEFASTER_GOOGLE_CLIENT_ID:-}
39
+ TYPEFASTER_GOOGLE_CLIENT_SECRET: ${TYPEFASTER_GOOGLE_CLIENT_SECRET:-}
36
40
  expose:
37
41
  - "8000"
38
42
  ports: