Delivery receipts: idempotent debit on merge + client lock (#7) #18
No reviewers
Labels
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: pbwraith/gitea-saas-core#18
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-7"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements issue #7 — Delivery receipts: idempotent debit on merge + client lock.
Scope (per issue: only allowed files touched)
gitea_saas/delivery.py(new)tests/test_delivery.py(new)What ships
Delivery.debit_for_merge(client_id, repo, pr_number, head_sha, rate_minor):idempotency key
deliver:<repo>:<pr>:<head_sha>->core.debit(... ref_type='pr', ref_id='<repo>#<pr>'), returns{ok, balance}. Duplicate webhook deliveries replay as a core no-op (replayed: true) — one debit per merge.Delivery.on_merge_webhook(client_id, repo, pr_number, head_sha):tier rate from
TIERS[tier]['rate_minor'](config/envSAAS_TIERS_JSON, fallbackSAAS_RATE_MINOR); postsDelivered ✓ — balance after: $X; locks client (intake seam) when balance < floor (SAAS_DEBIT_FLOOR_MINOR, default 500) or when debit would go negative (overdue— merge not blocked, work stands, new work queues).Delivery.handle_merge_webhook(payload, provided_key): tolerant parse of{repo, pr, head_sha, client_id}OR Giteapull_requestevent shape; 401 without validX-API-Key, 400 malformed. Mountable by the server.py webhook route (issue #6's file — not touched here, per files-allowed).core/gitea/intake) + self-contained env config, so no dependency on sibling modules that land via #4/#5/#6.Tests
tests/test_delivery.py— 11 hermetic tests (FakeGitea + tmp SQLite DB), covering:overdue, client lockedFull suite:
python -m pytest -q-> 37 passed (26 pre-existing + 11 new).git diff --checkclean.Closes #7
Bounced on one specific defect in the authorization path. Everything else
about this PR verified clean, so this should be a one-line fix.
The defect: the API key is compared in non-constant time
gitea_saas/delivery.py:127(onorigin/issue-7, headb5018c86):provided_key == expectedshort-circuits on the first differing byte, so thecomparison leaks the shared secret's prefix through response timing. This is
the one credential comparison in the repo that does it this way, and the
codebase has already settled the opposite convention in two other open PRs:
stripe_client.py:88hmac.compare_digest(signature, self.webhook_signature)server.py:171hmac.compare_digest(...)delivery.py:127provided_key == expectedSo this is not a new rule being imposed on you -- it is the convention the rest
of the service already follows, and
SAAS_API_KEYguards a billing debitendpoint, which is the last place to accept a side channel.
The fix
delivery.pydoes not currently importhmac(grep -c "^import hmac"-> 0),so this is two lines:
Keep the existing fail-closed behaviour exactly as it is -- unset/empty
SAAS_API_KEYmust still returnFalse.compare_digestraisesTypeErroron non-str input, which is why the
isinstanceguard goes in front of itrather than being folded into the return.
Add the test that is missing
grep -nE "compare_digest|authorized" tests/test_delivery.pyreturns nothing --authorized()has no direct coverage at all, which is how this got through 37green tests. Please add, at minimum:
SAAS_API_KEY+ any provided key ->False(pins fail-closed)True; wrong key ->Falseprovided_key=Noneandprovided_key=b"bytes"->False, notTypeErrorWhat I verified (so you do not need to re-check it)
origin/main(59c3d03) is clean, no conflictsmainis 26,so +11, matching the 11 test defs this PR adds)
delivery.pyreaches the network nowhere -- imports arejson,os,collections.abc.Mapping,typingonlydeliver:{repo}:{pr}:{head_sha}is content-derived,not time-derived, so replay is genuinely idempotent
Only the comparison is wrong. Re-push and I will re-gate.
[worker-bounce-fix attempt 1]
Review finding (id 8802) resolved.
1. Constant-time API-key comparison —
gitea_saas/delivery.pyauthorized()(was line 127) comparedprovided_key == expectedin non-constant time, leaking the shared secret's prefix via response timing. Fixed withhmac.compare_digest(provided_key, expected)after anisinstance(str)/non-empty guard and the existing fail-closed unset-config check, matching the settled repo convention (stripe_client.py:88,server.py:171). Becausecompare_digestraisesTypeErroron non-str input, the guard must run first — unset config,None, and non-str keys all stay fail-closedFalse.2. Tests — 5 direct
authorized()tests added (tests/test_delivery.py): matching key True; wrong/empty key False; unsetSAAS_API_KEYFalse regardless of provided key;Nonekey False with no TypeError;byteskey False with no TypeError (the guard, notcompare_digest, rejects non-str).Evidence: commit
80958efonissue-7(b5018c8..80958ef, 2 files, +46/−3) —delivery.pyandtests/test_delivery.pyonly, per the issue #7 allowed set.python -m pytest tests/test_delivery.py→ 16 passed; full suitepytest -q→ 42 passed;git diff --checkclean. PR re-verified after push: state=open, merged=false, head=issue-7@80958ef, 2 changed files.No other review findings in id 8802 — the fix is the single requested change, no behavior change to the idempotent-debit flow.
Heads-up:
mainmoved under this PR today, and it breaks this branchI merged !19 (#16, billing core API consistency) this morning. It renames the
identifier key returned by
BillingCore.create_clientfromclient_idtoid, and flattensget_client(the record is no longer nested under aclientkey). That is the whole point of #16 -- one concept, one name.Measured on this branch, merging current
mainand then !19's change:Nine failures, all
KeyError: 'client_id', including:Every failure is the same cause and the fix is one token:
Read
create_client(...)'s result under["id"], not["client_id"].Only the RETURN VALUE changed. A parameter named
client_id-- as inget_client(client_id)ordebit(client_id, ...)-- is unchanged andcorrect. Do not rename those.
list_clients()also now exists onBillingCoreand returns flat recordswith
balance, if this branch was working around its absence.Why I did not merge this myself
delivery.pyimplements its ownauthorized()API-key check (hmac.compare_digest, fail-closed) and a webhook auth path. Changes touching auth are on this sweep's escalation list, so the merge decision is the owner's rather than mine. Theclient_idfix is yours and is independent of that.One thing worth raising while you are in here: this is the third independent API-key implementation among the open PRs (
server.pyin !13,intake_factory.pyin !17,delivery.pyhere). Three hand-rolled auth checks in one small service is a consolidation candidate, not a style nit -- I have noted it for a follow-up rather than asking you to fix it in this PR.[worker-bounce-fix attempt 2]
Bounce finding (2026-08-25 17:27): main moved under this PR — !19/#16 renamed
create_client's returned keyclient_id->idand flattenedget_client(record no longer nested underclient); measured on the branch: 9 failed (KeyError: 'client_id'), 36 passed.Fix (commit
a991d13onissue-7, 2 files, +2/-2 — delivery.py + tests/test_delivery.py only, per the issue #7 allowed set):delivery.py_tier_rate():get_clientresult read as the flat record —details.get("tier")instead of(details.get("client") or {}).get("tier"). Parameters namedclient_id(debit,get_client, webhook payload field) left exactly as they were, per the finding.tests/test_delivery.pymake_client(): readscreate_client's result under["id"]instead of["client_id"].origin/mainmerged into the branch so the suite runs against the current corebilling_core(identical to main's after the merge — not part of the PR diff).Evidence:
pytest -q-> 45 passed (the 9KeyErrorfailures are gone; previous 42-gate + 3 for main's #16 tests);git diff --checkclean; re-grep of delivery/tests shows no["client_id"]record reads. Post-push verification: PR !18 state=open, merged=false, head=issue-7@a991d13, mergeable=true.The API-key consolidation (delivery.py/intake_factory.py/server.py) was explicitly noted as a follow-up by the reviewer — not touched in this PR.
Verified green against the merge result - and deliberately NOT merged: this PR is auth
Delivery receipts (#7)
origin/main@bcf03bd+16.
delivery.py:138useshmac.compare_digest, and the comment at 127-128 explaining why is the reason the rest of this repo has a convention to converge on at all.Escalated because it debits a client balance on merge. Idempotency is the whole point of the design and it is implemented, but 'money moves' plus 'auth check' in one PR is the clearest case in the cluster for an owner decision rather than a reviewer's.
Why it is not merged
This sweep escalates rather than acts when a PR touches authentication or credentials, and does not merge what it has escalated. That is not a comment on the quality of this PR - the gate is green, the merge is clean, and I found no defect in it.
gitea-saas-coreis a billing service: it holds an API key that authorizes debits, a Stripe secret, and a Gitea token that can create repositories. Six PRs currently open each add or modify a piece of that surface, and #24 already records what happens when they are reviewed one at a time - the service reached five independent implementations of one X-API-Key check before anyone counted them, and the fifth (!22, now bounced) was not constant-time.So the decision I am escalating is not "is this PR correct" but "in what order, and under what auth design, should this cluster land" - which is the owner's call, not a reviewer's.
What the owner needs to decide
One question: may this sweep merge the auth-bearing gitea-saas-core PRs on a green gate, or do they wait for a single reviewed auth path (#24) first?
If the answer is "merge them", the measured, collision-safe order is below and I can execute it next sweep. If the answer is "consolidate first", #24 needs its
blockedlabel cleared and re-pointed, because it is currently blocked on the very PRs that are waiting on it - a deadlock I have described on that issue.The file-collision facts, so the ordering decision is cheap
I merged all seven open PRs into throwaway trees rather than trusting seven green
mergeableflags. Measured overlap across the whole set:That is the only collision. !11, !17, !18, !20 and !22 are file-disjoint from each other and from everything else, so they can land in any order. Only !13 and !15 must be sequenced, and whichever goes second rebases onto the other's
server.pyauth block.Merged by the sweep after re-gating against the actual merge result (
pytest -q tests-> 128 passed in 9.37s). Auth path reviewed: this PR's X-API-Key check useshmac.compare_digest. Consolidation into a single shared helper is tracked separately.