The HTML operator page served the same clients/ledger/queue/review data as
the authenticated JSON summary, but with no auth at all. The loopback
requirement it relied on existed only as prose (page note + docstring); it
was never enforced by mount_dashboard, which does not own the app and
cannot constrain the bind address.
Put /dashboard behind the same _guard as /api/dashboard/summary:
- no X-API-Key -> 401, no billing data
- no configured key -> 503 (fail-closed)
- valid key -> 200 HTML (unchanged rendering)
Also drop the now-false 'loopback-only' security claims from the page note
and the mount_dashboard docstring, and update the tests:
- test_dashboard_page_open_and_html now sends a valid key
- new test_dashboard_page_guard_blocks_anonymous_access: no key -> 401,
body contains no client data (mutation check: fails if guard removed)
- new test_dashboard_wrong_key_same_length_rejected: wrong same-length
key -> 401 on HTML page too
- test_summary_fail_closed_without_configured_key now asserts the HTML
page also fails closed (503), not open
- test_dashboard_mounts_onto_existing_app sends a valid key
/api/dashboard/summary behaviour unchanged. Suite: 160 passed, 1 skipped
(before: 158 passed, 1 skipped).
The guard asserted gitea_saas/server.py was absent from the tree. That held
only until #2 (which owns the app factory) merged; afterwards it fired against
#2's own file rather than anything #9 shipped, and was the single failure when
issue-9 was merged onto main.
issue-9 adds exactly two files (gitea_saas/dashboard.py, tests/test_dashboard.py)
and never contained server.py, so the invariant the docstring describes was
always satisfied. It is now checked directly, via AST, so mount_dashboard's
docstring example (app = Flask(__name__)) is not mistaken for a real call.
Review finding (2026-08-26): the dashboard was the only auth path in the
service NOT using hmac.compare_digest — plain str != short-circuits on the
first differing byte (prefix timing leak on the shared secret) — and the
header name was a hardcoded literal instead of a named constant.
- import hmac; compare via hmac.compare_digest (503 fail-closed branch
kept exactly as reviewed)
- API_KEY_HEADER module constant is the single source of truth; when #24
consolidates auth paths this line becomes an import of the shared
config constant (config.py lands with #2/#6, both unmerged)
- new test: a wrong key of the SAME length as the configured key must be
rejected (the length-differing fixture cannot distinguish != from
compare_digest)
Bounce #2 (2026-08-25 22:38): gitea_saas/gitea_client.py is not in #8's
Files-allowed set. It was reassigned to issue #4 (sole owner) by !23 which
closed#21. Removed from this branch entirely; the import resolves from
main once !17 lands (reviewer: "only who ships the module changes").
The canonical client (issue-4 branch) is missing list_issue_comments —
the exact 8-line delta the bounce predicted. Per bounce instruction #3,
that is NOT forked here; the missing method is being requested on issue #4.
test_real_client_list_issue_comments_builds_correct_request now skips with
an explicit reason until the canonical client grows the method, so the gate
stays green and the guard re-activates automatically once !17 lands.
Review findings (manual review of !22):
1. Main moved: #16 renamed create_client/get_client records to flat
id-keyed shape. Tests read the old nested ['client']/['client_id']
shape (10 collection errors). Fixed to the flat ['id'] record;
parameter names stay unchanged per review.
2. server.py three-way conflict (#2/#6/#9): issue #9 must NOT define a
second Flask app. gitea_saas/server.py is dropped from this branch;
dashboard.py now exposes mount_dashboard(app, core, db_path, api_key)
which #2's app factory wires in. Endpoint tests build a throwaway app
the same way (X-API-Key discipline, fail-closed 503, 401) and add a
mount-contract test asserting no server.py ships with this issue.
Rebased onto current main (bcf03bd). Full gate: 58 passed, diff clean.
Addendum finding (bounce comment 9000): _authorized compared the shared
secret with ==, leaking a prefix-match timing signal. Use
hmac.compare_digest and read the header name from the shared
API_KEY_HEADER constant (defensive import; gitea_saas/config.py lands
with sibling auth PRs, fallback X-API-Key keeps this module importable
until then). Route comment updated to state the real discipline.
46 tests pass.
main (#16) flattened get_client records and renamed create_client's
returned identifier to 'id'. Read create_client(...)['id'] in the
real-core composition test, and make _default_repo_name accept the flat
record (top-level name / id) as well as the historical nested shape.
46 tests pass on top of origin/main.
BillingCore.create_client now returns the flat record keyed as 'id' (main's
PR #16/#19 rename). The Stripe test fixtures were reading the old
'client_id' return key, failing 5 tests on current main. Only the return
value changed; the 'client_id' parameter to member_payload and the
client_id metadata key are untouched.
GiteaClient (shared seam): create_org_repo/repo_exists/create_issue plus
the review-gate surface (get_issue/get_pr/get_pr_diff/post_comment).
IntakeFactory: provision_client (idempotent), submit_issue (funded ships,
unfunded/locked queues), unlock (flush with p1), lock, client_unfunded.
Factory tables (client_repos, pending_issues, factory_locks) are created
idempotently against the billing SQLite file. register_factory_routes
mounts the /api/factory/* endpoints on the issue #2 Flask app.
Gates live in the service only: no dispatcher/lane-script coupling.
Replace the == string comparison at delivery.py:127 with
hmac.compare_digest so the shared secret never leaks through response
timing (repo convention: stripe_client.py:88, server.py:171). The
isinstance/empty guards run first so compare_digest's TypeError on
non-str input is never reached — unset config, None, and non-str keys
stay fail-closed False. Five direct authorized() tests added (16
delivery tests / 42 full suite green).
create_client now returns 'id' (was 'client_id'), get_client returns a flat
record with balance as a field (was nested under 'client'), and list_clients
is implemented returning {'clients': [...]} with the same flat shape. A
cross-method shape-consistency test (create/get/list key-set agreement)
plus a wire-shape test mirroring the server's _client_out allow-list guard
against future drift. server.py/test_server.py untouched (issue #2/!13).
build_stripe_client() no longer degrades to the mock on absent config.
Mock is selected only on explicit opt-in (mock=True or SAAS_STRIPE_MODE=mock);
with mode unset/live, missing STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET raise
StripeConfigError naming the variable (never its value). MockStripeClient's
webhook_signature is now required (no published default) and verified with
hmac.compare_digest. Regression test asserts the forged-webhook scenario
leaves the balance unchanged.
Implements gitea-saas-core#2: Flask app over the issue #1 billing core.
- create_app(core, api_key) factory; X-API-Key auth on every route
except /health (missing/wrong key -> 401), hmac.compare_digest,
no key configured -> deny all (fail-closed).
- Endpoints: POST /api/clients, GET /api/clients (admin list),
GET /api/clients/{id}, POST .../credits, POST .../debits (409 on
negative balance), POST .../holds (409 on insufficient funds),
POST /api/holds/{id}/release, GET .../ledger?from=&to=.
- JSON in/out, errors always {"error", "code"}; core (ok, details)
contract enforced; missing core op -> 501, unexpected core failure
-> 500 JSON (never HTML, never leaked internals).
- config.py reads SAAS_API_KEY/SAAS_PORT/SAAS_DB_PATH with sane
defaults; never reads Stripe keys. CLI (python -m gitea_saas.server)
refuses to start without SAAS_API_KEY.
- tests use temp SQLite + Flask test client, no network. The billing
core (#1) is not merged onto main yet, so tests carry a reference
implementation of the documented core contract; a skipped-until-then
integration test runs against the real core once it lands.
Verification: python -m pytest tests/test_server.py -q -> 17 passed, 1 skipped.
MockStripeClient (deterministic, no network, records events, raises
StripeSignatureError on wrong sig) + real StripeClient behind env keys
(lazy stripe import; CI runs mock-only). build_stripe_client() factory:
mock by default, real only when STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET
set. apply_payment_event() ties payment.succeeded to core.credit(...,
idempotency_key=event_id) so duplicate webhook deliveries never
double-credit. 11 hermetic tests (no network).
SQLite schema + ledger operations in gitea_saas/billing_core.py (+ db.py,
schema.sql). balance = credits + refunds - debits - active holds, never
negative; every mutation idempotent via UNIQUE idempotency_key; holds
expire (default 30 min) and auto-release on the next read/query. All ops
return (ok, details) with structured errors; never raise. Thread-safe via
single lock + WAL. 26 hermetic pytest tests (no network/Stripe/Gitea).