Skip to content

feat(backend): Firebase token verification + async render jobs - #21

Merged
upgradedev merged 1 commit into
mainfrom
feat/auth-and-async-render
Jul 29, 2026
Merged

feat(backend): Firebase token verification + async render jobs#21
upgradedev merged 1 commit into
mainfrom
feat/auth-and-async-render

Conversation

@upgradedev

Copy link
Copy Markdown
Owner

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 both alg: none and the RS256→HS256 algorithm-confusion attack. project_id defaults from FIREBASE_PROJECT_ID (public value). Not wired into api.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 same StorageBackend the app already uses — B2 live, InMemoryStorage offline/tests) plus POST /cases/render/jobs (202 + job_id) and GET /cases/render/jobs/{job_id} alongside the existing synchronous POST /cases/render. POST /cases/render runs 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_render gained one optional storage kwarg (default None → resolves to the module-level _storage, byte-identical for the existing caller). render_case now delegates its validation to a new _prepare_render helper — 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.
  • Zero existing test files touched — 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 fresh B2Storage() instance per job (constructor re-reads index.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's B2Storage._persist_index() (this repo) currently persists a blind snapshot of its own local index with no such re-read/merge step, so two independent B2Storage instances 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.sh gains --no-cpu-throttling (background worker needs CPU between polls). No other change to that script — the Secret Manager hardening is untouched.
  • deploy/CLOUDRUN.md documents the flag + an "Async job submission" section mirroring the existing --timeout 600 note'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, missing sub, unknown kid, uid_from_authorization matrix, 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's tests/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 → status failed not 500, and an uploaded-photos variant. An autouse fixture gives this file's tests their own isolated InMemoryStorage so 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 passed
  • pytest tests/e2e/test_render_jobs.py — 9 passed (run 3x locally to confirm stability; the offline path here shells out to a real ffmpeg subprocess for schematic animation when ffmpeg is on PATH, unlike Cinemory's pure in-memory fakes, so the poll timeout is a generous 30s)
  • Full combined suite (pytest --cov=src --cov-fail-under=90 tests/) — all tests pass, 97.28% coverage; auth.py and jobs.py both 100%
  • ruff check src tests scripts — clean
  • pytest tests/security -v — 57 passed unmodified
  • python scripts/readiness.py --min 95 — 100% automatable (gate 95%)
  • pip-audit -r requirements.txt --strict — no known vulnerabilities
  • git diff --name-only --diff-filter=M -- tests/ — empty (zero existing tests modified)

🤖 Generated with Claude Code

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>
@upgradedev
upgradedev merged commit 6ffbdc9 into main Jul 29, 2026
11 checks passed
@upgradedev
upgradedev deleted the feat/auth-and-async-render branch July 29, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant