Slice 8 WIP: §12 hygiene + §10.7 + routing + rollback cleanup
- Add §12 30/90 hygiene scheduler in hygiene.py, mirroring the
DigestScheduler shape; wires next to digest in main.py with the
same start/stop/run_tick test seam.
- Extend bot.delete_branch to accept actor=None for system gestures,
per §15.9 (actor_user_id=NULL, on_behalf_of=bot_login).
- Convert every branches/{branch} route in api_branches.py and
api_prs.py to {branch:path}; move the bare GET to the bottom of
the router so deeper GETs match before greedy-path swallow.
- Extend api_prs.py's _require_pr to accept pr_kind='meta_metadata'
so the §9.5 metadata-pane PRs land an in-app merge.
- Graduation rollback now deletes the graduate-<slug>-<6hex> branch
after closing the PR — §19.2 candidate that lands here.
- Email-bounce webhook gains a WEBHOOK_EMAIL_BOUNCE_SECRET seam.
- FakeGitea grows a DELETE /branches/{branch:path} handler and a
slashed-branch read; integration tests for the hygiene vertical
cover the 30d close, 90d delete, post-merge delete, pinned
exemption, per-user cursor preservation, no-notification rule,
and the graduation-rollback cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -390,11 +390,27 @@ def make_router(config: Config) -> APIRouter:
|
||||
)
|
||||
|
||||
@router.post("/api/webhooks/email-bounce")
|
||||
async def email_bounce(body: BounceBody) -> dict[str, Any]:
|
||||
async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
|
||||
# §15.4: hard bounces and complaints flip the global opt-out.
|
||||
# The webhook is unauthenticated here for v1 — the SMTP provider's
|
||||
# callback URL is the contract. Tighten with a signing secret
|
||||
# when an actual provider is wired in.
|
||||
# Per the §19.2 "email bounce webhook authentication" candidate
|
||||
# Slice 8 settles: when `WEBHOOK_EMAIL_BOUNCE_SECRET` is set in
|
||||
# env, the webhook requires the same value in the
|
||||
# `X-Webhook-Secret` header. The shared-secret shape is the
|
||||
# narrowest seam that covers the major providers — Sendgrid's
|
||||
# `X-Twilio-Email-Event-Webhook-Signature`, SES via SNS topic
|
||||
# signatures, Postmark's HTTP basic auth — without forcing a
|
||||
# per-provider verifier today. When the operator wires a real
|
||||
# SMTP provider they pick the equivalent shared-secret or
|
||||
# rotate the value behind whichever signature scheme the
|
||||
# provider supports. When the env var is unset the webhook
|
||||
# stays unauthenticated for dev (the v1 contract).
|
||||
import os as _os
|
||||
expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
|
||||
if expected:
|
||||
received = request.headers.get("X-Webhook-Secret", "")
|
||||
import hmac as _hmac
|
||||
if not received or not _hmac.compare_digest(expected, received):
|
||||
raise HTTPException(401, "Invalid webhook signature")
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
|
||||
).fetchone()
|
||||
|
||||
Reference in New Issue
Block a user