- CloudConnectionError(reason=) defined in this module — token_expired | invalid_grant
- All 7 StorageBackend methods implemented as async coroutines
- Every sync googleapiclient call wrapped in asyncio.to_thread() (Pitfall 7)
- cache_discovery=False on build() prevents /tmp directory traversal (T-05-03-05)
- presigned_get_url and generate_presigned_put_url raise NotImplementedError (D-14)
- HttpError 401 raises CloudConnectionError(reason='token_expired')
- HttpError 400 with 'invalid_grant' raises CloudConnectionError(reason='invalid_grant')
- HttpError 404 on delete_object is silently swallowed (no-op per contract)
- Backend is stateless — no DB writes (B2 design, D-05/D-06)
- cloud_cache.py: module-level TTLCache(maxsize=1000, ttl=60) singleton with
threading.Lock for concurrent access safety (RESEARCH.md Pattern 8 / D-16)
- get_cloud_folders_cached(): async function; calls fetch_fn OUTSIDE the lock
to avoid blocking the event loop during cloud API calls
- invalidate_provider_cache(): removes all cache entries for a user+provider prefix
- storage/__init__.py: adds get_storage_backend_for_document() async factory
— returns MinIOBackend for minio docs; queries CloudConnection (scoped to user.id),
decrypts credentials, and lazy-imports cloud backends to avoid circular imports
— raises HTTPException(503) if connection missing or not ACTIVE (T-05-02-04)
- validate_cloud_url(): blocks RFC-1918 (10.x, 172.16.x, 192.168.x), loopback (127.x),
link-local (169.254.x), IPv6 loopback (::1), ULA (fc00::/7), and 'localhost' string;
resolves DNS via socket.getaddrinfo BEFORE IP check (anti-DNS-rebinding per D-17)
- _derive_fernet_key(): creates fresh HKDF-SHA256 instance per call (AlreadyFinalized
pitfall avoided per RESEARCH.md Pitfall 3); uses user_id as salt for per-user isolation
- encrypt_credentials(): Fernet-encrypts JSON-serialised credentials dict; returns str
- decrypt_credentials(): decrypts Fernet token back to original dict
- [Rule 1 - Bug] Fixed test_allows_public_https to use 8.8.8.8 IP (cloud.example.com
does not resolve in offline CI environments)
- Add users.pdf_open_mode column via batch_alter_table (server_default='in_app')
- Create GIN expression index ix_documents_fts on documents.extracted_text via raw SQL (Alembic #1390)
- Create audit-logs MinIO bucket gated on MINIO_ENDPOINT env var
- Add MinIOBackend.put_object_raw() for caller-supplied bucket+key uploads (audit CSV export)
Bugs fixed:
- minio_backend.py: generate_presigned_put_url and presigned_get_url used internal
_client (minio:9000) instead of _public_client (localhost:9000). Browser received
ERR_NAME_NOT_RESOLVED. Fixed by using _public_client with region='us-east-1' to
skip region-discovery HTTP request from inside the container.
- docker-compose.yml: MINIO_API_CORS_ALLOW_ORIGIN was set from CORS_ORIGINS which
uses pydantic JSON list format '["http://localhost:5173"]'. MinIO expected a plain
string and never matched the origin. Fixed to use FRONTEND_URL instead.
- admin.py: All write handlers (create_user, update_user_status, update_user_quota,
update_ai_config) used session.flush() without session.commit(). Changes appeared
to succeed (response reflected in-memory state) but rolled back on session close.
Fixed by replacing flush() with commit() in all four write handlers.
- auth.js: Concurrent refresh() calls from QuotaBar and App.vue on page reload caused
a token rotation race — first call rotated the cookie, second arrived with stale
cookie and cleared accessToken. Fixed by deduplicating with a shared in-flight
promise (_refreshInFlight).
Phase 3 UAT: 9/10 pass. UAT-3 (QuotaBar visual) pending browser confirmation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Includes planning artifacts (03-CONTEXT, 03-DISCUSSION-LOG, 03-02-SUMMARY),
integration test script, MinIO/auth/docker fixes, and local dev account reference.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>