Commit Graph

46 Commits

Author SHA1 Message Date
Claude
08febd56ca fix(dashboard): require X-API-Key on /dashboard (fail-closed, #25)
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).
2026-08-26 02:54:37 -05:00
ccccfbf879 SaaS dashboard: clients, ledger, queue, throughput, review verdicts (#9) (#22) 2026-08-26 09:19:25 +02:00
reviewer
1523074cbe tests(#9): make the no-second-server guard survive #2 merging
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.
2026-08-26 02:18:32 -05:00
r
43dca684d0 Merge remote-tracking branch 'origin/main' into HEAD 2026-08-26 02:14:37 -05:00
1fccd03843 Eval farm: batch intake -> lane drain -> results emitter (#8) (#20) 2026-08-26 09:13:47 +02:00
0ba11c75fd Delivery receipts: idempotent debit on merge + client lock (#7) (#18) 2026-08-26 09:12:58 +02:00
4d921a0ce1 Review policy + merge loop (tier auto-merge, bounce routing, delivery debit) (#6) (#15) 2026-08-26 09:12:38 +02:00
r
b8ffd2325d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	gitea_saas/gitea_client.py
2026-08-26 02:11:04 -05:00
4990007db1 Stripe adapter (mockable seam: checkout + webhook -> idempotent credit) (#3) (#11) 2026-08-26 09:09:48 +02:00
d8cc135c40 Factory intake: client repo provisioning + unfunded gating (#4) (#17) 2026-08-26 09:09:26 +02:00
6003fee8e9 feat(#2): Billing HTTP API (Flask, token-auth, fail-closed) (#13) 2026-08-26 09:08:39 +02:00
Claude
f7d0bc5666 fix(#9): bounce 2 — constant-time API-key compare + single header constant
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)
2026-08-25 21:00:34 -05:00
Claude
b30a358ff2 gitea-saas-core#8: drop gitea_client.py per bounce — issue #4 owns it (via !21/!23); keep real-client skip-guard
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.
2026-08-25 16:18:38 -05:00
Claude
c1b3105257 fix(#9): bounce — #16 id-key API + mount dashboard onto app factory
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.
2026-08-25 11:38:54 -05:00
Claude
ea674c2ff5 gitea-saas-core#4: constant-time API-key check via API_KEY_HEADER
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.
2026-08-25 11:36:52 -05:00
Claude
cc375b4d81 gitea-saas-core#8: canonical gitea_client + list_issue_comments, #16 id rename, real-request test 2026-08-25 11:32:18 -05:00
Claude
3daac0717c gitea-saas-core#9: SaaS operator dashboard — clients/queue/throughput/review verdicts/ledger flash (HTML page + authed JSON summary) 2026-08-25 11:30:15 -05:00
Claude
64b65ab0bd Merge remote-tracking branch 'origin/main' into issue-8 2026-08-25 11:26:49 -05:00
Claude
a991d13317 gitea-saas-core#7: adopt #16 core shape (create_client id key, flat get_client) 2026-08-25 11:20:55 -05:00
Claude
b73185e5af Merge remote-tracking branch 'origin/main' into issue-7 2026-08-25 11:19:23 -05:00
Claude
59eceb6b48 gitea-saas-core#4: adapt to billing core client id rename (#16) after rebase
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.
2026-08-25 11:15:40 -05:00
bcf03bd9cc Review gate: automated PR reviewer (acceptance parse, tests, verdicts) (#5) 2026-08-25 18:14:17 +02:00
23cdf4927f gitea-saas-core#21: single owning issue for gitea_saas/gitea_client.py (#21) 2026-08-25 18:13:55 +02:00
Claude
7a6fcdbd72 Merge origin/issue-2 into issue-6 (bounce fix: mount review routes on #2's app, drop duplicate gitea_client.py, adopt #16 id rename) 2026-08-25 11:08:50 -05:00
Claude
fc5613265b Merge remote-tracking branch 'origin/main' into issue-6 2026-08-25 11:00:51 -05:00
Claude
daeecc078d gitea-saas-core#3: tests: read create_client id key (bounce fix attempt 2, #16 rename)
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.
2026-08-25 10:59:56 -05:00
Claude
e42b27897c gitea-saas-core#4: factory intake — client repo provisioning + unfunded gating
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.
2026-08-25 10:53:01 -05:00
Claude
5e5827c987 gitea-saas-core#5: drop duplicate gitea_client.py — canonical version is !17's (#4) 2026-08-25 10:43:46 -05:00
Claude
1b730732ec gitea-saas-core#21: give gitea_saas/gitea_client.py a single owning issue (#4) 2026-08-25 10:32:42 -05:00
c78f497f34 Billing core client API consistency: id, flat records, list_clients (#16) 2026-08-25 17:12:31 +02:00
Claude
80958ef9b9 gitea-saas-core#7: constant-time API-key check in authorized() (bounce fix)
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).
2026-08-25 06:40:41 -05:00
Claude
4d3123af9e Merge remote-tracking branch 'origin/main' into issue-2
# Conflicts:
#	requirements.txt
2026-08-25 06:29:19 -05:00
Claude
2f2f53f3f7 gitea-saas-core#8: eval farm intake — batch -> lane drain -> results emitter (402 funded gate, eval-task issues, poll, emit w/ retry + file drop) 2026-08-25 06:21:54 -05:00
Claude
84e44ee4e8 gitea-saas-core#16: billing core client API consistency (id, flat records, list_clients)
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).
2026-08-25 06:09:46 -05:00
Claude
6fa667393a gitea-saas-core#3: fail-closed Stripe credential resolution (bounce fix)
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.
2026-08-25 06:00:36 -05:00
Claude
b5018c8645 gitea-saas-core#7: delivery receipts — idempotent debit on merge + client lock
Implement delivery.py: debit_for_merge (idempotency key deliver:repo:pr:sha),
on_merge_webhook (tier rate from config/env, receipt comment, floor/overdue
client lock), and a server-mountable handle_merge_webhook (X-API-Key auth,
tolerant Gitea payload parsing). Injectable core/gitea/intake seams; tests
hermetic with FakeGitea + tmp SQLite.
2026-08-25 05:53:29 -05:00
Claude
7c0d3fbcdb gitea-saas-core#6: review policy + merge loop (tier auto-merge, bounce routing, delivery debit) 2026-08-25 05:29:55 -05:00
59c3d035b1 Billing ledger core (clients/ledger/holds, idempotent, fail-closed) (#1) 2026-08-25 12:23:00 +02:00
Claude
6dc3c52fdb Merge remote-tracking branch 'origin/issue-1' into issue-6 2026-08-25 04:35:12 -05:00
pbwraith-worker
df72b3926f gitea-saas-core#5: review gate — automated PR reviewer (acceptance parse, tests, verdicts)
Reviewer(repo_client, runner) with review_pr() -> Verdict and
post_verdict() posting [review-gate] comments. Parses acceptance
checklists + Files-allowed scope from the linked issue; runs pytest,
optional build cmd (REVIEW_BUILD_CMD), git diff --check with timeouts
(REVIEW_TEST_TIMEOUT_S, default 300); secret-pattern scan (sk_live_,
AKIA, gh tokens, private keys, assigned credentials) quoting each added
diff line as evidence (values redacted); optional configurable LLM pass
that is validated — unquoted findings dropped. Verdict rules: failing
tests -> BOUNCE (unless allow_test_failure in issue body); timeout ->
BOUNCE; scope violation -> BOUNCE; blocking finding -> BOUNCE; empty
acceptance -> NEEDS_GUIDANCE (never silent-approve); else APPROVE.
FAIL-CLOSED: no merge call anywhere. gitea_client.py adds the Gitea
seam (env token, urllib, mockable). 16 hermetic tests, no network, no
real subprocesses.
2026-08-25 04:31:08 -05:00
Claude
d7d462de14 Add billing HTTP API (Flask, token-auth, fail-closed)
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.
2026-08-25 04:30:07 -05:00
pbwraith-worker
b6a0461739 gitea-saas-core#3: Stripe adapter (mockable seam: checkout + webhook -> idempotent credit)
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).
2026-08-25 04:22:00 -05:00
pbwraith-worker
6d764d46f6 gitea-saas-core#1: billing ledger core (clients/ledger/holds, idempotent, fail-closed)
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).
2026-08-25 04:16:35 -05:00
1820d0b34a architecture: intake-side gating (no dispatcher coupling) + review gate 2026-08-25 11:09:56 +02:00
pbwraith
2cbc83efa5 import gitea-saas-core architecture 2026-08-25 04:02:11 -05:00
b4436d3a0f Initial commit 2026-08-25 11:01:12 +02:00