From 3f5f2038bf722b1876dd415c080d708793e116f4 Mon Sep 17 00:00:00 2001 From: Wesley Date: Mon, 22 Jun 2026 23:27:56 +0200 Subject: [PATCH 1/4] chore(tooling): add ruff, mypy, and coverage; run them in CI Add a dev toolchain and wire it into the Tests workflow so every PR is linted, type-checked, and measured for coverage before images publish. - ruff: pycodestyle/pyflakes/isort/bugbear/comprehensions/pyupgrade, with the codebase's deliberate style (compact `if cond: return` guards, FastHTML's repeated get/post handlers, FT component calls) configured out. - mypy: scoped to the FT-free logic/data layer (FastHTML's dynamically generated tags can't be resolved from `import *`); union-attr disabled for sqlite-utils' Table|View access pattern. - coverage via pytest-cov. - .gitignore the coverage artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yaml | 10 +- .gitignore | 4 + pyproject.toml | 52 ++++++++ uv.lock | 245 ++++++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b5a6338..6044c27 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,5 +19,11 @@ jobs: - name: Install dependencies (runtime + dev) run: uv sync --frozen --group dev - - name: Run tests - run: uv run pytest + - name: Lint (ruff) + run: uv run ruff check app tests + + - name: Type-check (mypy) + run: uv run mypy + + - name: Run tests (with coverage) + run: uv run pytest --cov --cov-report=term-missing diff --git a/.gitignore b/.gitignore index d40d1e9..cd19118 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ subscriptions.db subscriptions.db.bak.* subscriptions.db-shm subscriptions.db-wal + +# Coverage artifacts +.coverage +htmlcov/ diff --git a/pyproject.toml b/pyproject.toml index 595f83b..6cda7c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,10 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=8.0", + "pytest-cov>=5.0", "httpx>=0.27", + "ruff>=0.6", + "mypy>=1.11", ] [tool.pytest.ini_options] @@ -23,3 +26,52 @@ addopts = "-q" # Put the repo root on sys.path so `app` imports under a plain `pytest` / # `uv run pytest` invocation (not just `python -m pytest`). pythonpath = ["."] + +[tool.coverage.run] +source = ["app"] +omit = ["app/styles.py"] + +[tool.coverage.report] +show_missing = true + +[tool.ruff] +target-version = "py313" +line-length = 100 +extend-exclude = ["subtracker", "subtracker-nightly"] + +[tool.ruff.lint] +# pycodestyle errors, pyflakes, import sorting, bugbear, comprehensions, pyupgrade. +select = ["E", "F", "I", "B", "C4", "UP"] +ignore = [ + "E501", # line length is handled by the formatter, not enforced here + "F403", "F405", # `from fasthtml.common import *` is the framework's intended usage + "B008", # function calls in arg defaults are fine for FT component factories + "E701", # compact `if cond: return` guards are a deliberate house style + "B026", # `f(kw=v, *args)` star-after-keyword is used by FT component calls + "B905", # not adopting explicit `zip(strict=)` across the codebase +] + +[tool.ruff.lint.per-file-ignores] +# Route modules define many same-named handlers (`get`/`post`) on one APIRouter — +# FastHTML's intended pattern, which pyflakes reads as redefinition. +"app/routes/*.py" = ["F811"] + +[tool.mypy] +python_version = "3.13" +# FastHTML builds its HTML tags (Div, H2, Form, …) dynamically, so mypy can't +# resolve names from `from fasthtml.common import *`. Type-checking is therefore +# scoped to the FT-free logic/data layer — where type bugs actually bite — and +# imported view/framework modules are followed but not reported. +files = [ + "app/auth.py", "app/authz.py", "app/config.py", "app/cost_utils.py", + "app/ratelimit.py", "app/rbac.py", "app/session.py", "app/timeutil.py", + "app/db", +] +follow_imports = "silent" +ignore_missing_imports = true +check_untyped_defs = true +warn_redundant_casts = true +warn_unused_ignores = true +# sqlite-utils types `db["table"]` as `Table | View`; the `.insert/.create/...` +# table methods are correct at runtime but trip union-attr across the data layer. +disable_error_code = ["union-attr"] diff --git a/uv.lock b/uv.lock index ed34f66..022f3b2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "anyio" @@ -74,6 +78,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/77/43b27c14865dd4204ef353b875b4251e270b2518296e90b9bda479776c58/apswutils-0.1.2-py3-none-any.whl", hash = "sha256:9cd73744f9ae83c2e6f4337d4fcb092f5ea2f1814037e9ff7d953e2bc9c8362a", size = 48171, upload-time = "2025-12-18T06:24:31.312Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + [[package]] name = "bcrypt" version = "5.0.0" @@ -195,6 +239,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/a3/3834a5564fe8f32154cd7032400d3c2f9c565b2a373fa671f2bbdad6f634/coverage-7.14.2.tar.gz", hash = "sha256:7a2da3d81cfe17c18038c6d98e6592aa9147d596d056119b0ee612c3c8bd5230", size = 923982, upload-time = "2026-06-20T14:49:30.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/09/b5b334c27960e7aac0003b96491bada7838dc641099fa64a1a598abf33cd/coverage-7.14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e5f077641a6713ce9d38df9e85d4fb9e008677fc0775cbaeb32ddfc3b319d4ca", size = 220552, upload-time = "2026-06-20T14:48:11.847Z" }, + { url = "https://files.pythonhosted.org/packages/79/20/879a000c319b4df7b50e4d688c0f7c0f6b5ac9d7b18848cbc00eabf26efe/coverage-7.14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0907f39b49ae818fe8af50aaa0f19afbc8ca164aea0865181ca7af17a3ac690b", size = 220919, upload-time = "2026-06-20T14:48:13.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/326dded4371bab60f42215797944a356e4d81a3cee106121c7f7dd531604/coverage-7.14.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734d47669118d75c28981e562d4530ceb77342d31ffef6def5edd5ad4f05d7b", size = 251917, upload-time = "2026-06-20T14:48:14.931Z" }, + { url = "https://files.pythonhosted.org/packages/eb/14/b3232ba218a0d1a70883d2675f18ff465de9e8e5e3346e81dc2b079838bd/coverage-7.14.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d9a1b5813d00ea6151f6ccf64d1fa16892771dfdda12ba87162d15ec4ea3e1e", size = 254515, upload-time = "2026-06-20T14:48:16.545Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/d77bcbee1cad71b42776574114b462225cc9125b4982f43da1b66adc850f/coverage-7.14.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f0a80f4c8ac3f774210b1cc1bc0e31e75502f2818dda9a144ff90e702c4d91d", size = 255749, upload-time = "2026-06-20T14:48:18.214Z" }, + { url = "https://files.pythonhosted.org/packages/86/86/97377937b29e9e44a1529bb20cb74dbcf80ed9006d87d7e742ff69e44b67/coverage-7.14.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e66f3f22d6c1515ce70f2e7c3e9c6f3ff0ff33480125c9f9c53e8f6508e30f", size = 257882, upload-time = "2026-06-20T14:48:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a4/0fc8fe68bc505450bb068a2823ac7797bd8495240ccb8b4a5a1da1ee7e62/coverage-7.14.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a2c37c3114f87ca7f10113756026eecb49656514debad600dcbec21f355ccea", size = 252144, upload-time = "2026-06-20T14:48:21.176Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4a/450094ddc41ab0d2eb4a0457b3856400ea3329568d1303696e85de099ae6/coverage-7.14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b16a7959d04b1497281c062c180413565c3f3469211d78799ad5b9a75f67796", size = 253882, upload-time = "2026-06-20T14:48:22.701Z" }, + { url = "https://files.pythonhosted.org/packages/d0/28/2f6ae6d98265d9aa6bac311c4a93403675905b03aca95dc4373080279d75/coverage-7.14.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6466c6999545cf00c4c142dfcbbf2db396dc735f005dcf8f91d57e351a79472b", size = 251846, upload-time = "2026-06-20T14:48:24.295Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6e/707281468400794d52874e8fb5e38ff7578a0ff32ed49fe4fe85f192d0fc/coverage-7.14.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c60915ebb8f562317ba5ff6b8c32e25c0882289b201a9f2fb2987f91efd95d8", size = 256002, upload-time = "2026-06-20T14:48:26.015Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/5e963120de4011257a950ce4cfb7fc833ddf3fee19db495268d3dec28154/coverage-7.14.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:33b830850488acbcd358c78a4fecfafe7031667b4da8ddff5546295dc962cdeb", size = 251665, upload-time = "2026-06-20T14:48:27.654Z" }, + { url = "https://files.pythonhosted.org/packages/e9/78/66b482cd525083bcc0bc894c16db79dabac37490065b53b07d6e8ab77202/coverage-7.14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d0f845539230b8269aec902bc978b0cc403f52f002d18a04492efc943404d0bc", size = 253435, upload-time = "2026-06-20T14:48:29.354Z" }, + { url = "https://files.pythonhosted.org/packages/e6/61/0663fb8cb530c8b11819b920109694eee95a3b22960a9495be0200f657f1/coverage-7.14.2-cp313-cp313-win32.whl", hash = "sha256:a8ac51a2e441e9119b9395f4d893fbc4934c64c8ba58be9b9eaa85591249e548", size = 222591, upload-time = "2026-06-20T14:48:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/a6/47/1536d2b009c2848c3682500f497053f4645e70911afe02f594000997831a/coverage-7.14.2-cp313-cp313-win_amd64.whl", hash = "sha256:039b264cdb31c44b48f9821e2afbf8f37df49e0fb837e24a942918b36c567e31", size = 223134, upload-time = "2026-06-20T14:48:32.696Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/33ba4f335dd60bb34350318283d784f46018070e67b7d4df7c910ec9d9a0/coverage-7.14.2-cp313-cp313-win_arm64.whl", hash = "sha256:7f2ef591e381cc36b8e53334e1b842c760c520c8a52d01e8626209400e93fe6a", size = 222529, upload-time = "2026-06-20T14:48:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bc/120390669817ede714ab141ae0a2a73240fd7354aac992c41dc0bd19570f/coverage-7.14.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7a0d1f026b72d627fa5c8a57cbc86ad209b64aa2a65833c83b290ace5cbee126", size = 220593, upload-time = "2026-06-20T14:48:35.755Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a3/7f1cfacd76af91e585f7ad689d7168002b444ed2a8ce59f2daaff10089b5/coverage-7.14.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4d2b86f81c1c9310a7e774e3cc9e927a3d0bf583ecbfa01498dd626930025428", size = 220925, upload-time = "2026-06-20T14:48:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/e7/10/6514b2525bb672eb8b43703e46d061d694111db21efe7609db722df2233f/coverage-7.14.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d76bdc1f9396ae70a55d050cf9743d88141c62ce0a22a3f627fab1d11c2f8bc6", size = 251974, upload-time = "2026-06-20T14:48:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/4533091541c6620ecd68115bbfa1c61265b775618adef3a5fd137f4582e9/coverage-7.14.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cda36d8e7bfd63b3e44e75163265429caa5d935b672b00f71bccc8c010518c64", size = 254479, upload-time = "2026-06-20T14:48:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/06/af/e251a143d5d106385dbca696c553afab6b69f7f6bc376a34e089cc0b8b32/coverage-7.14.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0904f3b79d7b845bef0715afe1900da634d12b97f05b9479cb472880ca07cb9c", size = 255824, upload-time = "2026-06-20T14:48:42.608Z" }, + { url = "https://files.pythonhosted.org/packages/9c/53/9e5876e60efbaa79d743d1948a5015ddc05b808db1cd62228acf83e87d43/coverage-7.14.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6795ca4198d6cb7fc2c6163214f6555a6bc5f0ae1e268e76139dec4b37c4499", size = 258139, upload-time = "2026-06-20T14:48:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/85/5a/d35a4f431fb594e46b81cad4a13b470b017e918f347c1c0b260f7494fa1e/coverage-7.14.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c41e9b60fc0fa57f5d73306417d2f9d668202cca6944f9435878c55a5e7ae213", size = 252002, upload-time = "2026-06-20T14:48:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e2/f5b304c8139c606c4f1b230d3a257d0c88edfbbdf06c58364f07625dc45c/coverage-7.14.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419d2aadd5746efc2e9df0f33c05570d8192e6f6a6098ab05acce586f44ce8a5", size = 253832, upload-time = "2026-06-20T14:48:47.582Z" }, + { url = "https://files.pythonhosted.org/packages/86/bc/bbbd283daa6be4f68aad4ad4066fd39ae98e4174db8c03ab26c5803d6234/coverage-7.14.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1c5d273c5f1411c0d26c4f066c398d4a434b1f97bb5fa409189bedce86d4add4", size = 251799, upload-time = "2026-06-20T14:48:49.42Z" }, + { url = "https://files.pythonhosted.org/packages/69/8d/0745fceb89c9e5f7dd8ed243d97dc8561b7a95545741e2409d2b34654824/coverage-7.14.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5fe465bc691264adce601527a972990c1174075d86bcbe9968fd20c95e0b1948", size = 256075, upload-time = "2026-06-20T14:48:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a0/441d9a5255cf021ab41ee00c014a4607d1c72d5e5bef0a4fdaa5be86a907/coverage-7.14.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6fbb61617af1c56f95d53170ae9fa6c9aef6de1abd02fcc50064bfc672efb18d", size = 251612, upload-time = "2026-06-20T14:48:52.653Z" }, + { url = "https://files.pythonhosted.org/packages/50/37/3d19c5e32d4a529c068eb296abfa3e455bd2c0f9311ecf26280f408ff8e0/coverage-7.14.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e1eff22b831dfd5694989cc1f0789980f18391f614ac67c851af9a8e6d25e9ba", size = 253270, upload-time = "2026-06-20T14:48:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b0/54dd13937297518da6d092cc2c39d9340ec2194bdfa92e0a64694d643e23/coverage-7.14.2-cp314-cp314-win32.whl", hash = "sha256:58e91be0a233adef698d3e6be54f10401bb91fd7854c0d4c4d50e0d3711e72f1", size = 222796, upload-time = "2026-06-20T14:48:56.084Z" }, + { url = "https://files.pythonhosted.org/packages/51/45/7a10e0909919686e335fdd95869cfb222d55243ebff27dc5cf59ca259a1f/coverage-7.14.2-cp314-cp314-win_amd64.whl", hash = "sha256:d8429bf97906bfe6c61f9dbfb3342e0d88120da61939da8bd04f830cc3eab3b8", size = 223285, upload-time = "2026-06-20T14:48:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/2e/03/9cb197eb4b3d1a2eccb2537c226a93c80522c5b8afc5dd93e1993d7bb021/coverage-7.14.2-cp314-cp314-win_arm64.whl", hash = "sha256:13609d9d77249447aa73357b14831b0f3b95f275026c9ff20dd105f981f53a0c", size = 222712, upload-time = "2026-06-20T14:48:59.413Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3c/e59f498511080d20bf866b0af9eeab820feb91547dae2084cb9bb7fb0e58/coverage-7.14.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9818486c2bac88ae931df7e04905ee29bef49fd218c00f5f02bed4855254a101", size = 221325, upload-time = "2026-06-20T14:49:01.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/37/8d7955f7e701e69198bd0a0132ea76518c078a635b930a4924e2ccfa70f0/coverage-7.14.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:58055adffabfa243516a197aa9f85f0dd56d905b0fba1a10193269759c29ccb0", size = 221594, upload-time = "2026-06-20T14:49:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/34/7a/6738e1e1533ce8ec4e2e472696eefdd4723864d7efaa140e433053bf576a/coverage-7.14.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:535747dbc200349d7fb434cffcb28e770f0290f69b225f56dc3803aa7210cdea", size = 262957, upload-time = "2026-06-20T14:49:04.829Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/d1be863cd39e0955904315fece67c5c23e046563f5eea0ceac16c547a759/coverage-7.14.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:420c66e35d85c0ca5dc6a38147d83ef239762542900e5921ebbdb89333c540ea", size = 265081, upload-time = "2026-06-20T14:49:07.018Z" }, + { url = "https://files.pythonhosted.org/packages/72/7f/412df3c3c251284a11834287fd6f7e3bb98c528c53e030589e9344a3ef80/coverage-7.14.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2cf17b33773be446a588551ea6a746b2d70dd0bc90dc31f1dd7648975a63c6b", size = 267500, upload-time = "2026-06-20T14:49:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/7d0764e83459455384d5c04179ce2d2a837bef01b9ba463079c6e8b31361/coverage-7.14.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adb4a5fef041f7179bb264203add873c147d169cf2f8d0adae89ff2e51271bac", size = 268619, upload-time = "2026-06-20T14:49:10.405Z" }, + { url = "https://files.pythonhosted.org/packages/14/68/1292164ac70cbcc86ac3982da31a6fbb42bb4bcebf6e5cf73c99cfcfd50d/coverage-7.14.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c012ec357dec9408a83dad5541172a63c5cfa1421709f2e5811480d31ae1b28", size = 262066, upload-time = "2026-06-20T14:49:12.257Z" }, + { url = "https://files.pythonhosted.org/packages/20/44/fd6fdf3f63b6e00a1a9230022d072ded5189576001685706aa6524187c65/coverage-7.14.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dacd0ecd08fda3cb2f85b60cabea7da326dcb2fc15fbb23a88830a80144cc9f2", size = 264953, upload-time = "2026-06-20T14:49:14.13Z" }, + { url = "https://files.pythonhosted.org/packages/39/29/e803fea3da89eaeb5b6b41b3ccd039fe9f3300a900e3803baac1a998529f/coverage-7.14.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f27e980f2feba5dfe7a32b22b125470de69c0bd113c75e16165de909a777f512", size = 262555, upload-time = "2026-06-20T14:49:15.803Z" }, + { url = "https://files.pythonhosted.org/packages/32/3c/b360e48ac68e3236c04cb83658382e7f5be7efbbec2e1faae3dcca432783/coverage-7.14.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:105c00efb65c863630b2b63cbf7b8267e4da2d44b62284efbb19a03b04c337d4", size = 266289, upload-time = "2026-06-20T14:49:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/59/12/1ed6d9274d599c586e2d1aa9818765dcdae6bb52aa88afa2fcd868398191/coverage-7.14.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:571173fa04c8e8d6235ab32ae67fecca97777e2e1b4a1a30f3022c34e397c1c1", size = 261402, upload-time = "2026-06-20T14:49:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/44/17/eb6cf12a4538cda937aefbeabb15377a8a30b377b484e63d31c9da790966/coverage-7.14.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e532f34d42d1a421fa00ed6b7735d14ac2e340256c1bad26a5e1dc1252b0bed7", size = 263715, upload-time = "2026-06-20T14:49:21.427Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ca/4bafdb9d372ab05d6ed3a63e7f00d3195d169d0afea00f617c026e386c19/coverage-7.14.2-cp314-cp314t-win32.whl", hash = "sha256:243971550fb46c3039257f75e65610002d84304c505f609bbd9779e20a653a0a", size = 223103, upload-time = "2026-06-20T14:49:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/35/cb/0765dbd9011d2e47315f1da31e62c5fe231f04a6ec8da213e64c4505896d/coverage-7.14.2-cp314-cp314t-win_amd64.whl", hash = "sha256:60fb0ca084a92da96474b8b405a7ea76dfecac3c68db54383e7934b6f3871169", size = 223934, upload-time = "2026-06-20T14:49:25.347Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ce/373dde027ecd0ae54511430fe7569f838d3a0376b70333ba9fd20c76b836/coverage-7.14.2-cp314-cp314t-win_arm64.whl", hash = "sha256:36a0a3f42ed7dfdbca2a69a541519ffd5064a5692152fc0018109e74370d7345", size = 223249, upload-time = "2026-06-20T14:49:27.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/a8ba14ceb014f39bd5e3f7077150718c7de61c01ce326bfe7e8eae9b19b2/coverage-7.14.2-py3-none-any.whl", hash = "sha256:04d92589e481a8b68a005a5a1e0646a91c76f322c397c4635298c57cf63699b5", size = 212325, upload-time = "2026-06-20T14:49:28.991Z" }, +] + [[package]] name = "fastcore" version = "1.13.6" @@ -338,6 +436,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, + { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, + { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, + { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, + { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, + { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -356,6 +547,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pip" version = "26.1.2" @@ -399,6 +599,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -486,6 +700,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "ruff" +version = "0.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -557,7 +796,10 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "httpx" }, + { name = "mypy" }, { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -571,7 +813,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "httpx", specifier = ">=0.27" }, + { name = "mypy", specifier = ">=1.11" }, { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = ">=0.6" }, ] [[package]] From 83fc754a47415a82175f9f6b787a367eb7754b44 Mon Sep 17 00:00:00 2001 From: Wesley Date: Mon, 22 Jun 2026 23:28:21 +0200 Subject: [PATCH 2/4] feat(security): CSRF, rate-limiting, secure cookies; Perm constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security hardening - CSRF protection (app/csrf.py): a per-session token, exposed in a tag, is auto-attached by client JS to POST forms (hidden field) and HTMX requests (X-CSRFToken header). A Beforeware ahead of the auth gate rejects any unsafe request whose token doesn't match the session — covering /login and /setup too. No per-form changes required. - Auth rate limiting (app/ratelimit.py): 10 attempts / 5 min per client on /login and /setup throttles online password guessing. - Secure cookies: SUBTRACKER_SECURE_COOKIES=1 marks the session cookie Secure and sets SameSite=strict (off by default for plain-HTTP dev). Permission constants - Extract the permission catalog into a dependency-free app/permissions.py with a Perm class (single source of truth for names). Migrate all 53 call sites from string literals to constants so a typo is an AttributeError at import time, not a silent allow/deny. Type/lint cleanup - Fix implicit-Optional annotations across the logic/data layer and a few missing var annotations so mypy is clean; apply ruff's import sorting. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/__main__.py | 2 +- app/auth.py | 3 +- app/components/__init__.py | 33 +++++++++---- app/components/charts.py | 2 +- app/components/forms.py | 4 +- app/components/layout.py | 40 ++++++++++------ app/components/widgets.py | 2 +- app/config.py | 16 +++++++ app/cost_utils.py | 17 ++++--- app/csrf.py | 96 +++++++++++++++++++++++++++++++++++++ app/db/__init__.py | 66 ++++++++++++++++++------- app/db/audit.py | 12 +++-- app/db/roles.py | 5 +- app/db/schema.py | 4 +- app/db/seed.py | 9 +++- app/db/subscriptions.py | 17 +++---- app/db/teams.py | 3 +- app/main.py | 13 +++-- app/permissions.py | 44 +++++++++++++++++ app/ratelimit.py | 56 ++++++++++++++++++++++ app/rbac.py | 47 +++++++++--------- app/routes/__init__.py | 11 ++++- app/routes/admin.py | 37 +++++++++----- app/routes/audit_routes.py | 16 +++++-- app/routes/auth_routes.py | 35 ++++++++++---- app/routes/dashboard.py | 41 ++++++++++++---- app/routes/import_export.py | 23 +++++---- app/routes/manage.py | 39 ++++++++++----- app/routes/subscriptions.py | 73 ++++++++++++++++++---------- app/routes/teams.py | 45 ++++++++++------- app/routes/users.py | 27 +++++++---- app/session.py | 2 +- 32 files changed, 623 insertions(+), 217 deletions(-) create mode 100644 app/csrf.py create mode 100644 app/permissions.py create mode 100644 app/ratelimit.py diff --git a/app/__main__.py b/app/__main__.py index 6856903..a8fd77a 100644 --- a/app/__main__.py +++ b/app/__main__.py @@ -4,8 +4,8 @@ from fasthtml.common import serve -from app.main import app # noqa: F401 (build the app) from app.config import PORT +from app.main import app # noqa: F401 (build the app) if __name__ == "__main__": serve(appname="app.main", app="app", port=PORT) diff --git a/app/auth.py b/app/auth.py index b12cc48..7aa93ed 100644 --- a/app/auth.py +++ b/app/auth.py @@ -24,7 +24,8 @@ def create_user(username: str, password: str, global_role: str = "user") -> int: "global_role": global_role, "created_at": timeutil.now_iso(), }) - return result.last_pk + assert result.last_pk is not None + return int(result.last_pk) def authenticate(username: str, password: str): diff --git a/app/components/__init__.py b/app/components/__init__.py index 6ab1647..b1626cb 100644 --- a/app/components/__init__.py +++ b/app/components/__init__.py @@ -2,23 +2,40 @@ app.components — stable import surface for all shared UI primitives. """ +from app.components.charts import bar_chart, hbar_breakdown, line_chart from app.components.fmt import ( - MONTH_LABELS, fmt_eur, category_label, truncate, json_pretty, -) -from app.components.widgets import ( - alert, badge, status_badge, action_btn, action_menu, pagination_bar, - dropdown_menu, menu_item_cls, select_menu, + MONTH_LABELS, + category_label, + fmt_eur, + json_pretty, + truncate, ) +from app.components.forms import subscription_form from app.components.layout import ( - nav_bar, page_title, section_card, collapsible_card, team_switcher, forbidden_page, + collapsible_card, + forbidden_page, + nav_bar, + page_title, + section_card, + team_switcher, +) +from app.components.widgets import ( + action_btn, + action_menu, + alert, + badge, + dropdown_menu, + menu_item_cls, + pagination_bar, + select_menu, + status_badge, ) -from app.components.charts import bar_chart, line_chart, hbar_breakdown -from app.components.forms import subscription_form __all__ = [ "MONTH_LABELS", "fmt_eur", "category_label", "truncate", "json_pretty", "alert", "badge", "status_badge", "action_btn", "action_menu", "pagination_bar", "dropdown_menu", "menu_item_cls", "select_menu", "nav_bar", "page_title", "section_card", "collapsible_card", + "team_switcher", "forbidden_page", "bar_chart", "line_chart", "hbar_breakdown", "subscription_form", ] diff --git a/app/components/charts.py b/app/components/charts.py index ebaddf0..8e257a1 100644 --- a/app/components/charts.py +++ b/app/components/charts.py @@ -4,7 +4,7 @@ """ from fasthtml.common import * -from fasthtml.svg import Svg, Rect, Line, Text, Polyline, Polygon, Circle +from fasthtml.svg import Circle, Line, Polygon, Polyline, Rect, Svg, Text # Shared SVG viewBox geometry for line_chart / bar_chart. The charts scale to their # container via `w-full` + viewBox; these are the internal coordinate units only. diff --git a/app/components/forms.py b/app/components/forms.py index 4b17ccf..8e2d84c 100644 --- a/app/components/forms.py +++ b/app/components/forms.py @@ -10,8 +10,8 @@ from app import timeutil from app.components.widgets import select_menu -from app.cost_utils import FREQUENCIES, BASE_UNITS, frequency_label -from app.styles import INPUT, TEXTAREA, LABEL, FIELD, btn +from app.cost_utils import BASE_UNITS, FREQUENCIES, frequency_label +from app.styles import FIELD, INPUT, LABEL, TEXTAREA, btn def _field(label, control): diff --git a/app/components/layout.py b/app/components/layout.py index efe0b59..1c78746 100644 --- a/app/components/layout.py +++ b/app/components/layout.py @@ -6,8 +6,15 @@ from fasthtml.common import * from app.components.widgets import alert, dropdown_menu, menu_item_cls +from app.permissions import Perm from app.styles import ( - NAV, NAV_LINK, NAV_LINK_ACTIVE, SECTION, PAGE_HEADER, btn, badge_cls, + NAV, + NAV_LINK, + NAV_LINK_ACTIVE, + PAGE_HEADER, + SECTION, + badge_cls, + btn, ) @@ -72,12 +79,12 @@ def theme_toggle() -> Button: def _nav_items(ctx) -> list: """[(label, href, key)] for the sections the current role may reach.""" items = [ - ("Dashboard", "/dashboard", "dashboard", ctx.can("subscriptions.view")), - ("Manage", "/manage", "manage", ctx.can("subscriptions.view")), - ("Audit Log", "/audit", "audit", ctx.can("audit.view")), - ("Teams", "/teams", "teams", ctx.can("teams.manage")), - ("Deleted", "/admin/deleted", "deleted", ctx.can("records.view_deleted")), - ("Users", "/users", "users", ctx.can("users.view")), + ("Dashboard", "/dashboard", "dashboard", ctx.can(Perm.SUB_VIEW)), + ("Manage", "/manage", "manage", ctx.can(Perm.SUB_VIEW)), + ("Audit Log", "/audit", "audit", ctx.can(Perm.AUDIT_VIEW)), + ("Teams", "/teams", "teams", ctx.can(Perm.TEAMS_MANAGE)), + ("Deleted", "/admin/deleted", "deleted", ctx.can(Perm.RECORDS_VIEW_DELETED)), + ("Users", "/users", "users", ctx.can(Perm.USERS_VIEW)), ] return [(lbl, href, key) for lbl, href, key, show in items if show] @@ -132,14 +139,17 @@ def nav_bar(ctx, active: str = "") -> Nav: cls="hidden md:flex items-center gap-3", ) - return Nav( - A("💳 SubTracker", href="/dashboard", cls="font-bold text-base mr-1"), - desktop_links, - Div(cls="flex-1"), - desktop_right, - Div(theme_toggle(), _mobile_menu(ctx, active, role_label), - cls="flex md:hidden items-center gap-2"), - cls=NAV, + return ( + Meta(name="csrf-token", content=ctx.csrf_token), + Nav( + A("💳 SubTracker", href="/dashboard", cls="font-bold text-base mr-1"), + desktop_links, + Div(cls="flex-1"), + desktop_right, + Div(theme_toggle(), _mobile_menu(ctx, active, role_label), + cls="flex md:hidden items-center gap-2"), + cls=NAV, + ), ) diff --git a/app/components/widgets.py b/app/components/widgets.py index b201ce3..da47d1e 100644 --- a/app/components/widgets.py +++ b/app/components/widgets.py @@ -4,7 +4,7 @@ from fasthtml.common import * -from app.styles import btn, badge_cls, ALERT +from app.styles import ALERT, badge_cls, btn # shadcn DropdownMenu content + item recipes. Show/hide is the native
# behaviour; the open animation + chevron rotation live in GLOBALS (app/styles.py), diff --git a/app/config.py b/app/config.py index 01f121a..8182aa0 100644 --- a/app/config.py +++ b/app/config.py @@ -27,3 +27,19 @@ "environment for production.", file=sys.stderr) PORT = int(os.environ.get("SUBTRACKER_PORT", "5001")) + + +def _flag(name: str, default: bool = False) -> bool: + """Read a boolean environment flag (1/true/yes/on).""" + val = os.environ.get(name) + if val is None: + return default + return val.strip().lower() in ("1", "true", "yes", "on") + + +# Cookie hardening for production behind HTTPS. When SUBTRACKER_SECURE_COOKIES is +# enabled the session cookie is marked Secure (browsers only send it over HTTPS) +# and SameSite is tightened to 'strict'. Left off by default so plain-HTTP local +# dev still works; turn it on for any internet-facing deployment. +SECURE_COOKIES = _flag("SUBTRACKER_SECURE_COOKIES", False) +SESSION_SAMESITE = "strict" if SECURE_COOKIES else "lax" diff --git a/app/cost_utils.py b/app/cost_utils.py index 99b997a..7ccf4f1 100644 --- a/app/cost_utils.py +++ b/app/cost_utils.py @@ -16,7 +16,6 @@ import calendar from datetime import date, timedelta - # ── Frequency model ────────────────────────────────────────────────────────── FREQUENCIES = ["daily", "weekly", "monthly", "quarterly", "yearly", "custom"] @@ -42,7 +41,7 @@ _UNIT_NOUN = {"daily": "day", "weekly": "week", "monthly": "month", "yearly": "year"} -def resolve(frequency: str, interval: int = 1, base_unit: str = None) -> tuple: +def resolve(frequency: str, interval: int = 1, base_unit: str | None = None) -> tuple: """Collapse (frequency, interval, base_unit) to an (effective_unit, n) pair.""" if frequency == "custom": unit = base_unit if base_unit in BASE_UNITS else "monthly" @@ -52,7 +51,7 @@ def resolve(frequency: str, interval: int = 1, base_unit: str = None) -> tuple: return frequency, 1 -def normalise_cadence(frequency: str, interval=1, base_unit: str = None) -> tuple: +def normalise_cadence(frequency: str, interval=1, base_unit: str | None = None) -> tuple: """Clean a submitted/imported (frequency, interval, base_unit) triple for storage. A named preset always means "every 1 unit" with no base_unit. "custom" keeps a @@ -74,19 +73,19 @@ def normalise_cadence(frequency: str, interval=1, base_unit: str = None) -> tupl # ── Cost normalisation ─────────────────────────────────────────────────────── def get_annual_cost(amount: float, frequency: str, interval: int = 1, - base_unit: str = None) -> float: + base_unit: str | None = None) -> float: unit, n = resolve(frequency, interval, base_unit) days_between = DAYS_PER_UNIT[unit] * n return round(amount * (365.25 / days_between), 2) def get_period_cost(amount: float, frequency: str, interval: int, - base_unit: str, period: str) -> float: + base_unit: str | None, period: str) -> float: return round(get_annual_cost(amount, frequency, interval, base_unit) / PERIOD_DIVISORS[period], 2) -def frequency_label(frequency: str, interval: int = 1, base_unit: str = None) -> str: +def frequency_label(frequency: str, interval: int = 1, base_unit: str | None = None) -> str: named = {"daily": "Daily", "weekly": "Weekly", "monthly": "Monthly", "quarterly": "Quarterly", "yearly": "Yearly"} if frequency != "custom": @@ -120,7 +119,7 @@ def _advance(d: date, unit: str, n: int) -> date: def next_payment_date(start_date_str: str, frequency: str, interval: int, - base_unit: str = None, reference: date = None) -> date: + base_unit: str | None = None, reference: date | None = None) -> date: """Return the next payment date >= reference (defaults to today).""" ref = reference or date.today() unit, n = resolve(frequency, interval, base_unit) @@ -133,13 +132,13 @@ def next_payment_date(start_date_str: str, frequency: str, interval: int, def upcoming_payments_for_periods(sub: dict, periods: list, count: int = 6, - reference: date = None) -> list: + reference: date | None = None) -> list: """ Return up to `count` upcoming [{date, amount}] payments at/after `reference`, walking each period's cadence (anchored at its start) and clamped to its end. """ ref = reference or date.today() - out = [] + out: list = [] freq, interval, base_unit = sub["frequency"], sub.get("interval") or 1, sub.get("base_unit") for p in sorted(periods, key=lambda x: x["start_date"]): pe = date.fromisoformat(p["end_date"]) if p.get("end_date") else None diff --git a/app/csrf.py b/app/csrf.py new file mode 100644 index 0000000..192812d --- /dev/null +++ b/app/csrf.py @@ -0,0 +1,96 @@ +""" +csrf.py — Cross-site request forgery protection. + +Strategy (synchroniser-token, scoped to the signed session): + * a per-session random token is minted on first render and stored in the + (signed, HttpOnly) session cookie via `ensure_token`; + * every rendered page exposes it in a tag (see + `csrf_meta`, emitted by the nav bar and the login/setup pages); + * client JS (CSRF_JS, injected globally in app.main) copies that token into a + hidden `csrf_token` field on every POST form submit and into the + `X-CSRFToken` header on every HTMX request — so no individual form or button + needs to know about CSRF; + * `csrf_guard` (a Beforeware running ahead of the auth gate) rejects any unsafe + request whose submitted token does not match the session token. + +Because the token lives in the signed session cookie, an attacker on another +origin can neither read it nor forge a request carrying the matching value. +""" + +import secrets + +from fasthtml.common import Meta, Response, Script + +SESSION_KEY = "_csrf" +FIELD_NAME = "csrf_token" +HEADER_NAME = "x-csrftoken" +SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"}) + +# Static-asset paths the CSRF gate never needs to run on. Unlike the auth gate it +# deliberately does NOT skip /login or /setup: those POSTs must be protected too. +ASSET_SKIP = [r"/[^/]*\.(css|js|ico|png|jpe?g|svg|woff2?|map|txt)", r"/favicon\.ico"] + + +def ensure_token(session: dict) -> str: + """Return the session's CSRF token, minting and storing one if absent.""" + token = session.get(SESSION_KEY) + if not token: + token = secrets.token_urlsafe(32) + session[SESSION_KEY] = token + return token + + +def csrf_meta(session: dict) -> Meta: + """ tag carrying the session CSRF token for the client JS to read.""" + return Meta(name="csrf-token", content=ensure_token(session)) + + +async def csrf_guard(req, session): + """ + Beforeware: reject unsafe requests lacking a valid CSRF token. + + Returns a 403 Response on failure (which short-circuits the request) or None + to let the request proceed. + """ + if req.method in SAFE_METHODS: + return None + expected = session.get(SESSION_KEY) + sent = req.headers.get(HEADER_NAME) + if not sent: + # Form was already parsed (and cached) by FastHTML before this runs. + form = await req.form() + sent = form.get(FIELD_NAME) + if not (expected and sent and secrets.compare_digest(str(sent), str(expected))): + return Response("CSRF validation failed. Reload the page and try again.", + status_code=403) + return None + + +# Injected into every page (app.main hdrs). Auto-attaches the token to native POST +# form submits and to HTMX requests, reading it from the tag, so handlers +# and templates stay CSRF-unaware. +CSRF_JS = Script(""" +(function () { + function token() { + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute('content') : null; + } + // Native form POSTs: inject a hidden field just before submission. + document.addEventListener('submit', function (e) { + var form = e.target; + if (!form || !form.matches || !form.matches('form')) return; + if ((form.getAttribute('method') || 'get').toLowerCase() !== 'post') return; + if (form.querySelector('input[name="csrf_token"]')) return; + var t = token(); + if (!t) return; + var i = document.createElement('input'); + i.type = 'hidden'; i.name = 'csrf_token'; i.value = t; + form.appendChild(i); + }, true); + // HTMX requests: add the header for every non-GET issued by hx-* attributes. + document.addEventListener('htmx:configRequest', function (e) { + var t = token(); + if (t) e.detail.headers['X-CSRFToken'] = t; + }); +})(); +""") diff --git a/app/db/__init__.py b/app/db/__init__.py index 3e723ee..0ac30c1 100644 --- a/app/db/__init__.py +++ b/app/db/__init__.py @@ -4,28 +4,60 @@ Route modules import from `app.db` so internal file moves stay invisible to handlers. """ -from app.db.connection import get_db, rows_as_dicts, one -from app.db.schema import init_db, has_any_users -from app.db.users import ( - get_user_by_username, get_user_by_id, get_all_users, username_taken, - count_super_admins, set_global_role, soft_delete_user, +from app.db.audit import ( + audit, + get_audit_for_entity, + get_audit_log, + write_audit_log, +) +from app.db.connection import get_db, one, rows_as_dicts +from app.db.roles import ( + list_permissions, + list_roles, + permissions_for_role, + role_matrix, + set_role_permission, ) +from app.db.schema import has_any_users, init_db from app.db.subscriptions import ( - get_subscription, get_active_subscriptions, get_all_subscriptions, - get_categories, get_periods, get_periods_map, is_active_on, current_price, - upcoming_price_change, validate_periods, add_period, update_period, delete_period, - restore_subscription, purge_subscription, + add_period, + current_price, + delete_period, + get_active_subscriptions, + get_all_subscriptions, + get_categories, + get_periods, + get_periods_map, + get_subscription, + is_active_on, + purge_subscription, + restore_subscription, + upcoming_price_change, + update_period, + validate_periods, ) from app.db.teams import ( - get_team, list_all_teams, list_user_teams, get_membership, list_team_members, - count_team_admins, member_count, create_team, add_member, set_member_role, - remove_member, get_membership_by_id, + add_member, + count_team_admins, + create_team, + get_membership, + get_membership_by_id, + get_team, + list_all_teams, + list_team_members, + list_user_teams, + member_count, + remove_member, + set_member_role, ) -from app.db.roles import ( - permissions_for_role, list_roles, list_permissions, role_matrix, set_role_permission, -) -from app.db.audit import ( - write_audit_log, audit, get_audit_for_entity, get_audit_log, +from app.db.users import ( + count_super_admins, + get_all_users, + get_user_by_id, + get_user_by_username, + set_global_role, + soft_delete_user, + username_taken, ) __all__ = [ diff --git a/app/db/audit.py b/app/db/audit.py index b6bfd4b..6685d58 100644 --- a/app/db/audit.py +++ b/app/db/audit.py @@ -10,13 +10,15 @@ from app import timeutil from app.db.connection import get_db, rows_as_dicts +from app.permissions import Perm def write_audit_log(actor_user_id: int, actor_name: str, action: str, entity_type: str, entity_id: int, entity_name: str, description: str, old_values=None, new_values=None, - actor_global_role: str = None, - team_id: int = None, team_name: str = None, db=None) -> None: + actor_global_role: str | None = None, + team_id: int | None = None, team_name: str | None = None, + db=None) -> None: # Reuse the caller's connection when given one (e.g. a bulk import loop) instead # of opening a fresh SQLite connection per audited row. (db or get_db())["audit_log"].insert({ @@ -38,7 +40,7 @@ def write_audit_log(actor_user_id: int, actor_name: str, action: str, def audit(ctx, action: str, entity_type: str, entity_id: int, entity_name: str, description: str, old_values=None, new_values=None, - team_id: int = None, team_name: str = None, db=None) -> None: + team_id: int | None = None, team_name: str | None = None, db=None) -> None: """Convenience wrapper that snapshots actor + team from the request context. Pass `db` to reuse an open connection (e.g. inside a bulk-import loop).""" @@ -56,14 +58,14 @@ def get_audit_for_entity(db, entity_id: int, entity_type: str) -> list: "ORDER BY timestamp DESC", [entity_id, entity_type]) -def get_audit_log(db, ctx, action_filter: str = None, +def get_audit_log(db, ctx, action_filter: str | None = None, page: int = 1, per_page: int = 25) -> tuple: """ With audit.view: the active team's entries (all teams in super-admin view-all). Without it: only the caller's own actions. """ where, params = [], [] - if "audit.view" in ctx.perms: + if Perm.AUDIT_VIEW in ctx.perms: if not (ctx.view_all and ctx.is_super): where.append("team_id = ?") params.append(ctx.active_team_id) diff --git a/app/db/roles.py b/app/db/roles.py index 0bc5d06..2f0d005 100644 --- a/app/db/roles.py +++ b/app/db/roles.py @@ -2,7 +2,6 @@ roles.py — Queries over the DB-driven roles / permissions / role_permissions tables. """ -from app.db.connection import rows_as_dicts def permissions_for_role(db, role_name: str) -> set: @@ -10,7 +9,7 @@ def permissions_for_role(db, role_name: str) -> set: for r in db["role_permissions"].rows_where("role_name = ?", [role_name])} -def list_roles(db, scope: str = None) -> list: +def list_roles(db, scope: str | None = None) -> list: where = "scope = ?" if scope else None args = [scope] if scope else None return list(db["roles"].rows_where(where, args, order_by="rank DESC")) @@ -22,7 +21,7 @@ def list_permissions(db) -> list: def role_matrix(db) -> dict: """{role_name: set(permission_name)} across every role.""" - m = {} + m: dict[str, set] = {} for r in db["role_permissions"].rows: m.setdefault(r["role_name"], set()).add(r["permission_name"]) return m diff --git a/app/db/schema.py b/app/db/schema.py index bf8e377..a999939 100644 --- a/app/db/schema.py +++ b/app/db/schema.py @@ -45,7 +45,7 @@ def _migrate_to_periods(db) -> None: "subscription_id = ?", [sub["id"]]), key=lambda h: (h["valid_from"], h["id"])) - def price_at(d: str) -> float: + def price_at(d: str, base_amount=base_amount, history=history) -> float: active = base_amount for h in history: if h["valid_from"] <= d: @@ -57,7 +57,7 @@ def price_at(d: str) -> float: if h["valid_from"] > start and (end is None or h["valid_from"] <= end)}) seg_starts = [start] + bps - periods = [] + periods: list[dict] = [] for i, ss in enumerate(seg_starts): se = (date.fromisoformat(seg_starts[i + 1]) - timedelta(days=1)).isoformat() \ if i + 1 < len(seg_starts) else end diff --git a/app/db/seed.py b/app/db/seed.py index 90a15ba..2d27c6c 100644 --- a/app/db/seed.py +++ b/app/db/seed.py @@ -7,8 +7,13 @@ """ from app.rbac import ( - PERMISSIONS, GLOBAL_ROLES, TEAM_ROLES, ROLE_PERMISSIONS, - CANONICAL_ROLE_NAMES, GLOBAL_ROLE_NAMES, TEAM_ROLE_NAMES, + CANONICAL_ROLE_NAMES, + GLOBAL_ROLE_NAMES, + GLOBAL_ROLES, + PERMISSIONS, + ROLE_PERMISSIONS, + TEAM_ROLE_NAMES, + TEAM_ROLES, ) diff --git a/app/db/subscriptions.py b/app/db/subscriptions.py index bbb30dd..c3481f9 100644 --- a/app/db/subscriptions.py +++ b/app/db/subscriptions.py @@ -16,6 +16,7 @@ from app import timeutil from app.db.connection import rows_as_dicts +from app.permissions import Perm def _team_clause(ctx) -> tuple: @@ -36,17 +37,17 @@ def _team_clause(ctx) -> tuple: def get_subscription(db, ctx, sub_id: int, include_deleted: bool = False): tc, tp = _team_clause(ctx) where = f"id = ? AND {tc}" - if not (include_deleted and ctx.can("records.view_deleted")): + if not (include_deleted and ctx.can(Perm.RECORDS_VIEW_DELETED)): where += " AND deleted_at IS NULL" rows = list(db["subscriptions"].rows_where(where, [sub_id] + tp)) return rows[0] if rows else None -def get_all_subscriptions(db, ctx, filter_active: str = None, search: str = None, - category: str = None, only_deleted: bool = False) -> list: +def get_all_subscriptions(db, ctx, filter_active: str | None = None, search: str | None = None, + category: str | None = None, only_deleted: bool = False) -> list: tc, tp = _team_clause(ctx) where, params = [tc], list(tp) - if only_deleted and ctx.can("records.view_deleted"): + if only_deleted and ctx.can(Perm.RECORDS_VIEW_DELETED): where.append("deleted_at IS NOT NULL") else: where.append("deleted_at IS NULL") @@ -100,7 +101,7 @@ def get_periods(db, subscription_id: int) -> list: def get_periods_map(db, subscription_ids: list) -> dict: """{subscription_id: [periods]} for many subscriptions in one query (avoids N+1).""" - out = {sid: [] for sid in subscription_ids} + out: dict[int, list] = {sid: [] for sid in subscription_ids} if not subscription_ids: return out placeholders = ",".join("?" * len(subscription_ids)) @@ -113,14 +114,14 @@ def get_periods_map(db, subscription_ids: list) -> dict: return out -def is_active_on(periods: list, reference_date: str = None) -> bool: +def is_active_on(periods: list, reference_date: str | None = None) -> bool: """True if any period covers the reference date (defaults to today).""" ref = reference_date or timeutil.today_iso() return any(p["start_date"] <= ref and (p["end_date"] is None or p["end_date"] >= ref) for p in periods) -def upcoming_price_change(periods: list, reference_date: str = None): +def upcoming_price_change(periods: list, reference_date: str | None = None): """ The next scheduled price change relative to the reference date (default today): the earliest future period whose amount differs from the current price. @@ -137,7 +138,7 @@ def upcoming_price_change(periods: list, reference_date: str = None): return None -def current_price(periods: list, reference_date: str = None): +def current_price(periods: list, reference_date: str | None = None): """ Price in effect at the reference date: the covering period's amount, else the most recent already-started period, else the earliest period. None if no periods. diff --git a/app/db/teams.py b/app/db/teams.py index d3ec4ee..821914e 100644 --- a/app/db/teams.py +++ b/app/db/teams.py @@ -5,8 +5,7 @@ import re from app import timeutil -from app.db.connection import rows_as_dicts, one - +from app.db.connection import one, rows_as_dicts # ── Reads ──────────────────────────────────────────────────────────────────── diff --git a/app/main.py b/app/main.py index 5e1a5e2..961eda1 100644 --- a/app/main.py +++ b/app/main.py @@ -12,11 +12,12 @@ from fasthtml.common import * -from app.config import SECRET_KEY -from app.styles import GLOBALS +from app.config import SECRET_KEY, SECURE_COOKIES, SESSION_SAMESITE +from app.csrf import ASSET_SKIP, CSRF_JS, csrf_guard from app.db import init_db from app.routes import ALL_ROUTERS -from app.session import load_ctx, SKIP +from app.session import SKIP, load_ctx +from app.styles import GLOBALS # Tailwind (Play CDN, v3) configured to consume shadcn's HSL tokens. Opacity # modifiers (bg-primary/90, hover:bg-muted/50, …) work because the tokens are HSL. @@ -143,9 +144,13 @@ Script(src="https://cdn.tailwindcss.com"), Script(TAILWIND_CONFIG), Script(DROPDOWN_JS), + CSRF_JS, Style(GLOBALS, type="text/tailwindcss"), ), - before=Beforeware(load_ctx, skip=SKIP), + # CSRF runs first (and on /login, /setup too); the auth gate runs second. + before=(Beforeware(csrf_guard, skip=ASSET_SKIP), Beforeware(load_ctx, skip=SKIP)), + same_site=SESSION_SAMESITE, + sess_https_only=SECURE_COOKIES, ) # Register all route modules onto the single app instance. diff --git a/app/permissions.py b/app/permissions.py new file mode 100644 index 0000000..c08ef08 --- /dev/null +++ b/app/permissions.py @@ -0,0 +1,44 @@ +""" +permissions.py — The permission catalog: names, labels, categories. + +Kept dependency-free (imports nothing from `app`) so any layer — routes, data +helpers, the RBAC resolver — can reference `Perm` without risking a circular +import. `Perm` is the single source of truth for permission NAMES: code uses the +constants (a typo becomes an AttributeError at import time rather than a silent +allow/deny), while the string VALUES stay stable for the DB role matrix. +""" + + +class Perm: + SUB_VIEW = "subscriptions.view" + SUB_CREATE = "subscriptions.create" + SUB_EDIT = "subscriptions.edit" + SUB_DELETE = "subscriptions.delete" + SUB_DELETE_PERMANENT = "subscriptions.delete.permanent" + RECORDS_RESTORE = "records.restore" + RECORDS_VIEW_DELETED = "records.view_deleted" + TEAMS_VIEW = "teams.view" + TEAMS_MANAGE = "teams.manage" + USERS_VIEW = "users.view" + USERS_MANAGE = "users.manage" + AUDIT_VIEW = "audit.view" + SETTINGS_MANAGE = "settings.manage" + + +# (name, label, category) for each permission, in display order. +PERMISSIONS = [ + (Perm.SUB_VIEW, "View subscriptions", "Subscriptions"), + (Perm.SUB_CREATE, "Create subscriptions", "Subscriptions"), + (Perm.SUB_EDIT, "Edit subscriptions", "Subscriptions"), + (Perm.SUB_DELETE, "Delete (soft) subscriptions", "Subscriptions"), + (Perm.SUB_DELETE_PERMANENT, "Permanently delete records", "Subscriptions"), + (Perm.RECORDS_RESTORE, "Restore deleted records", "Records"), + (Perm.RECORDS_VIEW_DELETED, "View deleted records", "Records"), + (Perm.TEAMS_VIEW, "View / switch teams", "Teams"), + (Perm.TEAMS_MANAGE, "Manage teams & members", "Teams"), + (Perm.USERS_VIEW, "View users", "Users"), + (Perm.USERS_MANAGE, "Manage users & global roles", "Users"), + (Perm.AUDIT_VIEW, "View team audit log", "Audit"), + (Perm.SETTINGS_MANAGE, "Manage settings & role matrix", "Settings"), +] +ALL_PERMISSIONS = [p[0] for p in PERMISSIONS] diff --git a/app/ratelimit.py b/app/ratelimit.py new file mode 100644 index 0000000..6fa41ef --- /dev/null +++ b/app/ratelimit.py @@ -0,0 +1,56 @@ +""" +ratelimit.py — Minimal in-memory fixed-window rate limiting for auth endpoints. + +Guards /login and /setup against online brute-forcing. State is per-process (a +plain dict of monotonic timestamps), which is the right scope for this app's +single-worker deployment; it intentionally adds no external dependency. Restarting +the process clears the counters, and a multi-worker deployment would need a shared +store — both acceptable trade-offs for a self-hosted tool. +""" + +import time +from collections import defaultdict, deque + + +class RateLimiter: + """Allow at most `max_attempts` recorded hits per key within `window` seconds.""" + + def __init__(self, max_attempts: int, window_seconds: float): + self.max_attempts = max_attempts + self.window = window_seconds + self._hits: dict[str, deque] = defaultdict(deque) + + def _prune(self, key: str, now: float) -> deque: + dq = self._hits[key] + while dq and dq[0] <= now - self.window: + dq.popleft() + return dq + + def is_limited(self, key: str) -> bool: + """True if `key` has already used up its allowance for the current window.""" + return len(self._prune(key, time.monotonic())) >= self.max_attempts + + def record(self, key: str) -> None: + """Record one attempt against `key`.""" + now = time.monotonic() + self._prune(key, now).append(now) + + def reset(self, key: str | None = None) -> None: + """Clear one key, or all keys when `key` is None (used by tests).""" + if key is None: + self._hits.clear() + else: + self._hits.pop(key, None) + + +# Auth limiter: 10 attempts per 5 minutes per client. Generous enough that a human +# fat-fingering a password is unaffected, tight enough to throttle a script. +login_limiter = RateLimiter(max_attempts=10, window_seconds=300) + + +def client_key(req) -> str: + """Best-effort client identity for rate-limiting (proxy-aware).""" + fwd = req.headers.get("x-forwarded-for") + if fwd: + return fwd.split(",")[0].strip() + return req.client.host if req.client else "unknown" diff --git a/app/rbac.py b/app/rbac.py index 0f76ed7..8140066 100644 --- a/app/rbac.py +++ b/app/rbac.py @@ -15,28 +15,22 @@ Permission STRINGS are fixed in code because enforcement references them. """ +from app.csrf import ensure_token from app.db.roles import permissions_for_role from app.db.teams import get_membership, get_team, list_all_teams, list_user_teams - -# ── Permission catalog (name, label, category) ─────────────────────────────── - -PERMISSIONS = [ - ("subscriptions.view", "View subscriptions", "Subscriptions"), - ("subscriptions.create", "Create subscriptions", "Subscriptions"), - ("subscriptions.edit", "Edit subscriptions", "Subscriptions"), - ("subscriptions.delete", "Delete (soft) subscriptions", "Subscriptions"), - ("subscriptions.delete.permanent","Permanently delete records", "Subscriptions"), - ("records.restore", "Restore deleted records", "Records"), - ("records.view_deleted", "View deleted records", "Records"), - ("teams.view", "View / switch teams", "Teams"), - ("teams.manage", "Manage teams & members", "Teams"), - ("users.view", "View users", "Users"), - ("users.manage", "Manage users & global roles", "Users"), - ("audit.view", "View team audit log", "Audit"), - ("settings.manage", "Manage settings & role matrix", "Settings"), +# Re-exported so existing `from app.rbac import Perm, PERMISSIONS, ALL_PERMISSIONS` +# imports keep working; the catalog itself lives in the dependency-free module. +from app.permissions import ALL_PERMISSIONS, PERMISSIONS, Perm + +# `PERMISSIONS` is consumed by importers (e.g. app.db.seed), not by this module — +# declaring it here marks the re-export as intentional. +__all__ = [ + "ALL_PERMISSIONS", "PERMISSIONS", "Perm", "ROLE_PERMISSIONS", + "GLOBAL_ROLES", "TEAM_ROLES", "GLOBAL_ROLE_NAMES", "TEAM_ROLE_NAMES", + "CANONICAL_ROLE_NAMES", "BASELINE_GLOBAL_ROLE", "Ctx", "build_ctx", + "resolve_permissions", "can_access_team", "global_role_rank", ] -ALL_PERMISSIONS = [p[0] for p in PERMISSIONS] # ── Roles (name, label, rank) ──────────────────────────────────────────────── # Exactly three assignable roles exist, with fixed (non-editable) permission sets: @@ -65,14 +59,14 @@ def global_role_rank(role: str) -> int: # ── Fixed role → permission sets (source of truth for seeding) ─────────────── -_VIEWER = {"subscriptions.view", "teams.view"} +_VIEWER = {Perm.SUB_VIEW, Perm.TEAMS_VIEW} ROLE_PERMISSIONS = { "super_admin": set(ALL_PERMISSIONS), - "team_admin": _VIEWER | {"subscriptions.create", "subscriptions.edit", - "subscriptions.delete", "subscriptions.delete.permanent", - "records.restore", "records.view_deleted", - "teams.manage", "audit.view"}, + "team_admin": _VIEWER | {Perm.SUB_CREATE, Perm.SUB_EDIT, + Perm.SUB_DELETE, Perm.SUB_DELETE_PERMANENT, + Perm.RECORDS_RESTORE, Perm.RECORDS_VIEW_DELETED, + Perm.TEAMS_MANAGE, Perm.AUDIT_VIEW}, "viewer": _VIEWER, } @@ -82,13 +76,15 @@ def global_role_rank(role: str) -> int: class Ctx: """Per-request identity + authorization context, built by the Beforeware.""" - def __init__(self, user, active_team_id, active_team_name, teams, perms, view_all): + def __init__(self, user, active_team_id, active_team_name, teams, perms, view_all, + csrf_token=""): self.user = user # users row (dict) self.active_team_id = active_team_id # int | None self.active_team_name = active_team_name self.teams = teams # [{id, name, (team_role)}] the user may switch among self.perms = perms # set[str] self.view_all = view_all # super_admin cross-team mode + self.csrf_token = csrf_token # per-session CSRF token (for ) @property def username(self): @@ -142,7 +138,8 @@ def build_ctx(db, user: dict, session: dict) -> Ctx: active_name = next((t["name"] for t in teams if t["id"] == active), None) perms = resolve_permissions(db, user, active, view_all) - return Ctx(user, active, active_name, teams, perms, view_all) + return Ctx(user, active, active_name, teams, perms, view_all, + csrf_token=ensure_token(session)) def can_access_team(db, user: dict, team_id: int) -> bool: diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 4541c12..2d23d30 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -3,8 +3,15 @@ """ from app.routes import ( - auth_routes, dashboard, manage, subscriptions, import_export, audit_routes, - users, teams, admin, + admin, + audit_routes, + auth_routes, + dashboard, + import_export, + manage, + subscriptions, + teams, + users, ) # Order is cosmetic; route paths are unique across modules. diff --git a/app/routes/admin.py b/app/routes/admin.py index fd2791e..cc5a845 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -5,16 +5,29 @@ from fasthtml.common import * -from app.db import ( - get_db, get_all_subscriptions, get_subscription, get_all_users, - get_periods, get_periods_map, current_price, - restore_subscription, purge_subscription, audit, -) from app.authz import require from app.components import ( - page_title, nav_bar, alert, badge, fmt_eur, category_label, + alert, + badge, + category_label, + fmt_eur, + nav_bar, + page_title, +) +from app.db import ( + audit, + current_price, + get_all_subscriptions, + get_all_users, + get_db, + get_periods, + get_periods_map, + get_subscription, + purge_subscription, + restore_subscription, ) -from app.styles import PAGE_HEADER, TABLE, MUTED, btn +from app.permissions import Perm +from app.styles import MUTED, PAGE_HEADER, TABLE, btn ar = APIRouter() @@ -24,13 +37,13 @@ @ar("/admin/deleted") def get(req, session, msg: str = "", msg_kind: str = "warning"): ctx = req.scope["ctx"] - if (r := require(ctx, "records.view_deleted")): return r + if (r := require(ctx, Perm.RECORDS_VIEW_DELETED)): return r db = get_db() deleted = get_all_subscriptions(db, ctx, only_deleted=True) periods_map = get_periods_map(db, [s["id"] for s in deleted]) user_names = {u["id"]: u["username"] for u in get_all_users(db)} - can_restore = ctx.can("records.restore") - can_purge = ctx.can("subscriptions.delete.permanent") + can_restore = ctx.can(Perm.RECORDS_RESTORE) + can_purge = ctx.can(Perm.SUB_DELETE_PERMANENT) def actions(s): btns = [] @@ -80,7 +93,7 @@ def actions(s): @ar("/admin/deleted/subscription/{sub_id}/restore") async def post(req, session, sub_id: int): ctx = req.scope["ctx"] - if (r := require(ctx, "records.restore")): return r + if (r := require(ctx, Perm.RECORDS_RESTORE)): return r db = get_db() sub = get_subscription(db, ctx, sub_id, include_deleted=True) if not sub or sub.get("deleted_at") is None: @@ -95,7 +108,7 @@ async def post(req, session, sub_id: int): @ar("/admin/deleted/subscription/{sub_id}/purge") async def post(req, session, sub_id: int): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.delete.permanent")): return r + if (r := require(ctx, Perm.SUB_DELETE_PERMANENT)): return r db = get_db() sub = get_subscription(db, ctx, sub_id, include_deleted=True) if not sub: diff --git a/app/routes/audit_routes.py b/app/routes/audit_routes.py index 04cd5dd..df42188 100644 --- a/app/routes/audit_routes.py +++ b/app/routes/audit_routes.py @@ -4,12 +4,18 @@ from fasthtml.common import * -from app.db import get_db, get_audit_log from app.authz import require from app.components import ( - page_title, nav_bar, alert, badge, pagination_bar, json_pretty, select_menu, + badge, + json_pretty, + nav_bar, + page_title, + pagination_bar, + select_menu, ) -from app.styles import PAGE_HEADER, TABLE, MUTED, btn +from app.db import get_audit_log, get_db +from app.permissions import Perm +from app.styles import MUTED, PAGE_HEADER, TABLE, btn ar = APIRouter() @@ -17,7 +23,7 @@ @ar("/audit") def get(req, session, action_filter: str = "", page: int = 1): ctx = req.scope["ctx"] - if (r := require(ctx, "audit.view")): return r + if (r := require(ctx, Perm.AUDIT_VIEW)): return r db = get_db() entries, total = get_audit_log(db, ctx, @@ -26,7 +32,7 @@ def get(req, session, action_filter: str = "", page: int = 1): actions = ["LOGIN", "LOGOUT", "CREATE", "UPDATE", "DELETE", "PRICE_CHANGE", "RESTORE", "PERMANENT_DELETE", "ROLE_CHANGE", "TEAM_CREATE", "MEMBER_ADD"] scope_note = ("all teams" if (ctx.view_all and ctx.is_super) else - (f"team: {ctx.active_team_name}" if ctx.can("audit.view") and ctx.active_team_name + (f"team: {ctx.active_team_name}" if ctx.can(Perm.AUDIT_VIEW) and ctx.active_team_name else "your actions")) filter_bar = Form( diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py index 7483b91..df28819 100644 --- a/app/routes/auth_routes.py +++ b/app/routes/auth_routes.py @@ -2,18 +2,29 @@ auth_routes.py — First-run setup, login, logout. """ +from urllib.parse import quote_plus + from fasthtml.common import * +from app.auth import authenticate, create_user +from app.components import alert, page_title +from app.csrf import csrf_meta from app.db import ( - init_db, has_any_users, get_user_by_id, get_db, - create_team, add_member, write_audit_log, + add_member, + create_team, + get_db, + get_user_by_id, + has_any_users, + init_db, + write_audit_log, ) -from app.auth import authenticate, create_user -from app.components import page_title, alert -from app.styles import INPUT, CARD, FIELD, btn +from app.ratelimit import client_key, login_limiter +from app.styles import CARD, FIELD, INPUT, btn ar = APIRouter() +_RATE_LIMIT_MSG = "Too many attempts. Wait a few minutes and try again." + # ── First-run setup (create initial admin) ─────────────────────────────────── @@ -22,7 +33,7 @@ def get(session, error: str = ""): db = init_db() if has_any_users(db): return RedirectResponse("/login", status_code=303) - return page_title("Setup"), Titled("Welcome to SubTracker", + return page_title("Setup"), csrf_meta(session), Titled("Welcome to SubTracker", Div( H2("Create your admin account", cls="mb-1"), alert(error, "error") if error else "", @@ -44,10 +55,13 @@ def get(session, error: str = ""): @ar("/setup") -async def post(session, username: str, password: str, password2: str): +async def post(req, session, username: str, password: str, password2: str): db = init_db() if has_any_users(db): return RedirectResponse("/login", status_code=303) + if login_limiter.is_limited(client_key(req)): + return RedirectResponse(f"/setup?error={quote_plus(_RATE_LIMIT_MSG)}", status_code=303) + login_limiter.record(client_key(req)) if not username.strip(): return RedirectResponse("/setup?error=Username+cannot+be+empty", status_code=303) if password != password2: @@ -79,7 +93,7 @@ def get(session, error: str = ""): db = init_db() if not has_any_users(db): return RedirectResponse("/setup", status_code=303) - return page_title("Login"), Titled("SubTracker", + return page_title("Login"), csrf_meta(session), Titled("SubTracker", Div( H2("Sign In", cls="mb-3"), alert(error, "error") if error else "", @@ -97,7 +111,10 @@ def get(session, error: str = ""): @ar("/login") -async def post(session, username: str, password: str): +async def post(req, session, username: str, password: str): + if login_limiter.is_limited(client_key(req)): + return RedirectResponse(f"/login?error={quote_plus(_RATE_LIMIT_MSG)}", status_code=303) + login_limiter.record(client_key(req)) user = authenticate(username, password) if not user: return RedirectResponse("/login?error=Invalid+username+or+password", status_code=303) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 238e28b..bb5544e 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -11,20 +11,41 @@ from fasthtml.common import * from app import timeutil -from app.db import ( - get_db, get_all_subscriptions, get_periods_map, is_active_on, current_price, -) from app.authz import require +from app.components import ( + MONTH_LABELS, + badge, + bar_chart, + category_label, + fmt_eur, + hbar_breakdown, + nav_bar, + page_title, + section_card, +) from app.cost_utils import ( - year_cost, monthly_costs_for_year, frequency_label, get_annual_cost, + frequency_label, + get_annual_cost, + monthly_costs_for_year, + year_cost, ) -from app.components import ( - page_title, nav_bar, section_card, badge, fmt_eur, category_label, - bar_chart, hbar_breakdown, MONTH_LABELS, +from app.db import ( + current_price, + get_all_subscriptions, + get_db, + get_periods_map, + is_active_on, ) +from app.permissions import Perm from app.styles import ( - PAGE_HEADER, COST_CARD, COST_CARDS, COST_LABEL, COST_AMOUNT, - CHARTS_GRID, LINK, btn, + CHARTS_GRID, + COST_AMOUNT, + COST_CARD, + COST_CARDS, + COST_LABEL, + LINK, + PAGE_HEADER, + btn, ) ar = APIRouter() @@ -123,7 +144,7 @@ def _yoy_badge(cur: float, prev: float, prev_year: int): @ar("/dashboard") def get(req, session, year: int = None): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.view")): return r + if (r := require(ctx, Perm.SUB_VIEW)): return r db = get_db() current_year = timeutil.today().year diff --git a/app/routes/import_export.py b/app/routes/import_export.py index bb241f8..428d933 100644 --- a/app/routes/import_export.py +++ b/app/routes/import_export.py @@ -20,13 +20,18 @@ from fasthtml.common import * from app import timeutil -from app.db import ( - get_db, get_all_subscriptions, get_periods_map, add_period, audit, -) from app.authz import require, writable_team +from app.components import alert, nav_bar, page_title, section_card from app.cost_utils import normalise_cadence -from app.components import page_title, nav_bar, section_card, alert -from app.styles import PAGE_HEADER, LINK, INPUT, MUTED_SM, btn +from app.db import ( + add_period, + audit, + get_all_subscriptions, + get_db, + get_periods_map, +) +from app.permissions import Perm +from app.styles import LINK, MUTED_SM, PAGE_HEADER, btn ar = APIRouter() @@ -109,7 +114,7 @@ def _to_json(subs, periods_map) -> str: @ar("/export") def get(req, session, fmt: str = "csv"): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.view")): return r + if (r := require(ctx, Perm.SUB_VIEW)): return r db = get_db() subs, periods_map = _collect(db, ctx) # Filename-safe local timestamp, e.g. 2026-06-18_143005 (no colons). @@ -254,7 +259,7 @@ def _result_block(created: int, periods_added: int, errors: list): def _page(ctx, result=None): - can_import = ctx.can("subscriptions.create") + can_import = ctx.can(Perm.SUB_CREATE) export_card = section_card( P("Download every subscription for the current team, including all of its " @@ -307,14 +312,14 @@ def _page(ctx, result=None): @ar("/import") def get(req, session): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.view")): return r + if (r := require(ctx, Perm.SUB_VIEW)): return r return _page(ctx) @ar("/import") async def post(req, session): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.create")): return r + if (r := require(ctx, Perm.SUB_CREATE)): return r if not writable_team(ctx): return _page(ctx, alert("Switch to a specific team (not “All teams”) before " "importing subscriptions.", "warning")) diff --git a/app/routes/manage.py b/app/routes/manage.py index 240190a..1ec4f75 100644 --- a/app/routes/manage.py +++ b/app/routes/manage.py @@ -10,17 +10,30 @@ from fasthtml.common import * from app import timeutil -from app.db import ( - get_db, get_all_subscriptions, get_categories, get_periods_map, - current_price, is_active_on, upcoming_price_change, -) from app.authz import require -from app.cost_utils import frequency_label from app.components import ( - page_title, nav_bar, badge, status_badge, action_menu, - select_menu, fmt_eur, category_label, pagination_bar, + action_menu, + badge, + category_label, + fmt_eur, + nav_bar, + page_title, + pagination_bar, + select_menu, + status_badge, +) +from app.cost_utils import frequency_label +from app.db import ( + current_price, + get_all_subscriptions, + get_categories, + get_db, + get_periods_map, + is_active_on, + upcoming_price_change, ) -from app.styles import PAGE_HEADER, TABLE, CONTROL, btn +from app.permissions import Perm +from app.styles import CONTROL, PAGE_HEADER, TABLE, btn _FF = "grid gap-1.5 text-sm font-medium" # filter field (label + control) _PER_PAGE = 20 # subscriptions per page in the manage list @@ -65,13 +78,13 @@ def _price_change_icon(change) -> object: @ar("/manage") def get(req, session, q: str = "", status: str = "all", category: str = "", page: int = 1): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.view")): return r + if (r := require(ctx, Perm.SUB_VIEW)): return r db = get_db() - can_create = ctx.can("subscriptions.create") - can_edit = ctx.can("subscriptions.edit") - can_delete = ctx.can("subscriptions.delete") - can_view_detail = ctx.can("subscriptions.view") + can_create = ctx.can(Perm.SUB_CREATE) + can_edit = ctx.can(Perm.SUB_EDIT) + can_delete = ctx.can(Perm.SUB_DELETE) + can_view_detail = ctx.can(Perm.SUB_VIEW) show_actions = can_edit or can_delete today = timeutil.today_iso() diff --git a/app/routes/subscriptions.py b/app/routes/subscriptions.py index 230d551..79f02f9 100644 --- a/app/routes/subscriptions.py +++ b/app/routes/subscriptions.py @@ -13,21 +13,46 @@ from fasthtml.common import * from app import timeutil -from app.db import ( - get_db, get_subscription, get_periods, current_price, is_active_on, - add_period, update_period, delete_period, validate_periods, - get_audit_for_entity, get_categories, audit, -) from app.authz import require, writable_team +from app.components import ( + MONTH_LABELS, + alert, + badge, + bar_chart, + category_label, + collapsible_card, + fmt_eur, + nav_bar, + page_title, + section_card, + status_badge, + subscription_form, +) from app.cost_utils import ( - frequency_label, get_period_cost, normalise_cadence, - upcoming_payments_for_periods, monthly_costs_for_year, year_cost, range_cost, + frequency_label, + get_period_cost, + monthly_costs_for_year, + normalise_cadence, + range_cost, + upcoming_payments_for_periods, + year_cost, ) -from app.components import ( - page_title, nav_bar, section_card, collapsible_card, alert, badge, status_badge, - fmt_eur, category_label, subscription_form, bar_chart, MONTH_LABELS, +from app.db import ( + add_period, + audit, + current_price, + delete_period, + get_audit_for_entity, + get_categories, + get_db, + get_periods, + get_subscription, + is_active_on, + update_period, + validate_periods, ) -from app.styles import PAGE_HEADER, TABLE, TABLE_WRAP, INPUT, TEXTAREA, LINK, MUTED_SM, btn +from app.permissions import Perm +from app.styles import INPUT, LINK, MUTED_SM, PAGE_HEADER, TABLE, TABLE_WRAP, btn ar = APIRouter() @@ -46,7 +71,7 @@ def _detail_redirect(sub_id: int, msg: str = "", kind: str = "warning"): @ar("/manage/new") def get(req, session): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.create")): return r + if (r := require(ctx, Perm.SUB_CREATE)): return r if not writable_team(ctx): return page_title("New Subscription"), nav_bar(ctx, "manage"), Main( Div(H2("Add Subscription"), A("← Manage", href="/manage", cls=LINK), cls=PAGE_HEADER), @@ -67,7 +92,7 @@ async def post(req, session, name: str, amount: float, start_date: str, interval: int = 1, base_unit: str = "", notes: str = "", category: str = ""): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.create")): return r + if (r := require(ctx, Perm.SUB_CREATE)): return r if not writable_team(ctx): return RedirectResponse("/teams?msg=Switch+to+a+specific+team+first&msg_kind=warning", status_code=303) @@ -116,7 +141,7 @@ async def post(req, session, name: str, amount: float, start_date: str, @ar("/subscriptions/{sub_id}/edit") def get(req, session, sub_id: int): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.edit")): return r + if (r := require(ctx, Perm.SUB_EDIT)): return r db = get_db() sub = get_subscription(db, ctx, sub_id) if not sub: @@ -138,7 +163,7 @@ async def post(req, session, sub_id: int, name: str, frequency: str = "monthly", interval: int = 1, base_unit: str = "", notes: str = "", category: str = ""): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.edit")): return r + if (r := require(ctx, Perm.SUB_EDIT)): return r db = get_db() sub = get_subscription(db, ctx, sub_id) if not sub: @@ -164,7 +189,7 @@ async def post(req, session, sub_id: int, name: str, frequency: str = "monthly", async def post(req, session, sub_id: int, amount: float, start_date: str, end_date: str = ""): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.edit")): return r + if (r := require(ctx, Perm.SUB_EDIT)): return r db = get_db() sub = get_subscription(db, ctx, sub_id) if not sub: @@ -189,7 +214,7 @@ async def post(req, session, sub_id: int, amount: float, start_date: str, @ar("/subscriptions/{sub_id}/periods/{period_id}/edit") def get(req, session, sub_id: int, period_id: int): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.edit")): return r + if (r := require(ctx, Perm.SUB_EDIT)): return r db = get_db() sub = get_subscription(db, ctx, sub_id) if not sub: @@ -225,7 +250,7 @@ def get(req, session, sub_id: int, period_id: int): async def post(req, session, sub_id: int, period_id: int, amount: float, start_date: str, end_date: str = ""): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.edit")): return r + if (r := require(ctx, Perm.SUB_EDIT)): return r db = get_db() sub = get_subscription(db, ctx, sub_id) if not sub: @@ -248,7 +273,7 @@ async def post(req, session, sub_id: int, period_id: int, amount: float, @ar("/subscriptions/{sub_id}/periods/{period_id}/delete") async def post(req, session, sub_id: int, period_id: int): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.edit")): return r + if (r := require(ctx, Perm.SUB_EDIT)): return r db = get_db() sub = get_subscription(db, ctx, sub_id) if not sub: @@ -273,7 +298,7 @@ async def post(req, session, sub_id: int, period_id: int): @ar("/subscriptions/{sub_id}/detail") def get(req, session, sub_id: int, msg: str = "", msg_kind: str = "warning"): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.view")): return r + if (r := require(ctx, Perm.SUB_VIEW)): return r db = get_db() sub = get_subscription(db, ctx, sub_id) if not sub: @@ -287,8 +312,8 @@ def get(req, session, sub_id: int, msg: str = "", msg_kind: str = "warning"): audit_entries = get_audit_for_entity(db, sub_id, "subscription") freq_lbl = frequency_label(sub["frequency"], sub["interval"] or 1, sub.get("base_unit")) - can_edit = ctx.can("subscriptions.edit") - can_delete = ctx.can("subscriptions.delete") + can_edit = ctx.can(Perm.SUB_EDIT) + can_delete = ctx.can(Perm.SUB_DELETE) actions = [] if can_edit: actions.append( @@ -445,7 +470,7 @@ def period_status(p): Thead(Tr(Th("Time"), Th("Action"), Th("Description"))), Tbody(*audit_rows), cls=TABLE, ), cls=TABLE_WRAP) if audit_rows else P("No audit entries.", cls=MUTED_SM), - ) if ctx.can("audit.view") else "" + ) if ctx.can(Perm.AUDIT_VIEW) else "" return page_title(sub["name"]), nav_bar(ctx, "manage"), Main( Div(H2(sub["name"]), A("← Manage", href="/manage", cls=LINK), cls=PAGE_HEADER), @@ -459,7 +484,7 @@ def period_status(p): @ar("/subscriptions/{sub_id}/delete") async def post(req, session, sub_id: int): ctx = req.scope["ctx"] - if (r := require(ctx, "subscriptions.delete")): return r + if (r := require(ctx, Perm.SUB_DELETE)): return r db = get_db() sub = get_subscription(db, ctx, sub_id) if not sub: diff --git a/app/routes/teams.py b/app/routes/teams.py index 3e09607..e965624 100644 --- a/app/routes/teams.py +++ b/app/routes/teams.py @@ -8,16 +8,29 @@ from fasthtml.common import * +from app.authz import require +from app.components import alert, badge, nav_bar, page_title, section_card, select_menu from app.db import ( - get_db, list_all_teams, list_user_teams, get_team, get_membership, - get_membership_by_id, list_team_members, member_count, count_team_admins, - create_team, add_member, set_member_role, remove_member, get_all_users, - get_user_by_id, audit, + add_member, + audit, + count_team_admins, + create_team, + get_all_users, + get_db, + get_membership, + get_membership_by_id, + get_team, + get_user_by_id, + list_all_teams, + list_team_members, + list_user_teams, + member_count, + remove_member, + set_member_role, ) -from app.authz import require -from app.rbac import TEAM_ROLES, TEAM_ROLE_NAMES, can_access_team -from app.components import page_title, nav_bar, section_card, alert, badge, select_menu -from app.styles import PAGE_HEADER, TABLE, INPUT, LINK, MUTED, btn +from app.permissions import Perm +from app.rbac import TEAM_ROLE_NAMES, TEAM_ROLES, can_access_team +from app.styles import INPUT, LINK, MUTED, PAGE_HEADER, TABLE, btn ar = APIRouter() @@ -43,9 +56,9 @@ def _team_role_select(name: str, current: str = "viewer", width: str = "w-[130px @ar("/teams") def get(req, session, msg: str = "", msg_kind: str = "warning"): ctx = req.scope["ctx"] - if (r := require(ctx, "teams.manage")): return r + if (r := require(ctx, Perm.TEAMS_MANAGE)): return r db = get_db() - can_create = ctx.can("teams.manage") + can_create = ctx.can(Perm.TEAMS_MANAGE) teams = list_all_teams(db) if ctx.is_global_admin else list_user_teams(db, ctx.user["id"]) rows = [] @@ -103,7 +116,7 @@ def get(req, session, msg: str = "", msg_kind: str = "warning"): @ar("/teams/switch") async def post(req, session, team_id: str = ""): ctx = req.scope["ctx"] - if (r := require(ctx, "teams.view")): return r + if (r := require(ctx, Perm.TEAMS_VIEW)): return r db = get_db() if team_id == "__all__" and ctx.is_super: session["view_all"] = True @@ -133,7 +146,7 @@ async def post(req, session): @ar("/teams/new") async def post(req, session, name: str, description: str = ""): ctx = req.scope["ctx"] - if (r := require(ctx, "teams.manage")): return r + if (r := require(ctx, Perm.TEAMS_MANAGE)): return r db = get_db() if not name.strip(): return RedirectResponse("/teams?msg=Team+name+required", status_code=303) @@ -153,7 +166,7 @@ async def post(req, session, name: str, description: str = ""): @ar("/teams/{team_id}/members") def get(req, session, team_id: int, msg: str = "", msg_kind: str = "warning"): ctx = req.scope["ctx"] - if (r := require(ctx, "teams.manage")): return r + if (r := require(ctx, Perm.TEAMS_MANAGE)): return r db = get_db() team = get_team(db, team_id) if not team or not can_manage_team(db, ctx, team_id): @@ -218,7 +231,7 @@ def get(req, session, team_id: int, msg: str = "", msg_kind: str = "warning"): @ar("/teams/{team_id}/members/add") async def post(req, session, team_id: int, user_id: int, team_role: str = "viewer"): ctx = req.scope["ctx"] - if (r := require(ctx, "teams.manage")): return r + if (r := require(ctx, Perm.TEAMS_MANAGE)): return r db = get_db() team = get_team(db, team_id) if not team or not can_manage_team(db, ctx, team_id): @@ -238,7 +251,7 @@ async def post(req, session, team_id: int, user_id: int, team_role: str = "viewe @ar("/teams/{team_id}/members/{membership_id}/role") async def post(req, session, team_id: int, membership_id: int, team_role: str): ctx = req.scope["ctx"] - if (r := require(ctx, "teams.manage")): return r + if (r := require(ctx, Perm.TEAMS_MANAGE)): return r db = get_db() team = get_team(db, team_id) if not team or not can_manage_team(db, ctx, team_id): @@ -263,7 +276,7 @@ async def post(req, session, team_id: int, membership_id: int, team_role: str): @ar("/teams/{team_id}/members/{membership_id}/remove") async def post(req, session, team_id: int, membership_id: int): ctx = req.scope["ctx"] - if (r := require(ctx, "teams.manage")): return r + if (r := require(ctx, Perm.TEAMS_MANAGE)): return r db = get_db() team = get_team(db, team_id) if not team or not can_manage_team(db, ctx, team_id): diff --git a/app/routes/users.py b/app/routes/users.py index 293f9c0..66960e1 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -8,14 +8,21 @@ from fasthtml.common import * +from app.authz import require +from app.components import alert, badge, nav_bar, page_title, select_menu from app.db import ( - get_db, get_all_users, get_user_by_id, username_taken, - count_super_admins, set_global_role, soft_delete_user, audit, + audit, + count_super_admins, + get_all_users, + get_db, + get_user_by_id, + set_global_role, + soft_delete_user, + username_taken, ) -from app.authz import require +from app.permissions import Perm from app.rbac import GLOBAL_ROLE_NAMES, global_role_rank -from app.components import page_title, nav_bar, alert, badge, select_menu -from app.styles import PAGE_HEADER, TABLE, INPUT, MUTED, btn +from app.styles import INPUT, MUTED, PAGE_HEADER, TABLE, btn ar = APIRouter() @@ -32,9 +39,9 @@ def _role_select(name: str, current: str, width: str = "w-[140px]"): @ar("/users") def get(req, session, msg: str = "", msg_kind: str = "warning"): ctx = req.scope["ctx"] - if (r := require(ctx, "users.view")): return r + if (r := require(ctx, Perm.USERS_VIEW)): return r db = get_db() - can_manage = ctx.can("users.manage") + can_manage = ctx.can(Perm.USERS_MANAGE) all_users = get_all_users(db) def row(u): @@ -98,7 +105,7 @@ def row(u): @ar("/users/new") async def post(req, session, username: str, password: str, global_role: str = "user"): ctx = req.scope["ctx"] - if (r := require(ctx, "users.manage")): return r + if (r := require(ctx, Perm.USERS_MANAGE)): return r db = get_db() uname = username.strip() role = global_role if global_role in GLOBAL_ROLE_NAMES else "user" @@ -119,7 +126,7 @@ async def post(req, session, username: str, password: str, global_role: str = "u @ar("/users/{uid}/role") async def post(req, session, uid: int, global_role: str): ctx = req.scope["ctx"] - if (r := require(ctx, "users.manage")): return r + if (r := require(ctx, Perm.USERS_MANAGE)): return r db = get_db() if uid == ctx.user["id"]: return RedirectResponse("/users?msg=Cannot+change+your+own+role", status_code=303) @@ -146,7 +153,7 @@ async def post(req, session, uid: int, global_role: str): @ar("/users/{uid}/delete") async def post(req, session, uid: int): ctx = req.scope["ctx"] - if (r := require(ctx, "users.manage")): return r + if (r := require(ctx, Perm.USERS_MANAGE)): return r db = get_db() if uid == ctx.user["id"]: return RedirectResponse("/users?msg=Cannot+delete+yourself", status_code=303) diff --git a/app/session.py b/app/session.py index 92784b8..ff1ada3 100644 --- a/app/session.py +++ b/app/session.py @@ -8,7 +8,7 @@ from starlette.responses import RedirectResponse -from app.db import has_any_users, get_user_by_id, get_db +from app.db import get_db, get_user_by_id, has_any_users from app.rbac import build_ctx # Paths the auth gate does NOT run on (public pages + top-level static assets). From 3e3dda0af600fa4b3f7f5d48dac19652e87826bc Mon Sep 17 00:00:00 2001 From: Wesley Date: Mon, 22 Jun 2026 23:28:32 +0200 Subject: [PATCH 3/4] test: CSRF, rate-limit, RBAC HTTP enforcement, lifecycle, import/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the suite from 43 to 66 tests: - CSRF: tokenless/wrong-token POSTs are 403; valid form token and the HTMX header path both pass; pages expose the token. - Rate limiter: unit behaviour + /login throttling after too many tries. - RBAC at the HTTP layer: a viewer is denied the new/create routes and the admin/users pages; a team admin is allowed — guarding the per-route permission checks, not just the resolver. - Subscription lifecycle: create -> edit -> soft-delete -> restore, and permanent-delete (row gone, audit retained). - Import/export round-trips (CSV + JSON) and bad-row reporting. - Permission-catalog drift guard: Perm constants match the catalog. conftest gains CSRF-aware post()/login()/setup_first_admin() helpers and an autouse fixture that resets the process-global rate limiter between tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/conftest.py | 42 +++++++++++++++++++++++- tests/test_app_smoke.py | 21 ++++++------ tests/test_auth.py | 2 +- tests/test_cost_utils.py | 1 - tests/test_csrf.py | 46 ++++++++++++++++++++++++++ tests/test_http_rbac.py | 64 +++++++++++++++++++++++++++++++++++++ tests/test_import_export.py | 60 ++++++++++++++++++++++++++++++++++ tests/test_lifecycle.py | 58 +++++++++++++++++++++++++++++++++ tests/test_permissions.py | 24 ++++++++++++++ tests/test_ratelimit.py | 40 +++++++++++++++++++++++ tests/test_rbac.py | 8 +++-- 11 files changed, 351 insertions(+), 15 deletions(-) create mode 100644 tests/test_csrf.py create mode 100644 tests/test_http_rbac.py create mode 100644 tests/test_import_export.py create mode 100644 tests/test_lifecycle.py create mode 100644 tests/test_permissions.py create mode 100644 tests/test_ratelimit.py diff --git a/tests/conftest.py b/tests/conftest.py index d0fbff0..dd32991 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ """ import os +import re # Set before any `app` import so config-time reads pick them up. os.environ.setdefault("SUBTRACKER_SECRET", "test-secret-not-for-prod") @@ -14,6 +15,44 @@ import pytest +_CSRF_META_RE = re.compile(r'name="csrf-token"\s+content="([^"]*)"') + + +def csrf_token(client, path: str = "/login") -> str: + """Scrape the per-session CSRF token from a rendered page's tag.""" + html = client.get(path, follow_redirects=True).text + m = _CSRF_META_RE.search(html) + return m.group(1) if m else "" + + +def post(client, url: str, data: dict | None = None, token_path: str = "/login", **kwargs): + """POST with the session's CSRF token injected (as the client JS would).""" + data = dict(data or {}) + data.setdefault("csrf_token", csrf_token(client, token_path)) + return client.post(url, data=data, **kwargs) + + +def setup_first_admin(client, username: str = "admin", password: str = "password123"): + """Run first-run /setup, leaving `client` logged in as the super admin.""" + return post(client, "/setup", + {"username": username, "password": password, "password2": password}, + token_path="/setup", follow_redirects=True) + + +def login(client, username: str, password: str): + """Log `client` in over HTTP (sets the session cookie).""" + return post(client, "/login", {"username": username, "password": password}, + follow_redirects=True) + + +@pytest.fixture(autouse=True) +def _reset_rate_limiter(): + """Clear the process-global auth rate limiter so tests don't bleed into each other.""" + from app.ratelimit import login_limiter + login_limiter.reset() + yield + login_limiter.reset() + @pytest.fixture def db_path(tmp_path, monkeypatch): @@ -34,6 +73,7 @@ def db(db_path): def client(db): """A Starlette TestClient bound to the per-test database.""" from starlette.testclient import TestClient + from app.main import app return TestClient(app) @@ -42,7 +82,7 @@ def client(db): def seed_team(db): """A super-admin user + a team they administer. Returns (uid, team_id).""" from app.auth import create_user - from app.db import create_team, add_member + from app.db import add_member, create_team uid = create_user("admin", "password123", global_role="super_admin") team_id = create_team(db, "Acme", "Acme team", created_by=uid) add_member(db, team_id, uid, "team_admin", created_by=uid) diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py index 1eb8525..c85a01d 100644 --- a/tests/test_app_smoke.py +++ b/tests/test_app_smoke.py @@ -1,6 +1,7 @@ """End-to-end HTTP smoke tests through the real ASGI app (isolated DB per test).""" from app.auth import create_user +from tests.conftest import post def test_fresh_install_redirects_to_setup(client): @@ -10,27 +11,27 @@ def test_fresh_install_redirects_to_setup(client): def test_setup_creates_admin_and_logs_in(client): - r = client.post("/setup", data={ + r = post(client, "/setup", { "username": "admin", "password": "password123", "password2": "password123", - }, follow_redirects=True) + }, token_path="/setup", follow_redirects=True) assert r.status_code == 200 assert r.url.path == "/dashboard" def test_setup_rejects_mismatched_passwords(client): - r = client.post("/setup", data={ + r = post(client, "/setup", { "username": "admin", "password": "aaaaaa", "password2": "bbbbbb", - }, follow_redirects=True) + }, token_path="/setup", follow_redirects=True) assert r.url.path == "/setup" assert "do not match" in r.text.lower() def test_setup_rejects_short_password(client): - r = client.post("/setup", data={ + r = post(client, "/setup", { "username": "admin", "password": "short", "password2": "short", - }, follow_redirects=True) + }, token_path="/setup", follow_redirects=True) assert r.url.path == "/setup" assert "at least 6" in r.text.lower() @@ -45,15 +46,15 @@ def test_protected_page_redirects_to_login_when_unauthenticated(client, db): def test_bad_login_is_rejected(client, db): create_user("admin", "password123", global_role="super_admin") - r = client.post("/login", data={"username": "admin", "password": "wrong"}, - follow_redirects=True) + r = post(client, "/login", {"username": "admin", "password": "wrong"}, + follow_redirects=True) assert r.url.path == "/login" assert "invalid" in r.text.lower() def test_login_then_reach_dashboard(client, db): create_user("admin", "password123", global_role="super_admin") - r = client.post("/login", data={"username": "admin", "password": "password123"}, - follow_redirects=True) + r = post(client, "/login", {"username": "admin", "password": "password123"}, + follow_redirects=True) assert r.status_code == 200 assert r.url.path == "/dashboard" diff --git a/tests/test_auth.py b/tests/test_auth.py index 49bbd3c..86c9592 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,6 @@ """Password hashing + authentication.""" -from app.auth import hash_password, verify_password, create_user, authenticate +from app.auth import authenticate, create_user, hash_password, verify_password def test_hash_is_salted_and_verifies(): diff --git a/tests/test_cost_utils.py b/tests/test_cost_utils.py index 28d48d9..1cec6a5 100644 --- a/tests/test_cost_utils.py +++ b/tests/test_cost_utils.py @@ -6,7 +6,6 @@ from app import cost_utils as cu - # ── normalise_cadence ──────────────────────────────────────────────────────── def test_named_preset_drops_interval_and_base_unit(): diff --git a/tests/test_csrf.py b/tests/test_csrf.py new file mode 100644 index 0000000..5cb8f08 --- /dev/null +++ b/tests/test_csrf.py @@ -0,0 +1,46 @@ +"""CSRF protection: unsafe requests need a token matching the session.""" + +from app.auth import create_user +from tests.conftest import csrf_token, login, post + + +def test_post_without_token_is_rejected(client, db): + create_user("admin", "password123", global_role="super_admin") + # A raw POST with no csrf_token field and no header — must be blocked. + r = client.post("/login", data={"username": "admin", "password": "password123"}) + assert r.status_code == 403 + assert "csrf" in r.text.lower() + + +def test_post_with_wrong_token_is_rejected(client, db): + create_user("admin", "password123", global_role="super_admin") + r = client.post("/login", data={"username": "admin", "password": "password123", + "csrf_token": "not-the-real-token"}) + assert r.status_code == 403 + + +def test_post_with_valid_token_succeeds(client, db): + create_user("admin", "password123", global_role="super_admin") + r = login(client, "admin", "password123") # helper injects the session token + assert r.status_code == 200 + assert r.url.path == "/dashboard" + + +def test_htmx_header_token_is_accepted(client, db): + """HTMX requests carry the token in the X-CSRFToken header, not the body.""" + create_user("admin", "password123", global_role="super_admin") + login(client, "admin", "password123") + token = csrf_token(client, "/manage") + # Deleting a non-existent subscription still passes the CSRF gate (then 303s), + # proving the header path is accepted; a missing header would 403 instead. + r = client.post("/subscriptions/99999/delete", headers={"X-CSRFToken": token}) + assert r.status_code != 403 + + +def test_pages_expose_csrf_meta_tag(client): + """Every rendered page carries the token for the client JS to read.""" + assert csrf_token(client, "/setup") # setup page (no users yet) + post(client, "/setup", {"username": "a", "password": "password123", + "password2": "password123"}, + token_path="/setup", follow_redirects=True) + assert csrf_token(client, "/dashboard") # authenticated page via nav bar diff --git a/tests/test_http_rbac.py b/tests/test_http_rbac.py new file mode 100644 index 0000000..a0b0d07 --- /dev/null +++ b/tests/test_http_rbac.py @@ -0,0 +1,64 @@ +"""Permission enforcement at the HTTP layer (not just the resolver).""" + +import pytest + +from app.auth import create_user +from app.db import add_member, create_team +from tests.conftest import login, post + + +@pytest.fixture +def team(db): + """A team owned by a super admin; returns its id.""" + owner = create_user("owner", "password123", global_role="super_admin") + return create_team(db, "Acme", "", created_by=owner) + + +def _member(db, team_id, username, role): + uid = create_user(username, "password123", global_role="user") + add_member(db, team_id, uid, role, created_by=uid) + return uid + + +def test_viewer_cannot_open_new_subscription_form(client, db, team): + _member(db, team, "vi", "viewer") + login(client, "vi", "password123") + r = client.get("/manage/new") + assert "not authorized" in r.text.lower() + + +def test_viewer_cannot_create_subscription(client, db, team): + _member(db, team, "vi", "viewer") + login(client, "vi", "password123") + r = post(client, "/manage/new", { + "name": "Sneaky", "amount": "9.99", "start_date": "2026-01-01", + }, token_path="/manage", follow_redirects=True) + assert "not authorized" in r.text.lower() + assert db["subscriptions"].count == 0 + + +def test_viewer_manage_page_hides_create_and_actions(client, db, team): + _member(db, team, "vi", "viewer") + login(client, "vi", "password123") + r = client.get("/manage") + assert r.status_code == 200 + assert "/manage/new" not in r.text # no Add link + assert "Actions" not in r.text # no per-row actions column + + +def test_team_admin_can_open_form_and_create(client, db, team): + _member(db, team, "ta", "team_admin") + login(client, "ta", "password123") + assert "Add Subscription" in client.get("/manage/new").text + post(client, "/manage/new", { + "name": "Netflix", "amount": "12.99", "start_date": "2026-01-01", + "frequency": "monthly", + }, token_path="/manage", follow_redirects=True) + assert db["subscriptions"].count == 1 + + +def test_viewer_cannot_reach_admin_or_users_pages(client, db, team): + _member(db, team, "vi", "viewer") + login(client, "vi", "password123") + assert "not authorized" in client.get("/admin/deleted").text.lower() + assert "not authorized" in client.get("/users").text.lower() diff --git a/tests/test_import_export.py b/tests/test_import_export.py new file mode 100644 index 0000000..ccfdf0a --- /dev/null +++ b/tests/test_import_export.py @@ -0,0 +1,60 @@ +"""Import / export round-trips through the real HTTP endpoints.""" + +import json + +from tests.conftest import csrf_token, post, setup_first_admin + + +def _create(client, name, amount="10.00"): + return post(client, "/manage/new", + {"name": name, "amount": amount, "start_date": "2026-01-01", + "frequency": "monthly"}, + token_path="/manage", follow_redirects=True) + + +def _import_file(client, filename, body, media): + token = csrf_token(client, "/import") + return client.post("/import", + files={"file": (filename, body, media)}, + data={"csrf_token": token}, follow_redirects=True) + + +def test_json_export_contains_subscriptions(client, db): + setup_first_admin(client) + _create(client, "Netflix") + _create(client, "Spotify") + + payload = json.loads(client.get("/export?fmt=json").text) + names = {s["name"] for s in payload["subscriptions"]} + assert names == {"Netflix", "Spotify"} + assert payload["version"] == 1 + + +def test_json_round_trip_recreates_subscriptions(client, db): + setup_first_admin(client) + _create(client, "Netflix") + body = client.get("/export?fmt=json").content + + r = _import_file(client, "export.json", body, "application/json") + assert "imported 1 subscription" in r.text.lower() + # Original + re-imported copy. + assert db["subscriptions"].count == 2 + assert [s["name"] for s in db["subscriptions"].rows] == ["Netflix", "Netflix"] + + +def test_csv_round_trip_recreates_subscriptions(client, db): + setup_first_admin(client) + _create(client, "Spotify") + body = client.get("/export?fmt=csv").content + + r = _import_file(client, "export.csv", body, "text/csv") + assert "imported 1 subscription" in r.text.lower() + assert db["subscriptions"].count == 2 + + +def test_import_reports_errors_for_bad_rows(client, db): + setup_first_admin(client) + bad = b"name,amount,start_date\n,5.00,2026-01-01\nGood,notanumber,2026-01-01\n" + r = _import_file(client, "bad.csv", bad, "text/csv") + # The nameless row and the non-numeric amount are both reported. + assert "issue" in r.text.lower() or "no subscriptions" in r.text.lower() diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..33cbbc7 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,58 @@ +"""Subscription lifecycle over HTTP: create → edit → soft-delete → restore.""" + +from tests.conftest import post, setup_first_admin + + +def _create(client, **over): + data = {"name": "Netflix", "amount": "12.99", "start_date": "2026-01-01", + "frequency": "monthly"} + data.update(over) + return post(client, "/manage/new", data, token_path="/manage", follow_redirects=True) + + +def test_full_lifecycle(client, db): + setup_first_admin(client) # super admin + Default team, logged in + + _create(client, name="Netflix") + sub = next(iter(db["subscriptions"].rows)) + sub_id = sub["id"] + assert sub["name"] == "Netflix" + assert db["subscription_periods"].count == 1 # first period created + + # Edit identity/cadence. + post(client, f"/subscriptions/{sub_id}/edit", + {"name": "Netflix Premium", "frequency": "monthly"}, + token_path="/manage", follow_redirects=True) + assert db["subscriptions"].get(sub_id)["name"] == "Netflix Premium" + + # Soft-delete: row stays but is marked deleted. + post(client, f"/subscriptions/{sub_id}/delete", token_path="/manage", + follow_redirects=True) + assert db["subscriptions"].get(sub_id)["deleted_at"] is not None + + # It disappears from the manage list... + assert "Netflix Premium" not in client.get("/manage").text + # ...but appears under deleted records... + assert "Netflix Premium" in client.get("/admin/deleted").text + + # Restore: deleted markers cleared, visible again. + post(client, f"/admin/deleted/subscription/{sub_id}/restore", + token_path="/admin/deleted", follow_redirects=True) + assert db["subscriptions"].get(sub_id)["deleted_at"] is None + assert "Netflix Premium" in client.get("/manage").text + + +def test_permanent_delete_removes_row_but_keeps_audit(client, db): + setup_first_admin(client) + _create(client, name="Spotify") + sub_id = next(iter(db["subscriptions"].rows))["id"] + + post(client, f"/subscriptions/{sub_id}/delete", token_path="/manage", + follow_redirects=True) + post(client, f"/admin/deleted/subscription/{sub_id}/purge", + token_path="/admin/deleted", follow_redirects=True) + + assert db["subscriptions"].count == 0 + # Audit history survives the row (no FK back to it). + actions = [a["action"] for a in db["audit_log"].rows if a["entity_id"] == sub_id] + assert "PERMANENT_DELETE" in actions diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..2898995 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,24 @@ +"""The permission catalog and the Perm constants must stay in lock-step.""" + +from app.permissions import ALL_PERMISSIONS, PERMISSIONS, Perm +from app.rbac import ROLE_PERMISSIONS + + +def _perm_constants() -> set: + return {v for k, v in vars(Perm).items() + if not k.startswith("_") and isinstance(v, str)} + + +def test_constants_exactly_cover_the_catalog(): + assert _perm_constants() == set(ALL_PERMISSIONS) + + +def test_permission_names_are_unique(): + assert len(ALL_PERMISSIONS) == len(set(ALL_PERMISSIONS)) + assert len(PERMISSIONS) == len(ALL_PERMISSIONS) + + +def test_role_matrix_only_references_known_permissions(): + for role, perms in ROLE_PERMISSIONS.items(): + unknown = perms - set(ALL_PERMISSIONS) + assert not unknown, f"{role} references unknown permissions: {unknown}" diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py new file mode 100644 index 0000000..8179639 --- /dev/null +++ b/tests/test_ratelimit.py @@ -0,0 +1,40 @@ +"""Auth rate limiting: the RateLimiter unit + the /login integration.""" + +from app.auth import create_user +from app.ratelimit import RateLimiter, login_limiter +from tests.conftest import post + + +def test_limiter_allows_up_to_max_then_blocks(): + rl = RateLimiter(max_attempts=3, window_seconds=300) + for _ in range(3): + assert not rl.is_limited("ip") + rl.record("ip") + assert rl.is_limited("ip") + + +def test_limiter_is_per_key(): + rl = RateLimiter(max_attempts=1, window_seconds=300) + rl.record("a") + assert rl.is_limited("a") + assert not rl.is_limited("b") + + +def test_reset_clears_state(): + rl = RateLimiter(max_attempts=1, window_seconds=300) + rl.record("a") + rl.reset("a") + assert not rl.is_limited("a") + + +def test_login_is_throttled_after_too_many_attempts(client, db): + create_user("admin", "password123", global_role="super_admin") + # Exhaust the allowance with wrong passwords. + for _ in range(login_limiter.max_attempts): + post(client, "/login", {"username": "admin", "password": "wrong"}, + follow_redirects=True) + # The next attempt — even with the CORRECT password — is rate-limited. + r = post(client, "/login", {"username": "admin", "password": "password123"}, + follow_redirects=True) + assert r.url.path == "/login" + assert "too many attempts" in r.text.lower() diff --git a/tests/test_rbac.py b/tests/test_rbac.py index 2a544a9..e251ae3 100644 --- a/tests/test_rbac.py +++ b/tests/test_rbac.py @@ -1,9 +1,13 @@ """Role-based access control: effective-permission resolution + context building.""" from app.auth import create_user -from app.db import create_team, add_member, get_user_by_id +from app.db import add_member, create_team, get_user_by_id from app.rbac import ( - resolve_permissions, build_ctx, can_access_team, ALL_PERMISSIONS, ROLE_PERMISSIONS, + ALL_PERMISSIONS, + ROLE_PERMISSIONS, + build_ctx, + can_access_team, + resolve_permissions, ) From 50b910af87158b5bd8c1a5b046e28d8f99ebe727 Mon Sep 17 00:00:00 2001 From: Wesley Date: Mon, 22 Jun 2026 23:28:32 +0200 Subject: [PATCH 4/4] docs: document CSRF, rate-limiting, secure cookies, and dev tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security notes claimed CSRF/rate-limiting/Secure cookies were "not yet implemented" — update to reflect what now ships, add the SUBTRACKER_SECURE_COOKIES env var, and document the ruff/mypy/coverage commands and the new modules in the project layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8fa3ae2..e278f83 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,12 @@ uv run python main.py # http://localhost:5001 (live reload) First visit walks you through creating the initial **super-admin** account. -Run the tests: +Lint, type-check, and test (the same checks CI runs): ```bash -uv run pytest +uv run ruff check app tests # lint + import sorting +uv run mypy # type-check the logic/data layer +uv run pytest --cov # tests with coverage ``` --- @@ -60,6 +62,7 @@ All configuration is via environment variables: | `SUBTRACKER_SECRET` | **Prod** | random per-process | Session-signing key. **Set a stable value in production** — without it, logins do not survive a restart and a warning is logged. Generate with `openssl rand -hex 32`. | | `SUBTRACKER_DB` | no | `./subscriptions.db` | SQLite file path. Point at a persistent volume in a container (e.g. `/data/subscriptions.db`). | | `SUBTRACKER_PORT` | no | `5001` | Listen port. | +| `SUBTRACKER_SECURE_COOKIES` | no | `0` | Set to `1` when serving over HTTPS: marks the session cookie `Secure` and sets `SameSite=strict`. | The database runs in WAL mode and is created automatically on first boot. @@ -141,12 +144,19 @@ test suite). Pushing to `master` never touches the stable image. - Sessions are signed cookies keyed by `SUBTRACKER_SECRET`. - Authorization is enforced per-route via the RBAC layer (`app/rbac.py`, `app/authz.py`); roles compose by union across a global and a per-team axis. +- **CSRF protection** (`app/csrf.py`): a per-session token is required on every + state-changing request. It rides in a `` tag; client JS attaches it to + POST forms (hidden field) and HTMX requests (`X-CSRFToken` header), and a + Beforeware rejects any unsafe request whose token doesn't match the session. +- **Login/setup rate-limiting** (`app/ratelimit.py`): 10 attempts per 5 minutes + per client throttles online password guessing. - CSV export quotes leading formula characters (`=`, `+`, `-`, `@`) to neutralise spreadsheet formula injection, and imports are size- and row-capped. -**Recommended hardening before exposing to untrusted networks** (not yet -implemented): CSRF tokens on state-changing forms, login rate-limiting, and -serving behind HTTPS with `Secure` session cookies. +**Before exposing to untrusted networks**, serve behind HTTPS and set +`SUBTRACKER_SECURE_COOKIES=1` — this marks the session cookie `Secure` (sent only +over HTTPS) and tightens `SameSite` to `strict`. The in-memory rate limiter is +per-process; a multi-worker deployment would want a shared store. --- @@ -158,6 +168,9 @@ app/ config.py env-driven configuration session.py auth gate (Beforeware) + request context rbac.py / authz.py role catalog, permission resolution, per-route guard + permissions.py permission name catalog (the Perm constants) + csrf.py per-session CSRF token: guard, , client JS + ratelimit.py in-memory auth rate limiter auth.py bcrypt hashing + authentication cost_utils.py cadence math, prorated/period-aware cost, next payments timeutil.py central date/time provider @@ -165,7 +178,8 @@ app/ db/ schema (idempotent + migration), data access per entity routes/ one APIRouter per feature area components/ FastHTML view helpers (shadcn-styled) + charts -tests/ pytest suite (cost math, RBAC, auth, migration, HTTP smoke) +tests/ pytest suite (cost math, RBAC + HTTP enforcement, auth, CSRF, + rate-limit, lifecycle, import/export, migration, HTTP smoke) Dockerfile production image (used by compose + the GHCR image build) docker-compose.yml standalone deployment repository.yaml Home Assistant add-on repository manifest