diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b132a4a..f7d6036 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,7 @@ -name: Build & Push +# SUPERSEDED by release.yml — CalVer tagging with stable/dev channels. +# This workflow is kept for backward compatibility but only runs tests. +# Image build and push is handled by release.yml. +name: Build & Push (legacy) on: push: @@ -24,27 +27,3 @@ jobs: run: pytest tests/ -v --tb=short env: TESTING: "1" - - build-and-push: - needs: test - runs-on: ubuntu-latest - permissions: - packages: write - contents: read - steps: - - uses: actions/checkout@v5 - - - name: Log in to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v7 - with: - push: true - tags: | - ghcr.io/${{ github.repository }}:latest - ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f377c05 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,130 @@ +name: Release + +on: + push: + branches: [main, "feature/**"] + +permissions: + contents: write + packages: write + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv pip install --system ".[dev]" + + - name: Run tests + run: pytest tests/ -v --tb=short + env: + TESTING: "1" + + build-and-push: + needs: test + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.versioned_tag }} + version: ${{ steps.meta.outputs.version }} + channel: ${{ steps.meta.outputs.channel }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Determine channel and version + id: meta + run: | + YEAR_MONTH=$(date +%Y.%m) + + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + CHANNEL="stable" + else + CHANNEL="dev" + fi + + # Count existing tags for this channel+month to get next N + EXISTING=$(git tag -l "${CHANNEL}-${YEAR_MONTH}.*" | wc -l | tr -d ' ') + N=$((EXISTING + 1)) + VERSION="${YEAR_MONTH}.${N}" + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + + echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "versioned_tag=${CHANNEL}-${VERSION}" >> "$GITHUB_OUTPUT" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + echo "Channel: ${CHANNEL}" + echo "Version: ${VERSION}" + echo "Versioned tag: ${CHANNEL}-${VERSION}" + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + push: true + build-args: | + AGNES_VERSION=${{ steps.meta.outputs.version }} + RELEASE_CHANNEL=${{ steps.meta.outputs.channel }} + tags: | + ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.channel }} + ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.versioned_tag }} + ghcr.io/${{ github.repository }}:sha-${{ steps.meta.outputs.short_sha }} + + - name: Create git tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + TAG="${{ steps.meta.outputs.versioned_tag }}" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" || echo "Tag $TAG already exists, skipping" + + smoke-test: + needs: build-and-push + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Start Agnes from built image + run: | + # Override image to use the just-built version + export AGNES_IMAGE="ghcr.io/${{ github.repository }}:${{ needs.build-and-push.outputs.image_tag }}" + sed -i "s|build: \.|image: ${AGNES_IMAGE}|g" docker-compose.yml + docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d + # Wait for healthy (max 60s) + timeout 60 bash -c 'until curl -sf http://localhost:8000/api/health | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d[\"status\"]!=\"unhealthy\" else 1)"; do sleep 3; done' + + - name: Run smoke tests + run: bash scripts/smoke-test.sh http://localhost:8000 + + - name: Collect logs on failure + if: failure() + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml logs > smoke-test-logs.txt + + - name: Upload logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: smoke-test-logs + path: smoke-test-logs.txt + + - name: Teardown + if: always() + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..180ecee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to Agnes AI Data Analyst are documented in this file. + +Format: [CalVer](https://calver.org/) `YYYY.MM.N` with channels `stable` and `dev`. + +--- + +## stable-2026.04.1 (unreleased) + +Multi-instance deployment and self-service setup. + +### Added +- CalVer versioning with `stable` and `dev` release channels +- `/api/health` now returns `version`, `channel`, and `schema_version` +- Auto-generated JWT and session secrets with file persistence (`/data/state/.jwt_secret`) +- Pre-migration snapshot of `system.duckdb` before schema upgrades +- `POST /api/admin/configure` for headless data source configuration +- `POST /api/admin/discover-and-register` combined table discovery and registration +- `/setup` web wizard for first-time instance setup +- `scripts/smoke-test.sh` for post-deploy verification +- Smoke test job in CI (Docker-in-CI after every release) +- OpenAPI snapshot test for breaking change detection +- Custom connector mount support (`connectors/custom/`) +- Startup banner logging version, channel, and schema version +- Schema migration safety tests (idempotency, data preservation, snapshot) +- `CHANGELOG.md` and release notes template + +### Breaking Changes +None. + +### Migration Guide +No action required. Existing instances upgrade seamlessly. diff --git a/Dockerfile b/Dockerfile index d5656c7..6cdbaa5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf # Install uv for fast dependency management COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +ARG AGNES_VERSION=dev +ARG RELEASE_CHANNEL=dev +ENV AGNES_VERSION=${AGNES_VERSION} +ENV RELEASE_CHANNEL=${RELEASE_CHANNEL} + WORKDIR /app # Copy application code diff --git a/Makefile b/Makefile index 332dfd6..025e635 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Agnes AI Data Analyst — Development Makefile -.PHONY: help test lint dev docker +.PHONY: help test lint dev docker update-openapi-snapshot help: @echo "Available targets:" @@ -20,3 +20,7 @@ docker: lint: @ruff check . 2>/dev/null || echo "ruff not installed: pip install ruff" + +update-openapi-snapshot: + TESTING=1 python scripts/generate_openapi.py > tests/snapshots/openapi.json + @echo "Snapshot updated. Review diff and commit." diff --git a/app/api/admin.py b/app/api/admin.py index 4d6a575..92b3386 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -1,7 +1,9 @@ -"""Admin endpoints — table discovery, registry management.""" +"""Admin endpoints — table discovery, registry management, instance configuration.""" import logging +import os import uuid +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -42,6 +44,16 @@ class UpdateTableRequest(BaseModel): profile_after_sync: Optional[bool] = None +class ConfigureRequest(BaseModel): + data_source: str # "keboola" | "bigquery" | "local" + keboola_token: Optional[str] = None + keboola_url: Optional[str] = None + bigquery_project: Optional[str] = None + bigquery_location: Optional[str] = None + instance_name: Optional[str] = None + allowed_domain: Optional[str] = None + + @router.get("/discover-tables") async def discover_tables( user: dict = Depends(require_role(Role.ADMIN)), @@ -144,3 +156,196 @@ async def unregister_table( if not repo.get(table_id): raise HTTPException(status_code=404, detail="Table not found") repo.unregister(table_id) + + +@router.post("/configure") +async def configure_instance( + request: ConfigureRequest, + user: dict = Depends(require_role(Role.ADMIN)), +): + """Configure data source and instance settings via API. + + Writes config to instance.yaml and persists secrets to .env_overlay. + AI agents and the /setup wizard use this instead of manual file editing. + """ + import yaml + + if request.data_source not in ("keboola", "bigquery", "local"): + raise HTTPException(status_code=400, detail="data_source must be 'keboola', 'bigquery', or 'local'") + + # Validate credentials if provided + if request.data_source == "keboola": + if not request.keboola_token or not request.keboola_url: + raise HTTPException(status_code=400, detail="keboola_token and keboola_url are required for Keboola data source") + try: + from connectors.keboola.client import KeboolaClient + client = KeboolaClient(token=request.keboola_token, url=request.keboola_url) + client.verify_token() + except Exception as e: + raise HTTPException(status_code=400, detail=f"Keboola connection failed: {e}") + + elif request.data_source == "bigquery": + if not request.bigquery_project: + raise HTTPException(status_code=400, detail="bigquery_project is required for BigQuery data source") + + # Build instance.yaml config (secrets as ${ENV_VAR} references) + config_dir = Path(os.environ.get("CONFIG_DIR", "./config")) + config_path = config_dir / "instance.yaml" + + # Load existing config or start fresh + existing = {} + if config_path.exists(): + try: + existing = yaml.safe_load(config_path.read_text()) or {} + except Exception: + existing = {} + + # Merge instance settings + if request.instance_name: + existing.setdefault("instance", {})["name"] = request.instance_name + + if request.allowed_domain: + existing.setdefault("auth", {})["allowed_domain"] = request.allowed_domain + + # Merge data source config (secrets as env var references) + existing["data_source"] = {"type": request.data_source} + if request.data_source == "keboola": + existing["data_source"]["keboola"] = { + "url": request.keboola_url, + "token_env": "KEBOOLA_STORAGE_TOKEN", + } + elif request.data_source == "bigquery": + existing["data_source"]["bigquery"] = { + "project": request.bigquery_project, + "location": request.bigquery_location or "us", + } + + # Write instance.yaml + config_dir.mkdir(parents=True, exist_ok=True) + config_path.write_text(yaml.dump(existing, default_flow_style=False, sort_keys=False)) + logger.info("Wrote instance config to %s", config_path) + + # Persist secrets to .env_overlay (in data volume, never in git) + secrets_to_persist = {} + if request.keboola_token: + secrets_to_persist["KEBOOLA_STORAGE_TOKEN"] = request.keboola_token + if request.keboola_url: + secrets_to_persist["KEBOOLA_STACK_URL"] = request.keboola_url + + if secrets_to_persist: + data_dir = Path(os.environ.get("DATA_DIR", "./data")) + overlay_path = data_dir / "state" / ".env_overlay" + overlay_path.parent.mkdir(parents=True, exist_ok=True) + + # Merge with existing overlay + existing_overlay = {} + if overlay_path.exists(): + for line in overlay_path.read_text().splitlines(): + if "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + existing_overlay[k.strip()] = v.strip() + existing_overlay.update(secrets_to_persist) + + overlay_path.write_text( + "\n".join(f"{k}={v}" for k, v in existing_overlay.items()) + "\n" + ) + try: + overlay_path.chmod(0o600) + except OSError: + pass + logger.info("Persisted %d secrets to .env_overlay", len(secrets_to_persist)) + + # Inject into current process environment + for k, v in secrets_to_persist.items(): + os.environ[k] = v + + # Invalidate cached instance config so next read picks up changes + import app.instance_config as ic + ic._instance_config = None + + return { + "status": "ok", + "data_source": request.data_source, + "connection": "verified" if request.data_source != "local" else "local", + } + + +def _discover_and_register_tables(conn: duckdb.DuckDBPyConnection, user_email: str) -> dict: + """Discover tables from configured source and register them. Shared logic for API and sync.""" + from app.instance_config import get_data_source_type, get_value + + source_type = get_data_source_type() + if source_type != "keboola": + return {"registered": 0, "skipped": 0, "errors": 0, "tables": [], "source": source_type} + + from connectors.keboola.client import KeboolaClient + url = get_value("keboola", "url", default="") + token = os.environ.get(get_value("keboola", "token_env", default="KEBOOLA_STORAGE_TOKEN"), "") + if not token: + token = os.environ.get("KEBOOLA_STORAGE_TOKEN", "") + + client = KeboolaClient(token=token, url=url) + discovered = client.discover_all_tables() + + repo = TableRegistryRepository(conn) + registered = 0 + skipped = 0 + errors = 0 + table_names = [] + + for table in discovered: + table_id = table.get("id", "").strip().lower().replace(".", "_").replace(" ", "_") + if not table_id: + errors += 1 + continue + + if repo.get(table_id): + skipped += 1 + continue + + try: + # Parse bucket from table ID (format: in.c-bucket.table_name) + parts = table.get("id", "").split(".") + bucket = parts[1] if len(parts) > 1 else "" + source_table = parts[2] if len(parts) > 2 else table.get("name", "") + + repo.register( + id=table_id, + name=table.get("name", table_id), + source_type="keboola", + bucket=bucket, + source_table=source_table, + query_mode="local", + registered_by=user_email, + description=f"Auto-discovered from Keboola: {table.get('id', '')}", + ) + registered += 1 + table_names.append(table_id) + except Exception as e: + logger.warning("Failed to register %s: %s", table_id, e) + errors += 1 + + return { + "registered": registered, + "skipped": skipped, + "errors": errors, + "tables": table_names, + "source": "keboola", + } + + +@router.post("/discover-and-register") +async def discover_and_register( + user: dict = Depends(require_role(Role.ADMIN)), + conn: duckdb.DuckDBPyConnection = Depends(_get_db), +): + """Discover tables from configured source and auto-register them. + + Combines discover-tables + register-table into one call. + Skips already-registered tables. Used by /setup wizard and AI agents. + """ + try: + result = _discover_and_register_tables(conn, user.get("email", "admin")) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=f"Discovery and registration failed: {e}") diff --git a/app/api/health.py b/app/api/health.py index e80ba17..2cc670e 100644 --- a/app/api/health.py +++ b/app/api/health.py @@ -1,11 +1,13 @@ """Health check endpoint — structured diagnostics for AI agents.""" +import os from datetime import datetime, timezone from fastapi import APIRouter, Depends import duckdb from app.auth.dependencies import _get_db +from src.db import SCHEMA_VERSION from src.repositories.sync_state import SyncStateRepository router = APIRouter(tags=["health"]) @@ -69,6 +71,9 @@ async def health_check(conn: duckdb.DuckDBPyConnection = Depends(_get_db)): return { "status": overall, + "version": os.environ.get("AGNES_VERSION", "dev"), + "channel": os.environ.get("RELEASE_CHANNEL", "dev"), + "schema_version": SCHEMA_VERSION, "timestamp": datetime.now(timezone.utc).isoformat(), "services": checks, } diff --git a/app/api/sync.py b/app/api/sync.py index d37da4d..5586ecd 100644 --- a/app/api/sync.py +++ b/app/api/sync.py @@ -64,8 +64,29 @@ def _run_sync(tables: Optional[List[str]] = None): sys_conn.close() if not table_configs: - logger.warning("No tables to sync for source_type=%s", source_type) - return + # Auto-discover tables on first sync when registry is empty + if source_type == "keboola" and os.environ.get("KEBOOLA_STORAGE_TOKEN"): + logger.info("No tables registered — running auto-discovery from Keboola") + try: + from app.api.admin import _discover_and_register_tables + auto_conn = get_system_db() + try: + result = _discover_and_register_tables(auto_conn, "auto-discovery") + logger.info("Auto-discovered %d tables, skipped %d", result["registered"], result["skipped"]) + finally: + auto_conn.close() + # Re-read table configs after auto-registration + sys_conn2 = get_system_db() + try: + table_configs = TableRegistryRepository(sys_conn2).list_local(source_type) + finally: + sys_conn2.close() + except Exception as e: + logger.warning("Auto-discovery failed: %s", e) + + if not table_configs: + logger.warning("No tables to sync for source_type=%s", source_type) + return # Serialize configs — strip non-serializable fields serializable = [] @@ -113,6 +134,29 @@ print(json.dumps(result)) else: print(f"[SYNC] Extractor OK", file=_sys.stderr, flush=True) + # Run custom connectors (Tier A: local mount) + connectors_dir = Path(os.environ.get("CONNECTORS_DIR", str(Path(__file__).parent.parent.parent / "connectors" / "custom"))) + if connectors_dir.exists(): + for connector_dir in sorted(connectors_dir.iterdir()): + if not connector_dir.is_dir(): + continue + extractor = connector_dir / "extractor.py" + if not extractor.exists(): + continue + logger.info("Running custom connector: %s", connector_dir.name) + try: + custom_result = subprocess.run( + [sys.executable, str(extractor)], + env=env, capture_output=True, text=True, timeout=600, + cwd=str(Path(__file__).parent.parent.parent), + ) + if custom_result.returncode != 0: + logger.error("Custom connector %s failed: %s", connector_dir.name, custom_result.stderr[-500:]) + else: + logger.info("Custom connector %s completed", connector_dir.name) + except subprocess.TimeoutExpired: + logger.error("Custom connector %s timed out", connector_dir.name) + # Rebuild master views (reads extract.duckdb files, no write conflict) from src.orchestrator import SyncOrchestrator orch = SyncOrchestrator() diff --git a/app/auth/jwt.py b/app/auth/jwt.py index 54c724d..a5173fc 100644 --- a/app/auth/jwt.py +++ b/app/auth/jwt.py @@ -7,22 +7,22 @@ from typing import Optional import jwt -SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "") - -if not SECRET_KEY: +def _get_secret_key() -> str: + """Load JWT secret - from env, file, or auto-generated.""" if os.environ.get("TESTING", "").lower() in ("1", "true"): - SECRET_KEY = "test-jwt-secret-key-minimum-32-chars!!" - else: - raise RuntimeError( - "JWT_SECRET_KEY environment variable is required. " - "Generate one: python -c \"import secrets; print(secrets.token_hex(32))\"" + return os.environ.get("JWT_SECRET_KEY", "test-jwt-secret-key-minimum-32-chars!!") + from app.secrets import get_jwt_secret + key = get_jwt_secret() + if len(key) < 32: + import warnings as _warnings + _warnings.warn( + f"JWT_SECRET_KEY is {len(key)} chars — minimum 32 recommended", + UserWarning, stacklevel=2, ) -elif len(SECRET_KEY) < 32 and os.environ.get("TESTING", "").lower() not in ("1", "true"): - import warnings as _warnings - _warnings.warn( - f"JWT_SECRET_KEY is {len(SECRET_KEY)} chars — minimum 32 recommended", - UserWarning, stacklevel=2, - ) + return key + + +SECRET_KEY = _get_secret_key() ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_HOURS = 24 # 24 hours diff --git a/app/main.py b/app/main.py index 224f684..c2652b0 100644 --- a/app/main.py +++ b/app/main.py @@ -48,8 +48,8 @@ def create_app() -> FastAPI: ) # Session middleware (required for OAuth state) - import secrets as _secrets - session_secret = os.environ.get("SESSION_SECRET", os.environ.get("JWT_SECRET_KEY", _secrets.token_hex(32))) + from app.secrets import get_session_secret + session_secret = get_session_secret() app.add_middleware(SessionMiddleware, secret_key=session_secret) # CORS for CLI and external clients @@ -62,6 +62,14 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + # Load .env_overlay (persisted by /api/admin/configure) + _overlay = Path(os.environ.get("DATA_DIR", "./data")) / "state" / ".env_overlay" + if _overlay.exists(): + for line in _overlay.read_text().splitlines(): + if "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + os.environ.setdefault(k.strip(), v.strip()) + # Load instance config on startup try: from app.instance_config import load_instance_config @@ -70,6 +78,15 @@ def create_app() -> FastAPI: except Exception as e: logger.warning(f"Could not load instance config: {e}") + # Startup banner + from src.db import SCHEMA_VERSION + logger.info( + "Agnes %s | channel: %s | schema v%s", + os.environ.get("AGNES_VERSION", "dev"), + os.environ.get("RELEASE_CHANNEL", "dev"), + SCHEMA_VERSION, + ) + # Seed admin user for testing/CI (when SEED_ADMIN_EMAIL is set) seed_email = os.environ.get("SEED_ADMIN_EMAIL") if seed_email: diff --git a/app/secrets.py b/app/secrets.py new file mode 100644 index 0000000..e97999b --- /dev/null +++ b/app/secrets.py @@ -0,0 +1,37 @@ +"""Auto-generate and persist secrets that survive container restarts.""" +import logging +import os +import secrets +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def _load_or_generate(env_var: str, file_name: str) -> str: + """Load secret from env var, or from file, or generate and persist.""" + val = os.environ.get(env_var, "") + if val: + return val + data_dir = Path(os.environ.get("DATA_DIR", "./data")) + secret_path = data_dir / "state" / file_name + if secret_path.exists(): + return secret_path.read_text().strip() + secret_path.parent.mkdir(parents=True, exist_ok=True) + val = secrets.token_hex(32) + secret_path.write_text(val) + secret_path.chmod(0o600) + logger.info( + "Auto-generated %s -> %s (set %s in .env to use a fixed value)", + file_name, secret_path, env_var, + ) + return val + + +def get_jwt_secret() -> str: + """Get JWT secret key from env, file, or auto-generate.""" + return _load_or_generate("JWT_SECRET_KEY", ".jwt_secret") + + +def get_session_secret() -> str: + """Get session secret from env, file, or auto-generate.""" + return _load_or_generate("SESSION_SECRET", ".session_secret") diff --git a/app/web/router.py b/app/web/router.py index f7c5f2b..23c6968 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -120,6 +120,7 @@ _URL_MAP = { "email_auth.login_email_form": "/login/email", "email_auth.send_magic_link": "/auth/email/send-link", "register": "/auth/password/setup", + "setup": "/setup", } @@ -177,6 +178,18 @@ async def index(request: Request, user: Optional[dict] = Depends(get_optional_us return RedirectResponse(url="/login", status_code=302) +@router.get("/setup", response_class=HTMLResponse) +async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depends(_get_db)): + """First-time setup wizard. Redirects to dashboard if users already exist.""" + try: + user_count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + if user_count > 0: + return RedirectResponse(url="/dashboard", status_code=302) + except Exception: + pass # No users table yet — show setup + return templates.TemplateResponse(request, "setup.html", _build_context(request)) + + @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request): providers = [] diff --git a/app/web/templates/setup.html b/app/web/templates/setup.html new file mode 100644 index 0000000..810cc59 --- /dev/null +++ b/app/web/templates/setup.html @@ -0,0 +1,261 @@ +{% extends "base_login.html" %} + +{% block title %}Setup - Agnes AI Data Analyst{% endblock %} + +{% block content %} +
+
+ +
+
+ + +{% endblock %} diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..543d03c --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,11 @@ +# CI smoke test overlay — minimal config for testing in GitHub Actions. +# Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d +services: + app: + environment: + - JWT_SECRET_KEY=smoke-test-ci-key-minimum-32-chars-xx + - SESSION_SECRET=smoke-test-session-key-32-chars-min-x + - DATA_DIR=/data + - TESTING=0 + ports: + - "8000:8000" diff --git a/docker-compose.yml b/docker-compose.yml index 406641d..94d4932 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: volumes: - data:/data - ./config:/app/config:ro + # - ./custom-connectors:/app/connectors/custom:ro # Tier A: AI-generated connectors env_file: .env environment: - DATA_DIR=/data diff --git a/docs/RELEASE_TEMPLATE.md b/docs/RELEASE_TEMPLATE.md new file mode 100644 index 0000000..3c32b6d --- /dev/null +++ b/docs/RELEASE_TEMPLATE.md @@ -0,0 +1,37 @@ +# Release Notes Template + +Use this template when adding a new entry to `CHANGELOG.md`. + +--- + +## stable-YYYY.MM.N + +**Image:** `ghcr.io/keboola/agnes-the-ai-analyst:stable-YYYY.MM.N` +**Digest:** `sha256:...` (from `docker inspect --format='{{index .RepoDigests 0}}'`) +**Date:** YYYY-MM-DD + +### Added +- Feature description + +### Changed +- Change description + +### Fixed +- Bug fix description + +### Breaking Changes +- Description of breaking change +- **Migration guide:** Steps to upgrade from previous version + +### Deprecated +- Description of deprecated feature (will be removed in YYYY.MM.N) + +--- + +## Guidelines + +- Every merge to `main` creates a new `stable-YYYY.MM.N` release +- Include the image digest for verification with `cosign verify` +- Breaking changes require `BREAKING:` prefix in commit message +- Migration guides must include exact commands or config changes +- If a release deprecates the previous stable, note it explicitly diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py new file mode 100644 index 0000000..ad1bbfe --- /dev/null +++ b/scripts/generate_openapi.py @@ -0,0 +1,16 @@ +"""Generate OpenAPI snapshot from the current FastAPI app.""" + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("TESTING", "1") +os.environ.setdefault("JWT_SECRET_KEY", "snapshot-generation-key-32-chars-min!!") + +from app.main import create_app # noqa: E402 + +app = create_app() +schema = app.openapi() +json.dump(schema, sys.stdout, indent=2, sort_keys=True) +sys.stdout.write("\n") diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..c2962b8 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Agnes smoke test — verifies a running instance is functional. +# Usage: ./scripts/smoke-test.sh [host:port] +# Default: http://localhost:8000 +set -euo pipefail + +HOST="${1:-http://localhost:8000}" +PASS=0 +FAIL=0 +TOKEN="" + +check() { + local name="$1" ok="$2" + if [ "$ok" = "true" ]; then + echo " PASS $name" + ((PASS++)) + else + echo " FAIL $name" + ((FAIL++)) + fi +} + +echo "Smoke test: $HOST" +echo "---" + +# 1. Health check +HEALTH=$(curl -sf "$HOST/api/health" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])" 2>/dev/null || echo "unreachable") +if [ "$HEALTH" = "unhealthy" ] || [ "$HEALTH" = "unreachable" ]; then + echo " FATAL: health=$HEALTH" + exit 1 +fi +check "health ($HEALTH)" "true" + +# 2. Health has version fields +HAS_VERSION=$(curl -sf "$HOST/api/health" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print('true' if 'version' in d and 'channel' in d and 'schema_version' in d else 'false') +" 2>/dev/null || echo "false") +check "health version fields" "$HAS_VERSION" + +# 3. Bootstrap (only works on fresh DB; 403 means users exist) +BOOT_HTTP=$(curl -s -o /tmp/smoke_boot.json -w "%{http_code}" -X POST "$HOST/auth/bootstrap" \ + -H "Content-Type: application/json" \ + -d '{"email":"smoke@test.local","name":"Smoke Test","password":"SmokeTest123!"}' 2>/dev/null || echo "000") + +if [ "$BOOT_HTTP" = "200" ]; then + TOKEN=$(python3 -c "import json; print(json.load(open('/tmp/smoke_boot.json'))['access_token'])" 2>/dev/null || echo "") + check "bootstrap (new admin)" "true" +elif [ "$BOOT_HTTP" = "403" ]; then + TOKEN="${SMOKE_TOKEN:-}" + echo " SKIP bootstrap (users exist)" +else + check "bootstrap (HTTP $BOOT_HTTP)" "false" +fi + +# 4. Query SELECT 1 (requires auth) +if [ -n "$TOKEN" ]; then + QUERY_OK=$(curl -sf -X POST "$HOST/api/query" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"sql":"SELECT 1 as test"}' | python3 -c " +import sys,json +d=json.load(sys.stdin) +print('true' if len(d.get('rows',[])) > 0 else 'false') +" 2>/dev/null || echo "false") + check "query SELECT 1" "$QUERY_OK" +else + echo " SKIP query (no token)" +fi + +# 5. Sync trigger +if [ -n "$TOKEN" ]; then + SYNC_HTTP=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$HOST/api/sync/trigger" \ + -H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "000") + if [[ "$SYNC_HTTP" =~ ^(200|202)$ ]]; then + check "sync trigger" "true" + else + check "sync trigger (HTTP $SYNC_HTTP)" "false" + fi +else + echo " SKIP sync (no token)" +fi + +# 6. Post-sync health (wait briefly) +sleep 5 +HEALTH2=$(curl -sf "$HOST/api/health" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])" 2>/dev/null || echo "unreachable") +if [ "$HEALTH2" = "unhealthy" ] || [ "$HEALTH2" = "unreachable" ]; then + check "post-sync health ($HEALTH2)" "false" +else + check "post-sync health ($HEALTH2)" "true" +fi + +# Results +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/src/db.py b/src/db.py index b55d131..1146e67 100644 --- a/src/db.py +++ b/src/db.py @@ -4,12 +4,16 @@ Provides get_system_db() for the system state database and get_analytics_db() for the analytics database with parquet views. """ +import logging import os import re +import shutil from pathlib import Path import duckdb +logger = logging.getLogger(__name__) + _SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$") SCHEMA_VERSION = 3 @@ -260,6 +264,16 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None: """Create tables if they don't exist. Apply migrations if schema version changed.""" current = get_schema_version(conn) if current < SCHEMA_VERSION: + # Snapshot before migration for rollback support + if current > 0: + try: + db_path = Path(os.environ.get("DATA_DIR", "./data")) / "state" / "system.duckdb" + if db_path.exists(): + snapshot = db_path.parent / "system.duckdb.pre-migrate" + shutil.copy2(str(db_path), str(snapshot)) + logger.info("Pre-migration snapshot saved: %s", snapshot) + except Exception as e: + logger.warning("Could not create pre-migration snapshot: %s", e) conn.execute(_SYSTEM_SCHEMA) if current == 0: conn.execute( diff --git a/tests/snapshots/openapi.json b/tests/snapshots/openapi.json new file mode 100644 index 0000000..f97d362 --- /dev/null +++ b/tests/snapshots/openapi.json @@ -0,0 +1,5151 @@ +{ + "components": { + "schemas": { + "AccessRequestCreate": { + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "title": "Reason" + }, + "table_id": { + "title": "Table Id", + "type": "string" + } + }, + "required": [ + "table_id" + ], + "title": "AccessRequestCreate", + "type": "object" + }, + "AdminActionRequest": { + "properties": { + "audience": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audience" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "title": "AdminActionRequest", + "type": "object" + }, + "BatchActionRequest": { + "properties": { + "action": { + "title": "Action", + "type": "string" + }, + "audience": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Audience" + }, + "item_ids": { + "items": { + "type": "string" + }, + "title": "Item Ids", + "type": "array" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "required": [ + "item_ids", + "action" + ], + "title": "BatchActionRequest", + "type": "object" + }, + "Body_password_login_web_auth_password_login_web_post": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "password": { + "default": "", + "title": "Password", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "Body_password_login_web_auth_password_login_web_post", + "type": "object" + }, + "Body_upload_artifact_api_upload_artifacts_post": { + "properties": { + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + } + }, + "required": [ + "file" + ], + "title": "Body_upload_artifact_api_upload_artifacts_post", + "type": "object" + }, + "Body_upload_session_api_upload_sessions_post": { + "properties": { + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + } + }, + "required": [ + "file" + ], + "title": "Body_upload_session_api_upload_sessions_post", + "type": "object" + }, + "BootstrapRequest": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "name": { + "default": "", + "title": "Name", + "type": "string" + }, + "password": { + "default": "", + "title": "Password", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "BootstrapRequest", + "type": "object" + }, + "ConfigureRequest": { + "properties": { + "allowed_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Allowed Domain" + }, + "bigquery_location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bigquery Location" + }, + "bigquery_project": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bigquery Project" + }, + "data_source": { + "title": "Data Source", + "type": "string" + }, + "instance_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Instance Name" + }, + "keboola_token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keboola Token" + }, + "keboola_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keboola Url" + } + }, + "required": [ + "data_source" + ], + "title": "ConfigureRequest", + "type": "object" + }, + "CreateKnowledgeRequest": { + "properties": { + "category": { + "title": "Category", + "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "content", + "category" + ], + "title": "CreateKnowledgeRequest", + "type": "object" + }, + "CreateUserRequest": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "role": { + "default": "analyst", + "title": "Role", + "type": "string" + } + }, + "required": [ + "email", + "name" + ], + "title": "CreateUserRequest", + "type": "object" + }, + "DatasetSettingRequest": { + "properties": { + "dataset": { + "title": "Dataset", + "type": "string" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + } + }, + "required": [ + "dataset", + "enabled" + ], + "title": "DatasetSettingRequest", + "type": "object" + }, + "DeployScriptRequest": { + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Schedule" + }, + "source": { + "title": "Source", + "type": "string" + } + }, + "required": [ + "name", + "source" + ], + "title": "DeployScriptRequest", + "type": "object" + }, + "EditRequest": { + "properties": { + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + } + }, + "title": "EditRequest", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "LocalMdRequest": { + "properties": { + "content": { + "title": "Content", + "type": "string" + } + }, + "required": [ + "content" + ], + "title": "LocalMdRequest", + "type": "object" + }, + "MagicLinkRequest": { + "properties": { + "email": { + "title": "Email", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "MagicLinkRequest", + "type": "object" + }, + "MagicLinkVerify": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "token": { + "title": "Token", + "type": "string" + } + }, + "required": [ + "email", + "token" + ], + "title": "MagicLinkVerify", + "type": "object" + }, + "PasswordLoginRequest": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "title": "PasswordLoginRequest", + "type": "object" + }, + "PasswordSetupRequest": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "token": { + "title": "Token", + "type": "string" + } + }, + "required": [ + "email", + "token", + "password" + ], + "title": "PasswordSetupRequest", + "type": "object" + }, + "PermissionRequest": { + "properties": { + "access": { + "default": "read", + "title": "Access", + "type": "string" + }, + "dataset": { + "title": "Dataset", + "type": "string" + }, + "user_id": { + "title": "User Id", + "type": "string" + } + }, + "required": [ + "user_id", + "dataset" + ], + "title": "PermissionRequest", + "type": "object" + }, + "QueryRequest": { + "properties": { + "limit": { + "default": 1000, + "title": "Limit", + "type": "integer" + }, + "sql": { + "title": "Sql", + "type": "string" + } + }, + "required": [ + "sql" + ], + "title": "QueryRequest", + "type": "object" + }, + "QueryResponse": { + "properties": { + "columns": { + "items": {}, + "title": "Columns", + "type": "array" + }, + "row_count": { + "title": "Row Count", + "type": "integer" + }, + "rows": { + "items": {}, + "title": "Rows", + "type": "array" + }, + "truncated": { + "default": false, + "title": "Truncated", + "type": "boolean" + } + }, + "required": [ + "columns", + "rows", + "row_count" + ], + "title": "QueryResponse", + "type": "object" + }, + "RegisterTableRequest": { + "properties": { + "bucket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bucket" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "folder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Folder" + }, + "name": { + "title": "Name", + "type": "string" + }, + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Primary Key" + }, + "profile_after_sync": { + "default": true, + "title": "Profile After Sync", + "type": "boolean" + }, + "query_mode": { + "default": "local", + "title": "Query Mode", + "type": "string" + }, + "source_table": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Table" + }, + "source_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Type" + }, + "sync_schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sync Schedule" + }, + "sync_strategy": { + "default": "full_refresh", + "title": "Sync Strategy", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "RegisterTableRequest", + "type": "object" + }, + "RunScriptRequest": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source" + } + }, + "title": "RunScriptRequest", + "type": "object" + }, + "SyncSettingsUpdate": { + "properties": { + "datasets": { + "additionalProperties": true, + "title": "Datasets", + "type": "object" + } + }, + "required": [ + "datasets" + ], + "title": "SyncSettingsUpdate", + "type": "object" + }, + "TableSubscriptionUpdate": { + "properties": { + "table_mode": { + "default": "all", + "title": "Table Mode", + "type": "string" + }, + "tables": { + "additionalProperties": true, + "default": {}, + "title": "Tables", + "type": "object" + } + }, + "title": "TableSubscriptionUpdate", + "type": "object" + }, + "TokenRequest": { + "properties": { + "email": { + "title": "Email", + "type": "string" + }, + "password": { + "default": "", + "title": "Password", + "type": "string" + } + }, + "required": [ + "email" + ], + "title": "TokenRequest", + "type": "object" + }, + "TokenResponse": { + "properties": { + "access_token": { + "title": "Access Token", + "type": "string" + }, + "email": { + "title": "Email", + "type": "string" + }, + "role": { + "title": "Role", + "type": "string" + }, + "token_type": { + "default": "bearer", + "title": "Token Type", + "type": "string" + }, + "user_id": { + "title": "User Id", + "type": "string" + } + }, + "required": [ + "access_token", + "user_id", + "email", + "role" + ], + "title": "TokenResponse", + "type": "object" + }, + "UpdateTableRequest": { + "properties": { + "bucket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bucket" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "primary_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Primary Key" + }, + "profile_after_sync": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Profile After Sync" + }, + "query_mode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Query Mode" + }, + "source_table": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Table" + }, + "source_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Type" + }, + "sync_schedule": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sync Schedule" + }, + "sync_strategy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sync Strategy" + } + }, + "title": "UpdateTableRequest", + "type": "object" + }, + "UserResponse": { + "properties": { + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "email": { + "title": "Email", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "role": { + "title": "Role", + "type": "string" + } + }, + "required": [ + "id", + "email", + "name", + "role", + "created_at" + ], + "title": "UserResponse", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + }, + "VerifyRequest": { + "properties": { + "code": { + "title": "Code", + "type": "string" + } + }, + "required": [ + "code" + ], + "title": "VerifyRequest", + "type": "object" + }, + "VoteRequest": { + "properties": { + "vote": { + "title": "Vote", + "type": "integer" + } + }, + "required": [ + "vote" + ], + "title": "VoteRequest", + "type": "object" + } + } + }, + "info": { + "description": "Data distribution platform for AI analytical systems", + "title": "AI Data Analyst", + "version": "2.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "index__get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Index", + "tags": [ + "web" + ] + } + }, + "/activity-center": { + "get": { + "operationId": "activity_center_activity_center_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Activity Center", + "tags": [ + "web" + ] + } + }, + "/admin/permissions": { + "get": { + "description": "Admin page for managing permissions and access requests.", + "operationId": "admin_permissions_page_admin_permissions_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Permissions Page", + "tags": [ + "web" + ] + } + }, + "/admin/tables": { + "get": { + "operationId": "admin_tables_admin_tables_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Tables", + "tags": [ + "web" + ] + } + }, + "/api/access-requests": { + "post": { + "description": "Submit an access request for a table.", + "operationId": "create_request_api_access_requests_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessRequestCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Request", + "tags": [ + "access-requests" + ] + } + }, + "/api/access-requests/my": { + "get": { + "description": "List current user's access requests.", + "operationId": "my_requests_api_access_requests_my_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "My Requests", + "tags": [ + "access-requests" + ] + } + }, + "/api/access-requests/pending": { + "get": { + "description": "List all pending access requests (admin only).", + "operationId": "pending_requests_api_access_requests_pending_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Pending Requests", + "tags": [ + "access-requests" + ] + } + }, + "/api/access-requests/{request_id}/approve": { + "post": { + "description": "Approve an access request (admin only). Auto-grants permission.", + "operationId": "approve_request_api_access_requests__request_id__approve_post", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "title": "Request Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Approve Request", + "tags": [ + "access-requests" + ] + } + }, + "/api/access-requests/{request_id}/deny": { + "post": { + "description": "Deny an access request (admin only).", + "operationId": "deny_request_api_access_requests__request_id__deny_post", + "parameters": [ + { + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "title": "Request Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Deny Request", + "tags": [ + "access-requests" + ] + } + }, + "/api/admin/configure": { + "post": { + "description": "Configure data source and instance settings via API.\n\nWrites config to instance.yaml and persists secrets to .env_overlay.\nAI agents and the /setup wizard use this instead of manual file editing.", + "operationId": "configure_instance_api_admin_configure_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigureRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Configure Instance", + "tags": [ + "admin" + ] + } + }, + "/api/admin/discover-and-register": { + "post": { + "description": "Discover tables from configured source and auto-register them.\n\nCombines discover-tables + register-table into one call.\nSkips already-registered tables. Used by /setup wizard and AI agents.", + "operationId": "discover_and_register_api_admin_discover_and_register_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Discover And Register", + "tags": [ + "admin" + ] + } + }, + "/api/admin/discover-tables": { + "get": { + "description": "Discover all available tables from the configured data source.", + "operationId": "discover_tables_api_admin_discover_tables_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Discover Tables", + "tags": [ + "admin" + ] + } + }, + "/api/admin/permissions": { + "delete": { + "description": "Revoke a user's access to a dataset/table.", + "operationId": "revoke_permission_api_admin_permissions_delete", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Revoke Permission", + "tags": [ + "permissions" + ] + }, + "get": { + "description": "List all dataset permissions.", + "operationId": "list_all_permissions_api_admin_permissions_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List All Permissions", + "tags": [ + "permissions" + ] + }, + "post": { + "description": "Grant a user access to a dataset/table.", + "operationId": "grant_permission_api_admin_permissions_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Grant Permission", + "tags": [ + "permissions" + ] + } + }, + "/api/admin/permissions/{user_id}": { + "get": { + "description": "List all permissions for a user.", + "operationId": "get_user_permissions_api_admin_permissions__user_id__get", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get User Permissions", + "tags": [ + "permissions" + ] + } + }, + "/api/admin/register-table": { + "post": { + "description": "Register a new table in the system.", + "operationId": "register_table_api_admin_register_table_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterTableRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Register Table", + "tags": [ + "admin" + ] + } + }, + "/api/admin/registry": { + "get": { + "description": "Get full table registry.", + "operationId": "list_registry_api_admin_registry_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Registry", + "tags": [ + "admin" + ] + } + }, + "/api/admin/registry/{table_id}": { + "delete": { + "description": "Unregister a table from the system.", + "operationId": "unregister_table_api_admin_registry__table_id__delete", + "parameters": [ + { + "in": "path", + "name": "table_id", + "required": true, + "schema": { + "title": "Table Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Unregister Table", + "tags": [ + "admin" + ] + }, + "put": { + "description": "Update a registered table's configuration.", + "operationId": "update_table_api_admin_registry__table_id__put", + "parameters": [ + { + "in": "path", + "name": "table_id", + "required": true, + "schema": { + "title": "Table Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTableRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Table", + "tags": [ + "admin" + ] + } + }, + "/api/catalog/metrics/{metric_path}": { + "get": { + "description": "Get a metric YAML definition parsed as structured JSON.", + "operationId": "get_metric_api_catalog_metrics__metric_path__get", + "parameters": [ + { + "in": "path", + "name": "metric_path", + "required": true, + "schema": { + "title": "Metric Path", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Metric", + "tags": [ + "catalog" + ] + } + }, + "/api/catalog/profile/{table_name}": { + "get": { + "description": "Get profiler data for a specific table.", + "operationId": "get_table_profile_api_catalog_profile__table_name__get", + "parameters": [ + { + "in": "path", + "name": "table_name", + "required": true, + "schema": { + "title": "Table Name", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Table Profile", + "tags": [ + "catalog" + ] + } + }, + "/api/catalog/profile/{table_name}/refresh": { + "post": { + "description": "Re-generate profile for a table on demand.", + "operationId": "refresh_profile_api_catalog_profile__table_name__refresh_post", + "parameters": [ + { + "in": "path", + "name": "table_name", + "required": true, + "schema": { + "title": "Table Name", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Refresh Profile", + "tags": [ + "catalog" + ] + } + }, + "/api/catalog/tables": { + "get": { + "description": "List all available tables from table_registry.", + "operationId": "list_catalog_tables_api_catalog_tables_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Catalog Tables", + "tags": [ + "catalog" + ] + } + }, + "/api/data/{table_id}/download": { + "get": { + "description": "Stream a parquet file for download. Supports ETag for caching.", + "operationId": "download_table_api_data__table_id__download_get", + "parameters": [ + { + "in": "path", + "name": "table_id", + "required": true, + "schema": { + "title": "Table Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Download Table", + "tags": [ + "data" + ] + } + }, + "/api/health": { + "get": { + "description": "Structured health check. No auth required.", + "operationId": "health_check_api_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Health Check", + "tags": [ + "health" + ] + } + }, + "/api/memory": { + "get": { + "description": "List knowledge items with filtering, pagination, search.", + "operationId": "list_knowledge_api_memory_get", + "parameters": [ + { + "in": "query", + "name": "status_filter", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status Filter" + } + }, + { + "in": "query", + "name": "category", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + } + }, + { + "in": "query", + "name": "search", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Search" + } + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "title": "Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "default": 50, + "title": "Per Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "sort", + "required": false, + "schema": { + "default": "updated_at", + "title": "Sort", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Knowledge", + "tags": [ + "memory" + ] + }, + "post": { + "operationId": "create_knowledge_api_memory_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKnowledgeRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Knowledge", + "tags": [ + "memory" + ] + } + }, + "/api/memory/admin/approve": { + "post": { + "operationId": "admin_approve_api_memory_admin_approve_post", + "parameters": [ + { + "in": "query", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Approve", + "tags": [ + "memory" + ] + } + }, + "/api/memory/admin/audit": { + "get": { + "description": "Get governance audit log.", + "operationId": "admin_audit_api_memory_admin_audit_get", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "title": "Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "default": 50, + "title": "Per Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "action", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Action" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Audit", + "tags": [ + "memory" + ] + } + }, + "/api/memory/admin/batch": { + "post": { + "description": "Batch governance action on multiple items.", + "operationId": "admin_batch_api_memory_admin_batch_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Batch", + "tags": [ + "memory" + ] + } + }, + "/api/memory/admin/edit": { + "post": { + "operationId": "admin_edit_api_memory_admin_edit_post", + "parameters": [ + { + "in": "query", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Edit", + "tags": [ + "memory" + ] + } + }, + "/api/memory/admin/mandate": { + "post": { + "operationId": "admin_mandate_api_memory_admin_mandate_post", + "parameters": [ + { + "in": "query", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Mandate", + "tags": [ + "memory" + ] + } + }, + "/api/memory/admin/pending": { + "get": { + "description": "Get pending items queue for admin review.", + "operationId": "admin_pending_api_memory_admin_pending_get", + "parameters": [ + { + "in": "query", + "name": "category", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Category" + } + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "title": "Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "default": 50, + "title": "Per Page", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Pending", + "tags": [ + "memory" + ] + } + }, + "/api/memory/admin/reject": { + "post": { + "operationId": "admin_reject_api_memory_admin_reject_post", + "parameters": [ + { + "in": "query", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Reject", + "tags": [ + "memory" + ] + } + }, + "/api/memory/admin/revoke": { + "post": { + "operationId": "admin_revoke_api_memory_admin_revoke_post", + "parameters": [ + { + "in": "query", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Revoke", + "tags": [ + "memory" + ] + } + }, + "/api/memory/my-votes": { + "get": { + "description": "Get current user's votes on all items.", + "operationId": "get_my_votes_api_memory_my_votes_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get My Votes", + "tags": [ + "memory" + ] + } + }, + "/api/memory/stats": { + "get": { + "description": "Get corporate memory statistics.", + "operationId": "get_stats_api_memory_stats_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Stats", + "tags": [ + "memory" + ] + } + }, + "/api/memory/{item_id}/vote": { + "post": { + "operationId": "vote_knowledge_api_memory__item_id__vote_post", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "title": "Item Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VoteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Vote Knowledge", + "tags": [ + "memory" + ] + } + }, + "/api/query": { + "post": { + "description": "Execute SQL against the server analytics DuckDB.", + "operationId": "execute_query_api_query_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Execute Query", + "tags": [ + "query" + ] + } + }, + "/api/scripts": { + "get": { + "operationId": "list_scripts_api_scripts_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Scripts", + "tags": [ + "scripts" + ] + } + }, + "/api/scripts/deploy": { + "post": { + "description": "Deploy a Python script to be run on the server (optionally on schedule).", + "operationId": "deploy_script_api_scripts_deploy_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployScriptRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Deploy Script", + "tags": [ + "scripts" + ] + } + }, + "/api/scripts/run": { + "post": { + "description": "Run an ad-hoc Python script (not deployed).", + "operationId": "run_adhoc_script_api_scripts_run_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunScriptRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Run Adhoc Script", + "tags": [ + "scripts" + ] + } + }, + "/api/scripts/{script_id}": { + "delete": { + "operationId": "undeploy_script_api_scripts__script_id__delete", + "parameters": [ + { + "in": "path", + "name": "script_id", + "required": true, + "schema": { + "title": "Script Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Undeploy Script", + "tags": [ + "scripts" + ] + } + }, + "/api/scripts/{script_id}/run": { + "post": { + "description": "Run a deployed script by ID.", + "operationId": "run_deployed_script_api_scripts__script_id__run_post", + "parameters": [ + { + "in": "path", + "name": "script_id", + "required": true, + "schema": { + "title": "Script Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Run Deployed Script", + "tags": [ + "scripts" + ] + } + }, + "/api/settings": { + "get": { + "description": "Get current user's sync settings and permissions.", + "operationId": "get_settings_api_settings_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Settings", + "tags": [ + "settings" + ] + } + }, + "/api/settings/dataset": { + "put": { + "description": "Enable or disable a dataset for sync.", + "operationId": "update_dataset_setting_api_settings_dataset_put", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DatasetSettingRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Dataset Setting", + "tags": [ + "settings" + ] + } + }, + "/api/sync/manifest": { + "get": { + "description": "Return hash-based manifest of all synced data, filtered per user.", + "operationId": "sync_manifest_api_sync_manifest_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Sync Manifest", + "tags": [ + "sync" + ] + } + }, + "/api/sync/settings": { + "get": { + "description": "Get user's dataset sync settings.", + "operationId": "get_sync_settings_api_sync_settings_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Sync Settings", + "tags": [ + "sync" + ] + }, + "post": { + "description": "Update user's dataset sync settings.", + "operationId": "update_sync_settings_api_sync_settings_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncSettingsUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Sync Settings", + "tags": [ + "sync" + ] + } + }, + "/api/sync/table-subscriptions": { + "get": { + "description": "Get user's per-table subscription settings.", + "operationId": "get_table_subscriptions_api_sync_table_subscriptions_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Table Subscriptions", + "tags": [ + "sync" + ] + }, + "post": { + "description": "Update per-table subscription preferences.", + "operationId": "update_table_subscriptions_api_sync_table_subscriptions_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableSubscriptionUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Table Subscriptions", + "tags": [ + "sync" + ] + } + }, + "/api/sync/trigger": { + "post": { + "description": "Trigger data sync from configured source. Admin only. Runs in background.", + "operationId": "trigger_sync_api_sync_trigger_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tables" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Trigger Sync", + "tags": [ + "sync" + ] + } + }, + "/api/telegram/status": { + "get": { + "description": "Get current Telegram link status.", + "operationId": "telegram_status_api_telegram_status_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Telegram Status", + "tags": [ + "telegram" + ] + } + }, + "/api/telegram/unlink": { + "post": { + "description": "Unlink Telegram account.", + "operationId": "telegram_unlink_api_telegram_unlink_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Telegram Unlink", + "tags": [ + "telegram" + ] + } + }, + "/api/telegram/verify": { + "post": { + "description": "Verify a code to link Telegram account.", + "operationId": "telegram_verify_api_telegram_verify_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Telegram Verify", + "tags": [ + "telegram" + ] + } + }, + "/api/upload/artifacts": { + "post": { + "description": "Upload an artifact (HTML report, PNG chart, etc.).", + "operationId": "upload_artifact_api_upload_artifacts_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_artifact_api_upload_artifacts_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Artifact", + "tags": [ + "upload" + ] + } + }, + "/api/upload/local-md": { + "post": { + "description": "Upload CLAUDE.local.md content for corporate memory processing.", + "operationId": "upload_local_md_api_upload_local_md_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocalMdRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Local Md", + "tags": [ + "upload" + ] + } + }, + "/api/upload/sessions": { + "post": { + "description": "Upload a Claude session transcript (JSONL).", + "operationId": "upload_session_api_upload_sessions_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_session_api_upload_sessions_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Session", + "tags": [ + "upload" + ] + } + }, + "/api/users": { + "get": { + "operationId": "list_users_api_users_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/UserResponse" + }, + "title": "Response List Users Api Users Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Users", + "tags": [ + "users" + ] + }, + "post": { + "operationId": "create_user_api_users_post", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create User", + "tags": [ + "users" + ] + } + }, + "/api/users/{user_id}": { + "delete": { + "operationId": "delete_user_api_users__user_id__delete", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "title": "User Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete User", + "tags": [ + "users" + ] + } + }, + "/auth/bootstrap": { + "post": { + "description": "Create the first admin user. Only works when no users exist.\n\nThis endpoint allows an AI agent to bootstrap a fresh instance\nwithout needing docker exec or SSH. It automatically deactivates\nafter the first user is created.", + "operationId": "bootstrap_auth_bootstrap_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootstrapRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Bootstrap", + "tags": [ + "auth" + ] + } + }, + "/auth/email/send-link": { + "post": { + "description": "Send a magic link to the user's email.", + "operationId": "send_magic_link_auth_email_send_link_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MagicLinkRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Send Magic Link", + "tags": [ + "auth" + ] + } + }, + "/auth/email/verify": { + "post": { + "description": "Verify a magic link token and issue JWT.", + "operationId": "verify_magic_link_auth_email_verify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MagicLinkVerify" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Verify Magic Link", + "tags": [ + "auth" + ] + } + }, + "/auth/google/callback": { + "get": { + "description": "Handle Google OAuth callback.", + "operationId": "google_callback_auth_google_callback_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Google Callback", + "tags": [ + "auth" + ] + } + }, + "/auth/google/login": { + "get": { + "description": "Redirect to Google OAuth.", + "operationId": "google_login_auth_google_login_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Google Login", + "tags": [ + "auth" + ] + } + }, + "/auth/password/login": { + "post": { + "description": "Login with email + password.", + "operationId": "password_login_auth_password_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Password Login", + "tags": [ + "auth" + ] + } + }, + "/auth/password/login/web": { + "post": { + "description": "Web form login \u2014 sets cookie and redirects to dashboard.", + "operationId": "password_login_web_auth_password_login_web_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_password_login_web_auth_password_login_web_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Password Login Web", + "tags": [ + "auth" + ] + } + }, + "/auth/password/setup": { + "post": { + "description": "Set initial password using setup token.", + "operationId": "password_setup_auth_password_setup_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordSetupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Password Setup", + "tags": [ + "auth" + ] + } + }, + "/auth/token": { + "post": { + "description": "Issue a JWT token. Requires password authentication.", + "operationId": "create_token_auth_token_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Token", + "tags": [ + "auth" + ] + } + }, + "/catalog": { + "get": { + "operationId": "catalog_catalog_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Catalog", + "tags": [ + "web" + ] + } + }, + "/corporate-memory": { + "get": { + "operationId": "corporate_memory_corporate_memory_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Corporate Memory", + "tags": [ + "web" + ] + } + }, + "/corporate-memory/admin": { + "get": { + "operationId": "corporate_memory_admin_corporate_memory_admin_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Corporate Memory Admin", + "tags": [ + "web" + ] + } + }, + "/dashboard": { + "get": { + "operationId": "dashboard_dashboard_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Dashboard", + "tags": [ + "web" + ] + } + }, + "/login": { + "get": { + "operationId": "login_page_login_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Login Page", + "tags": [ + "web" + ] + } + }, + "/login/email": { + "get": { + "description": "Email magic link login form.", + "operationId": "login_email_page_login_email_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Login Email Page", + "tags": [ + "web" + ] + } + }, + "/login/password": { + "get": { + "description": "Password login form (email + password).", + "operationId": "login_password_page_login_password_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Login Password Page", + "tags": [ + "web" + ] + } + }, + "/setup": { + "get": { + "description": "First-time setup wizard. Redirects to dashboard if users already exist.", + "operationId": "setup_wizard_setup_get", + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Setup Wizard", + "tags": [ + "web" + ] + } + }, + "/webhooks/jira": { + "post": { + "description": "Receive and process Jira webhook notifications.", + "operationId": "receive_jira_webhook_webhooks_jira_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Receive Jira Webhook", + "tags": [ + "jira-webhooks" + ] + } + }, + "/webhooks/jira/health": { + "get": { + "description": "Health check for Jira webhook endpoint.", + "operationId": "jira_webhook_health_webhooks_jira_health_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Jira Webhook Health Webhooks Jira Health Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Jira Webhook Health", + "tags": [ + "jira-webhooks" + ] + } + } + } +} diff --git a/tests/test_db.py b/tests/test_db.py index 32d625f..3a4ae0c 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -144,6 +144,205 @@ class TestGetAnalyticsDb: conn.close() +class TestMigrationSafety: + """Tests for schema migration correctness, idempotency, and safety snapshots.""" + + # Minimal v2 table_registry (no is_public column — that comes in v3) + _V2_TABLE_REGISTRY = """ + CREATE TABLE table_registry ( + id VARCHAR PRIMARY KEY, + name VARCHAR NOT NULL, + source_type VARCHAR, + bucket VARCHAR, + source_table VARCHAR, + sync_strategy VARCHAR DEFAULT 'full_refresh', + query_mode VARCHAR DEFAULT 'local', + sync_schedule VARCHAR, + profile_after_sync BOOLEAN DEFAULT true, + primary_key VARCHAR, + folder VARCHAR, + description TEXT, + registered_by VARCHAR, + registered_at TIMESTAMP DEFAULT current_timestamp + ); + """ + + def _create_v2_db(self, db_path): + """Create a minimal v2-schema DuckDB file at db_path.""" + import duckdb as _duckdb + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = _duckdb.connect(str(db_path)) + try: + conn.execute( + "CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp);" + "INSERT INTO schema_version (version) VALUES (2);" + ) + conn.execute(self._V2_TABLE_REGISTRY) + # Stub out remaining tables so _ensure_schema doesn't fail + for ddl in [ + "CREATE TABLE IF NOT EXISTS users (id VARCHAR PRIMARY KEY, email VARCHAR)", + "CREATE TABLE IF NOT EXISTS sync_state (table_id VARCHAR PRIMARY KEY)", + "CREATE TABLE IF NOT EXISTS sync_history (id VARCHAR PRIMARY KEY, table_id VARCHAR)", + "CREATE TABLE IF NOT EXISTS user_sync_settings (user_id VARCHAR, dataset VARCHAR, PRIMARY KEY(user_id, dataset))", + "CREATE TABLE IF NOT EXISTS knowledge_items (id VARCHAR PRIMARY KEY, title VARCHAR)", + "CREATE TABLE IF NOT EXISTS knowledge_votes (item_id VARCHAR, user_id VARCHAR, PRIMARY KEY(item_id, user_id))", + "CREATE TABLE IF NOT EXISTS audit_log (id VARCHAR PRIMARY KEY, action VARCHAR)", + "CREATE TABLE IF NOT EXISTS telegram_links (user_id VARCHAR PRIMARY KEY, chat_id BIGINT)", + "CREATE TABLE IF NOT EXISTS pending_codes (code VARCHAR PRIMARY KEY, chat_id BIGINT)", + "CREATE TABLE IF NOT EXISTS script_registry (id VARCHAR PRIMARY KEY, name VARCHAR, source TEXT)", + "CREATE TABLE IF NOT EXISTS table_profiles (table_id VARCHAR PRIMARY KEY, profile JSON)", + "CREATE TABLE IF NOT EXISTS dataset_permissions (user_id VARCHAR, dataset VARCHAR, PRIMARY KEY(user_id, dataset))", + ]: + conn.execute(ddl) + finally: + conn.close() + + def test_v2_to_v3_migration(self, tmp_path, monkeypatch): + """v2 DB migrated to v3: schema_version=3 and is_public column added.""" + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + import duckdb as _duckdb + from src.db import _ensure_schema, get_schema_version + + db_path = tmp_path / "state" / "system.duckdb" + self._create_v2_db(db_path) + + conn = _duckdb.connect(str(db_path)) + try: + _ensure_schema(conn) + assert get_schema_version(conn) == 3 + cols = { + r[0] + for r in conn.execute( + "SELECT column_name FROM information_schema.columns WHERE table_name='table_registry'" + ).fetchall() + } + assert "is_public" in cols + finally: + conn.close() + + def test_migration_idempotency(self, tmp_path, monkeypatch): + """Calling _ensure_schema twice on a fresh DB raises no error and leaves version at 3.""" + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + import duckdb as _duckdb + from src.db import _ensure_schema, get_schema_version, SCHEMA_VERSION + + db_path = tmp_path / "state" / "system.duckdb" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = _duckdb.connect(str(db_path)) + try: + _ensure_schema(conn) + _ensure_schema(conn) + assert get_schema_version(conn) == SCHEMA_VERSION + finally: + conn.close() + + def test_migration_preserves_data(self, tmp_path, monkeypatch): + """Data inserted before migration is preserved after migration runs.""" + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + import duckdb as _duckdb + from src.db import _ensure_schema, get_schema_version, _SYSTEM_SCHEMA + + db_path = tmp_path / "state" / "system.duckdb" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = _duckdb.connect(str(db_path)) + try: + # Build a v1 schema manually + conn.execute( + "CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp);" + "INSERT INTO schema_version (version) VALUES (1);" + ) + conn.execute(""" + CREATE TABLE table_registry ( + id VARCHAR PRIMARY KEY, + name VARCHAR NOT NULL, + folder VARCHAR, + sync_strategy VARCHAR, + primary_key VARCHAR, + description TEXT, + registered_by VARCHAR, + registered_at TIMESTAMP DEFAULT current_timestamp + ); + """) + conn.execute( + "INSERT INTO table_registry (id, name, description) VALUES ('row1', 'MyTable', 'kept')" + ) + # Stub remaining tables + for ddl in [ + "CREATE TABLE IF NOT EXISTS users (id VARCHAR PRIMARY KEY, email VARCHAR)", + "CREATE TABLE IF NOT EXISTS sync_state (table_id VARCHAR PRIMARY KEY)", + "CREATE TABLE IF NOT EXISTS sync_history (id VARCHAR PRIMARY KEY, table_id VARCHAR)", + "CREATE TABLE IF NOT EXISTS user_sync_settings (user_id VARCHAR, dataset VARCHAR, PRIMARY KEY(user_id, dataset))", + "CREATE TABLE IF NOT EXISTS knowledge_items (id VARCHAR PRIMARY KEY, title VARCHAR)", + "CREATE TABLE IF NOT EXISTS knowledge_votes (item_id VARCHAR, user_id VARCHAR, PRIMARY KEY(item_id, user_id))", + "CREATE TABLE IF NOT EXISTS audit_log (id VARCHAR PRIMARY KEY, action VARCHAR)", + "CREATE TABLE IF NOT EXISTS telegram_links (user_id VARCHAR PRIMARY KEY, chat_id BIGINT)", + "CREATE TABLE IF NOT EXISTS pending_codes (code VARCHAR PRIMARY KEY, chat_id BIGINT)", + "CREATE TABLE IF NOT EXISTS script_registry (id VARCHAR PRIMARY KEY, name VARCHAR, source TEXT)", + "CREATE TABLE IF NOT EXISTS table_profiles (table_id VARCHAR PRIMARY KEY, profile JSON)", + "CREATE TABLE IF NOT EXISTS dataset_permissions (user_id VARCHAR, dataset VARCHAR, PRIMARY KEY(user_id, dataset))", + ]: + conn.execute(ddl) + + _ensure_schema(conn) + + assert get_schema_version(conn) == 3 + row = conn.execute( + "SELECT name, description FROM table_registry WHERE id='row1'" + ).fetchone() + assert row is not None, "Pre-migration row was lost" + assert row[0] == "MyTable" + assert row[1] == "kept" + finally: + conn.close() + + def test_pre_migration_snapshot_created(self, tmp_path, monkeypatch): + """A pre-migrate snapshot is written when migrating an existing (non-fresh) DB.""" + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + from src.db import get_system_db + + # Create a v2 DB at the expected path before calling get_system_db + db_path = tmp_path / "state" / "system.duckdb" + self._create_v2_db(db_path) + + conn = get_system_db() + try: + snapshot = tmp_path / "state" / "system.duckdb.pre-migrate" + assert snapshot.exists(), "Pre-migration snapshot was not created" + finally: + conn.close() + + def test_no_snapshot_on_fresh_db(self, tmp_path, monkeypatch): + """No pre-migrate snapshot is created when initialising a brand-new DB.""" + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + from src.db import get_system_db + + conn = get_system_db() + try: + snapshot = tmp_path / "state" / "system.duckdb.pre-migrate" + assert not snapshot.exists(), "Snapshot should not exist for a fresh DB" + finally: + conn.close() + + def test_future_version_is_noop(self, tmp_path, monkeypatch): + """_ensure_schema does nothing when schema_version > SCHEMA_VERSION.""" + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + import duckdb as _duckdb + from src.db import _ensure_schema, get_schema_version + + db_path = tmp_path / "state" / "system.duckdb" + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = _duckdb.connect(str(db_path)) + try: + conn.execute( + "CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp);" + "INSERT INTO schema_version (version) VALUES (99);" + ) + _ensure_schema(conn) + assert get_schema_version(conn) == 99 + finally: + conn.close() + + class TestGetAnalyticsDbReadonly: def test_analytics_readonly_rejects_malicious_dir_name(self, tmp_path, monkeypatch): """Directories with SQL-injection chars in their name are skipped.""" diff --git a/tests/test_openapi_snapshot.py b/tests/test_openapi_snapshot.py new file mode 100644 index 0000000..db5a2fa --- /dev/null +++ b/tests/test_openapi_snapshot.py @@ -0,0 +1,73 @@ +"""OpenAPI snapshot test — detect breaking API changes. + +Compares the current app's OpenAPI schema against a committed snapshot. +Fails if any path or HTTP method has been removed (breaking change). + +To update the snapshot after an intentional change: + make update-openapi-snapshot +""" + +import json +import os +from pathlib import Path + +import pytest + +SNAPSHOT_PATH = Path(__file__).parent / "snapshots" / "openapi.json" + + +@pytest.fixture(scope="module") +def current_schema(): + os.environ.setdefault("TESTING", "1") + from app.main import create_app + + app = create_app() + return app.openapi() + + +def test_snapshot_exists(): + """Committed OpenAPI snapshot must exist.""" + assert SNAPSHOT_PATH.exists(), ( + "No OpenAPI snapshot found. Generate one with: make update-openapi-snapshot" + ) + + +def test_no_removed_paths(current_schema): + """No API paths should be removed compared to the snapshot.""" + if not SNAPSHOT_PATH.exists(): + pytest.skip("No snapshot to compare against") + + snapshot = json.loads(SNAPSHOT_PATH.read_text()) + current_paths = set(current_schema.get("paths", {})) + snapshot_paths = set(snapshot.get("paths", {})) + + removed = snapshot_paths - current_paths + assert not removed, ( + f"BREAKING: {len(removed)} API path(s) removed: {sorted(removed)}\n" + "If intentional, run: make update-openapi-snapshot" + ) + + +def test_no_removed_methods(current_schema): + """No HTTP methods should be removed from existing paths.""" + if not SNAPSHOT_PATH.exists(): + pytest.skip("No snapshot to compare against") + + snapshot = json.loads(SNAPSHOT_PATH.read_text()) + current_paths = current_schema.get("paths", {}) + snapshot_paths = snapshot.get("paths", {}) + + breaking = [] + for path in set(snapshot_paths) & set(current_paths): + removed_methods = set(snapshot_paths[path]) - set(current_paths[path]) + # Ignore non-HTTP keys like 'parameters' + http_methods = {"get", "post", "put", "delete", "patch", "head", "options"} + removed_http = removed_methods & http_methods + if removed_http: + breaking.append(f" {path}: {sorted(removed_http)}") + + assert not breaking, ( + f"BREAKING: HTTP methods removed from {len(breaking)} path(s):\n" + + "\n".join(breaking) + + "\nIf intentional, run: make update-openapi-snapshot" + ) diff --git a/tests/test_security.py b/tests/test_security.py index f50cc7c..a2d242b 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -304,26 +304,37 @@ class TestJwtClaims: # ---- JWT Secret Hardening ---- class TestJwtSecretHardening: - def test_raises_without_jwt_secret_in_non_test_env(self): - """Module-level code must raise RuntimeError when JWT_SECRET_KEY is absent - and TESTING is not set, preventing accidental production deploys with no secret.""" + def test_auto_generates_jwt_secret_when_absent(self, tmp_path): + """When JWT_SECRET_KEY is absent and TESTING is not set, + the secret is auto-generated and persisted to a file.""" saved_key = os.environ.pop("JWT_SECRET_KEY", None) saved_testing = os.environ.pop("TESTING", None) - # Eject any cached module so the re-import re-executes module-level code + saved_data_dir = os.environ.get("DATA_DIR") + os.environ["DATA_DIR"] = str(tmp_path) + # Eject cached modules so the re-import re-executes module-level code sys.modules.pop("app.auth.jwt", None) + sys.modules.pop("app.secrets", None) try: - with pytest.raises(RuntimeError, match="JWT_SECRET_KEY environment variable is required"): - importlib.import_module("app.auth.jwt") + importlib.import_module("app.auth.jwt") + secret_file = tmp_path / "state" / ".jwt_secret" + assert secret_file.exists(), "JWT secret file should be auto-generated" + secret = secret_file.read_text().strip() + assert len(secret) == 64, "Auto-generated secret should be 64 hex chars (32 bytes)" finally: # Restore environment before re-importing so the module loads cleanly if saved_key is not None: os.environ["JWT_SECRET_KEY"] = saved_key if saved_testing is not None: os.environ["TESTING"] = saved_testing + if saved_data_dir is not None: + os.environ["DATA_DIR"] = saved_data_dir + else: + os.environ.pop("DATA_DIR", None) # If neither was set (bare test run), use TESTING flag so reload works if saved_key is None and saved_testing is None: os.environ["TESTING"] = "1" sys.modules.pop("app.auth.jwt", None) + sys.modules.pop("app.secrets", None) importlib.import_module("app.auth.jwt") # Clean up the temporary TESTING flag if we added it if saved_key is None and saved_testing is None: