feat(backend): Firebase token verification + async render jobs - #21
Merged
Conversation
Ports two already-merged Cinemory capabilities (same owner, MIT, shared B2/
provenance foundation) into ClaimScene, laying the foundation for a later
multitenancy PR:
1. claimscene.auth — standalone RS256 Firebase ID-token verification against
Google's public x509 certs (no Admin SDK, no service-account secret).
Hardcoded RS256 allow-list checked before any key lookup blocks alg:none
and the RS256/HS256 confusion attack. Not wired into api.py yet.
2. claimscene.jobs + async render endpoints — a storage-backed submit+poll
job store (jobs/<id>/status.json, same StorageBackend the app already
uses) plus POST /cases/render/jobs (202 + job_id) and
GET /cases/render/jobs/{job_id} alongside the existing synchronous
POST /cases/render. The two-step illustration generation (still, then
image-to-video clip) can take minutes; async submit+poll avoids the
Firebase Hosting proxy's ~60s cap. The worker thread gets an isolated
storage instance in live mode (own B2Storage per job) so it never shares
mutable adapter state with a request thread.
_run_render and render_case gain a pure extract-method refactor
(_prepare_render, an optional storage param) — behavior-preserving for the
existing synchronous caller. deploy-cloudrun.sh gains --no-cpu-throttling so
the background worker gets CPU between polls; CLOUDRUN.md documents the
honest limitation (in-process worker, no external queue).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ports two already-built, already-merged capabilities from Cinemory (same owner, MIT, shared B2/provenance foundation) into ClaimScene. Both are the foundation for ClaimScene multitenancy, which a later PR adds — this PR does not wire tenant scoping into any route.
src/claimscene/auth.py— standalone RS256 Firebase ID-token verification against Google's public x509 certs (securetoken@system.gserviceaccount.com). No Firebase Admin SDK, no service-account secret. A hardcoded("RS256",)allow-list is checked against the token header before any key lookup, blocking bothalg: noneand the RS256→HS256 algorithm-confusion attack.project_iddefaults fromFIREBASE_PROJECT_ID(public value). Not wired intoapi.py— nothing imports it yet, matching Cinemory's own PR #33 sequencing.src/claimscene/jobs.py+ async render endpoints — a storage-backed submit+poll job store (jobs/<job_id>/status.json, through the sameStorageBackendthe app already uses — B2 live,InMemoryStorageoffline/tests) plusPOST /cases/render/jobs(202 +job_id) andGET /cases/render/jobs/{job_id}alongside the existing synchronousPOST /cases/render.POST /cases/renderruns a real two-step generation (an establish-shot still, then an image-to-video clip chained from it) that can take minutes — longer than the Firebase Hosting proxy's ~60s cap — so a caller that can't hold one request open that long submits a job and polls it instead.Strictly additive
/health,/scenarios,POST /cases/extract,POST /cases/render, and every other existing route keep their exact current behaviour. The async routes sit alongside the sync one._run_rendergained one optionalstoragekwarg (defaultNone→ resolves to the module-level_storage, byte-identical for the existing caller).render_casenow delegates its validation to a new_prepare_renderhelper — a pure extract-method refactor shared with the new async submit endpoint, so both run the identical ingest + 422-vocabulary checks before any generation starts.git diff --name-only --diff-filter=M -- tests/is empty.tests/security/,DISCLOSURE/WATERMARK,provenance.py, the SceneGraph schema, and the illustration prompts are untouched.Worker-thread storage safety
Mirrors Cinemory's
_job_storage()reasoning:InMemoryStorage(offline/tests) is shared with the module-level_storage(safe — plain dict/list mutation under the GIL, no read-modify-write cycle). In live mode the worker gets its own freshB2Storage()instance per job (constructor re-readsindex.jsonl, so nothing durable is lost), so a background thread never shares a mutable client/index with a request thread.One flagged adapter difference (per the task's explicit ask to report rather than silently fix): Cinemory's
B2Storage._persist_index()does a remote read-modify-write merge by key before every write (added after a live incident). ClaimScene'sB2Storage._persist_index()(this repo) currently persists a blind snapshot of its own localindexwith no such re-read/merge step, so two independentB2Storageinstances writing around the same time can still last-writer-wins clobber each other's index rows. Giving the job its own instance still helps (its writes are no longer interleaved with a request thread sharing the exact same mutable object) but does not eliminate that pre-existing gap. Documented in_job_storage's docstring; porting Cinemory's merge-on-write fix is flagged as a separate follow-up (not in this PR's scope).Deploy
deploy/deploy-cloudrun.shgains--no-cpu-throttling(background worker needs CPU between polls). No other change to that script — the Secret Manager hardening is untouched.deploy/CLOUDRUN.mddocuments the flag + an "Async job submission" section mirroring the existing--timeout 600note's style, including the honest in-process-worker limitation.New tests only
tests/unit/test_auth.py— the full adversarial suite (valid, expired, wrong aud/iss, wrong signing key,alg: none, hand-crafted HS256-confusion, missingsub, unknownkid,uid_from_authorizationmatrix, cache helpers).tests/unit/test_jobs.py— job-store unit tests (create/read/transitions, corrupt→None, hostile id stays namespaced).tests/e2e/test_render_jobs.py(ClaimScene's own tier convention for TestClient-driven tests, mirroring Cinemory'stests/integration/test_api_jobs.py) — submit→poll→done with a real sealed case result, invalid scene → 422 synchronously (before any job is created), malformed scene JSON → 422, unknown scenario → 404, unknown job id → 404, corrupt status → 404 not 500, forced failure → statusfailednot 500, and an uploaded-photos variant. An autouse fixture gives this file's tests their own isolatedInMemoryStorageso job-status keys never land in the shared module-level index other test files scan for content-addressing invariants (those scans are already case-id-scoped so not actually at risk, but this removes the pollution class entirely and keeps the new tests order-independent).Test plan
pytest tests/unit/test_auth.py tests/unit/test_jobs.py— 57 passedpytest tests/e2e/test_render_jobs.py— 9 passed (run 3x locally to confirm stability; the offline path here shells out to a realffmpegsubprocess for schematic animation when ffmpeg is on PATH, unlike Cinemory's pure in-memory fakes, so the poll timeout is a generous 30s)pytest --cov=src --cov-fail-under=90 tests/) — all tests pass, 97.28% coverage;auth.pyandjobs.pyboth 100%ruff check src tests scripts— cleanpytest tests/security -v— 57 passed unmodifiedpython scripts/readiness.py --min 95— 100% automatable (gate 95%)pip-audit -r requirements.txt --strict— no known vulnerabilitiesgit diff --name-only --diff-filter=M -- tests/— empty (zero existing tests modified)🤖 Generated with Claude Code