The root route lived only in run.py (dev server), so gunicorn wsgi:app
never registered it and the bare domain 404'd in every deployment. Move
it into create_app so dev and prod both redirect / to the login page.
The full-key reveal modal and its click/copy handlers referenced the
plaintext key display that was dropped when keys became hashed. They
matched no elements (harmless no-ops), so remove the orphaned modal
markup and handlers; toggle/delete handlers are untouched.
Keys are now stored as a SHA-256 digest in the existing key column (64
chars, no schema change). Lookups hash the presented key; rows still
holding plaintext are matched and upgraded to their hash on first use.
The full key is shown once at creation and the dashboard displays a
masked placeholder thereafter.
docker/Dockerfile used 3.14-slim and deploy/Dockerfile used 3.11-slim.
Unify on 3.12 — the version the CI matrix tests against and the local
venv runs — so the image matches what's actually validated. Update the
docs that referenced 3.11 to match.
- run.py and the __init__ SocketIO comment referenced the eventlet
worker; production uses gthread. Copy-pasting the old run.py guidance
would have redeployed the exact config wsgi.py warns against.
- README/CLAUDE tech-stack tables said Flask 2.3.3 (actual: 3.1.3);
CLAUDE architecture tree listed a rule_cache.py that doesn't exist.
- Drop the per-request token count that was computed and stored on the
content object but never read (the AI paths recount as needed).
- Remove the conflicting-AI-decisions manual-review branch: the pipeline
early-exits on the first AI match, so it could never see >1 AI result.
- Read/reset the process-global cache-store counter under the cache lock
so it can't tear against the locked increment.
- Resolve the /api/moderate rate-limit bucket from the request's API key
directly (hashed) instead of request.api_key, which isn't populated yet
at the limiter layer — so per-key limits actually apply rather than
always falling back to per-IP.
- Bound registration password length to match the login form (8-200),
so an over-long password can't register an account that can never log in.
- Flash generic messages on admin user/data operation failures and log
the detail server-side instead of surfacing raw exception text.
- Only allow manual decisions on content still awaiting review (status
'flagged'), matching the bulk path, so a decided item isn't overwritten.
- Build the manual-review API-user search's accessible-project list by
membership rather than ownership, so member-only users get results.
- Make API-key and API-user usage counters atomic (single UPDATE ...
SET col = col + 1) so concurrent moderations for the same key/user
don't lose increments to a read-modify-write race.
- Persist each moderation outcome (content status + user counters +
result rows) in a single transaction via save_moderation_outcome,
replacing three independent commits plus a no-op commit that could
leave a decided status with no result rows on partial failure.
- Include rule.action in the batched AI cache fingerprint and clear the
shared result cache when a rule is created/updated/toggled/deleted, so
edits take effect immediately instead of after the 1h TTL.
- Flag (not approve) content that exceeds the per-request chunk cap:
partial coverage must not yield a clean approval.
Applies the two remaining Dependabot bumps (#85, #83) directly; their
PR branches had stale merge bases and conflicted after the dev history
rewrite. Supersedes both PRs.
No longer used; the job failed on every PR because the CLAUDE_API_KEY
secret is unset, producing no findings.json and a red X on all open
Dependabot PRs. Other security checks (CodeQL, Snyk, bandit in ci.yml)
remain in place.
Bots and link prefetchers hit /auth/google/callback directly with no
OAuth session, making Authlib raise mismatching_state. The broad
except Exception logged it at error level, promoting every crawl into a
Sentry issue (SENTRY#1386). Catch OAuthError specifically and log at
info so it stays a breadcrumb; genuine flow failures still fall through
to the error-level handler.
engineio/async_drivers/_websocket_wsgi.py intentionally raises
StopIteration after a successful WebSocket upgrade to tell gunicorn
"I'm keeping this connection open, don't finalize the response." It's a
protocol signal, not a real error, but Sentry's WSGI instrumentation
captures it anyway and it gets reported as AUTOMOD-3E.
Only drop it on the /socket.io/ path so we don't accidentally hide a
genuine StopIteration bubbling up from elsewhere.
Cloud Run was 500-ing every async route on the eventlet worker:
RuntimeError: asyncio.run() cannot be called from a running event loop
RuntimeError: You cannot use AsyncToSync in the same thread as an
async event loop - just await the async function directly.
Root cause: standard Flask decorators (@login_required from Flask-Login,
@limiter.limit from Flask-Limiter, and Flask's own async-view wrapper)
call ``flask.current_app.ensure_sync(obj)(*a, **k)``, which uses
``asgiref.AsyncToSync``, which calls ``asyncio.run()``. Under eventlet's
greenlet hub ``asyncio.run()`` raises because the hub *is* the running
loop. Every ``async def`` route wrapped by any of those decorators (i.e.
all of /api/moderate, /auth/*, /dashboard/*) dies at the decorator
boundary before the view body ever executes. Sentry AUTOMOD-3B/3C/3D.
The dev server didn't show this because werkzeug gives each request a
fresh OS thread with no existing event loop, so asgiref can spin up its
own loop fine.
gthread worker + SOCKETIO_ASYNC_MODE=threading:
- Real OS threads for each request → asgiref's AsyncToSync works.
- Flask-SocketIO threading mode supports WebSockets for our
dashboard-scale realtime traffic.
- Dev (run.py) and prod now use the same async mode, so behaviour
matches end-to-end.
- 50 threads/worker = plenty of concurrency for HTTP + long-lived
WebSocket connections.
Dropped eventlet and psycogreen from requirements.txt — they were pinned
specifically for the eventlet worker, neither is used any more.
Cloud Run workers were dying with a TypeError in sentry_sdk.init because
`enable_logs=True` isn't a valid kwarg until sentry-sdk 2.40. Our Phase 0
pin of 2.18.0 was older than the kwarg. Two defences:
- Bump sentry-sdk[flask] to >=2.40.0,<3.0.0 so the kwarg works.
- Catch TypeError in create_app() and fall back to sentry_sdk.init without
enable_logs, so future kwarg churn across sentry-sdk versions can't
brick the whole app at boot — error reporting still initialises, we
just log a warning and lose the logs feature.
The eventlet "10 RLock(s) were not greened" warning in those Cloud Run
logs is not the crash cause; gunicorn's eventlet worker monkey-patches
after its own import of gunicorn internals, and the warning is benign
for our workload. The sentry-sdk TypeError was the actual blocker.
Rolls up the open dependabot PRs #65, #66, #69, #72, #73, #74, #75, #76
into a single commit. Each bump is a minor/patch release with no breaking
changes relevant to this codebase; all chat.completions / Flask-SocketIO
/ pydantic-v1-validator usage continues to work.
- Flask[async] 3.1.2 -> 3.1.3 (#65, #72)
- Flask-SocketIO 5.6.0 -> 5.6.1 (#66)
- Werkzeug 3.1.5 -> 3.1.8 (#73; supersedes master-targeting #67
which wanted 3.1.6)
- python-dotenv 1.2.1 -> 1.2.2 (#69)
- requests 2.32.5 -> 2.33.1 (#74)
- pydantic 2.12.5 -> 2.13.2 (#76)
- Authlib 1.6.8 -> 1.7.0 (#75)
Doing this as one commit on dev because dev has diverged from master
(Phase 0 + Phase 1 security/architectural work) and each dependabot PR
has a requirements.txt conflict against the new pins we added — resolving
8 of those by hand is more churn than just bumping the versions directly.
'minimal' is only valid on older gpt-5 / o-series models and is rejected
by gpt-5.4 with a 400 ("Supported values are: 'none', 'low', 'medium',
'high', 'xhigh'"). Our fail-safe silently approved every request when the
API errored, which presented as "moderation is fast but broken" in real
tests — scam / phishing / misinfo content was getting through.
'low' is the lowest common value accepted by every reasoning model in the
family so the default works regardless of which model you point the
OPENAI_CHAT_MODEL at. Empirically verified: 8/8 real-world classification
accuracy on gpt-5.4-nano with reasoning=low, vs 2/4 with reasoning=none
(false negatives on obvious scams).