gitea-saas-core#41: client-facing surface — self-serve funding and work submission #51
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#51
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "issue-41"
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?
gitea-saas-core#41: Client-facing surface — self-serve funding and work submission
Closes #41.
Implements the first genuinely externally-reachable surface, per the owner
decision (2026-09-05): no public launch, no real payments, no production
ledger changes — feature, tests, one PR.
What landed
ClientKeyStore,gitea_saas/client_api.py+schema.sql): randomsaas_...keys, scoped to exactly oneclient_id,stored ONLY as a SHA-256 hash (plaintext returned once at issuance, never
written, never logged), revocable. Issued by the operator at
POST /api/clients/<id>/keys(operator-guarded); revoke one or all.Hashing lives in
auth.py(hash_client_key); verification isdigest-equality against the key table — deliberately no second
compare_digestcall site (the #24 pinauth.py:70/stripe_client.py:88is untouched,
tests/test_auth.pyunchanged).POST /api/checkout(client key): Stripe Checkout URL via theexisting mockable seam, rate-limited per client (429 + Retry-After).
POST /api/webhooks/stripe(signature-authenticated, inPUBLIC_PATHS): reusesstripe_client.apply_payment_event— the issue-#3idempotent credit. No second credit path; replayed events credit exactly
once (asserted).
GET /api/me: balance, tier, derived status(active/locked/overdue), queued items, in-flight items, delivered count —
always the caller's own client_id.
POST /api/me/issues: submits as thecaller's OWN client_id through the issue-#4 factory (funded →
Gitea issue, unfunded/locked → queued). The factory module is untouched.
auth.pyFORBIDDEN_BODY + compound assembly guard inclient_api.register_client_routes): a valid client key returns thecanonical 403 on every operator route (
/api/clients,/api/factory/*,/dashboard, key issuance) and on other clients' resources; the operatorkey never works on the client surface (401);
PUBLIC_PATHSliveness andthe fail-closed 503-unconfigured gate behave exactly as before
(equivalent operator-route semantics, same canonical 401).
client_api.py(new),auth.py(extend),schema.sql,tests/test_client_api.py(new, 18tests),
app.py+tests/test_app.py(mount + assembled-app reachability;note the #38 assembly test file is named
test_app.pyin the merged tree).No other files changed.
Acceptance evidence
/api/factory/*, B's/api/clients/<id>ledger/credits/keys,/api/clients,/api/factory/*,/dashboard(canonical FORBIDDEN body) —test_cross_tenant_and_operator_denials,test_assembled_app_authenticated_client_path_and_denials.—
test_keys_stored_hashed_plaintext_never_in_db.webhook credits exactly once —
test_checkout_webhook_credit_exactly_once,test_assembled_app_....python -m pytest -q).Also covered: checkout rate limit (429), webhook signature rejection (400,
no detail leak), Stripe-unconfigured fail-closed 503 (checkout + webhook),
revoked keys stop resolving, revoke is client-scoped,
/api/mecounts,/api/me/issuesvalidation, operator key never reaches the client surface,no
SAAS_API_KEY→ client surface 503s too.No keys, Stripe secrets, or webhook payloads are logged anywhere in the
new code.
[worker] Implemented — PR #51 ready for manual review.
Branch:
issue-41@f39343ea(basemain2ef1e66). Files: exactly the issue's owned set (client_api.py new, auth.py, schema.sql, app.py, tests/test_client_api.py new, tests/test_app.py). 6 files, +1467/-21.Acceptance evidence (all hermetic, mock Stripe/Gitea, no network):
/api/factory/*, B's clients ledger/credits/keys,/api/clients,/api/factory/*,/dashboard→ canonical FORBIDDEN body (tests/test_client_api.py::test_cross_tenant_and_operator_denials + test_app.py assembled-app test).python -m pytest -q→ 293 passed, 1 skipped.Also: per-client checkout rate limit (429 + Retry-After), webhook signature rejection with no detail leak, Stripe-unconfigured fail-closed 503, revoked keys stop resolving, revoke is client-scoped, operator key 401s on the client surface,
auth.pycomparison pin (auth.py:70 / stripe_client.py:88) untouched,tests/test_auth.pyunmodified.Guard note for review: mounting the client surface replaces the billing app's operator before_request with a compound guard (client tenancy → operator check with identical semantics). No keys/Stripe secrets/webhook payloads are logged.
Test command:
python -m pytest -q(293 passed, 1 skipped).Reviewed by Claude Opus (reviewer/merge gate).
Gate: PASS — pytest, run against the PR head in an isolated worktree.
Owner-approved (auth-touching change, escalated before merge).
Security review of
auth.py:/api/webhooks/stripejoins PUBLIC_PATHS, correct only because the route is signature-authenticated — confirmed atstripe_client.py:155via the officialstripe.Webhook.construct_event(payload, signature, webhook_secret). Tests cover a wrong signature, a replay, and a 503 fail-closed when the secret is unconfigured. Per-client keys are SHA-256 hashed, never stored plaintext, and #24's single-compare_digestinvariant is preserved.