feat: SaaS foundation for CrowdSight

Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
This commit is contained in:
Kunthawat Greethong
2026-08-31 13:05:21 +07:00
parent 89d04e795b
commit 8b84378fe1
165 changed files with 15884 additions and 4001 deletions

View File

@@ -0,0 +1,43 @@
import os
import pytest
from app import create_app
from app.config import Config
from app.models.task import TaskManager
class TestConfig(Config):
SECRET_KEY = "test-secret"
MEMORY_BACKEND = "local"
CORS_ALLOWED_ORIGINS = ["https://allowed.example"]
TESTING = True
def test_cors_uses_app_allowlist_and_credentials():
previous = os.environ.get("DATABASE_URL")
os.environ["DATABASE_URL"] = "sqlite+pysqlite:///:memory:"
try:
app = create_app(TestConfig)
response = app.test_client().options(
"/api/graph/project/list",
headers={
"Origin": "https://allowed.example",
"Access-Control-Request-Method": "GET",
},
)
assert response.headers["Access-Control-Allow-Origin"] == "https://allowed.example"
assert response.headers["Access-Control-Allow-Credentials"] == "true"
finally:
if previous is None:
os.environ.pop("DATABASE_URL", None)
else:
os.environ["DATABASE_URL"] = previous
def test_cors_wildcard_is_rejected_with_cookie_auth():
class WildcardConfig(TestConfig):
CORS_ALLOWED_ORIGINS = ["*"]
with pytest.raises(RuntimeError, match="wildcard_cors_not_allowed"):
create_app(WildcardConfig)