diff --git a/app/workspace/bff.py b/app/workspace/bff.py new file mode 100644 index 0000000..4371df2 --- /dev/null +++ b/app/workspace/bff.py @@ -0,0 +1,184 @@ +"""Read-only Workspace BFF projections. + +The v1 BFF is deliberately narrower than the legacy workspace compatibility API: +no command, persistence, or approval identifiers cross this boundary. +""" +from __future__ import annotations + +import base64 +import hashlib +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +class BFFUnavailableError(RuntimeError): + """The projection cannot be read safely from the current local database.""" + + +class BFFNotFoundError(LookupError): + """The opaque public reference does not identify a supported object.""" + + +@dataclass(frozen=True) +class ActivityPage: + items: list[dict[str, Any]] + next_cursor: str | None + + +def public_ref(kind: str, value: str) -> str: + """Return a stable opaque reference without exposing persistence identifiers. + + This is an object reference, not an authorization credential. Local loopback + isolation remains the authorization boundary. + """ + material = f"archeaxis-workspace-bff-v1\0{kind}\0{value}".encode() + return f"wr1_{hashlib.sha256(material).hexdigest()[:32]}" + + +def _cursor_encode(updated_at: str, reference: str) -> str: + raw = f"{updated_at}\0{reference}".encode() + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _cursor_decode(cursor: str) -> tuple[str, str]: + try: + padded = cursor + "=" * (-len(cursor) % 4) + updated_at, reference = base64.urlsafe_b64decode(padded).decode("utf-8").split("\0", 1) + except (ValueError, UnicodeDecodeError, base64.binascii.Error) as exc: + raise ValueError("invalid activity cursor") from exc + if not updated_at or not reference: + raise ValueError("invalid activity cursor") + return updated_at, reference + + +def _connection(db_path: str | Path) -> sqlite3.Connection: + connection = sqlite3.connect(Path(db_path), timeout=30.0) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout=30000") + connection.execute("PRAGMA query_only=ON") + return connection + + +def _require_table(connection: sqlite3.Connection, table: str) -> None: + exists = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,) + ).fetchone() + if exists is None: + raise BFFUnavailableError(f"workspace projection table unavailable: {table}") + + +def _activity_rows(connection: sqlite3.Connection) -> list[dict[str, Any]]: + _require_table(connection, "workspace_jobs_v1") + _require_table(connection, "research_packages_v1") + rows: list[dict[str, Any]] = [] + for row in connection.execute( + "SELECT job_id, state, updated_at FROM workspace_jobs_v1" + ).fetchall(): + reference = public_ref("job", str(row["job_id"])) + rows.append( + { + "public_ref": reference, + "kind": "job", + "label": "资料导入", + "state": str(row["state"]), + "updated_at": str(row["updated_at"]), + } + ) + for row in connection.execute( + "SELECT canonical_url, status, created_at FROM research_packages_v1" + ).fetchall(): + reference = public_ref("source", str(row["canonical_url"])) + rows.append( + { + "public_ref": reference, + "kind": "source", + "label": "研究资料", + "state": str(row["status"]), + "updated_at": str(row["created_at"]), + } + ) + return sorted( + rows, + key=lambda item: (item["updated_at"], item["public_ref"]), + reverse=True, + ) + + +def activity(*, db_path: str | Path, limit: int = 20, cursor: str | None = None) -> dict[str, Any]: + if not 1 <= limit <= 50: + raise ValueError("limit must be between 1 and 50") + with _connection(db_path) as connection: + rows = _activity_rows(connection) + if cursor: + cursor_time, cursor_ref = _cursor_decode(cursor) + rows = [ + item + for item in rows + if (item["updated_at"], item["public_ref"]) < (cursor_time, cursor_ref) + ] + page = rows[:limit] + next_cursor = None + if len(rows) > limit: + tail = page[-1] + next_cursor = _cursor_encode(tail["updated_at"], tail["public_ref"]) + return {"schema_version": "v1", "items": page, "next_cursor": next_cursor} + + +def _object_from_row(kind: str, row: sqlite3.Row) -> dict[str, Any]: + if kind == "job": + value = str(row["job_id"]) + return { + "schema_version": "v1", + "kind": "job", + "public_ref": public_ref(kind, value), + "label": "资料导入", + "state": str(row["state"]), + "updated_at": str(row["updated_at"]), + } + value = str(row["canonical_url"]) + return { + "schema_version": "v1", + "kind": "source", + "public_ref": public_ref(kind, value), + "label": "研究资料", + "source": value, + "state": str(row["status"]), + "updated_at": str(row["created_at"]), + } + + +def object_by_ref(*, db_path: str | Path, reference: str) -> dict[str, Any]: + if not reference.startswith("wr1_") or len(reference) != 36: + raise BFFNotFoundError("workspace object was not found") + with _connection(db_path) as connection: + _require_table(connection, "workspace_jobs_v1") + for row in connection.execute( + "SELECT job_id, state, updated_at FROM workspace_jobs_v1" + ).fetchall(): + if public_ref("job", str(row["job_id"])) == reference: + return _object_from_row("job", row) + _require_table(connection, "research_packages_v1") + for row in connection.execute( + "SELECT canonical_url, status, created_at FROM research_packages_v1" + ).fetchall(): + if public_ref("source", str(row["canonical_url"])) == reference: + return _object_from_row("source", row) + raise BFFNotFoundError("workspace object was not found") + + +def home(*, db_path: str | Path) -> dict[str, Any]: + from app.workspace.service import workspace_status + + status = workspace_status(db_path=db_path) + recent = activity(db_path=db_path, limit=5)["items"] + return { + "schema_version": "v1", + "observed_at": status["observed_at"], + "release": status["release"], + "components": status["components"], + "counts": status["counts"], + "capabilities": status["capabilities"], + "recent_activity": recent, + } diff --git a/app/workspace/router.py b/app/workspace/router.py index ae853ec..dc81e91 100644 --- a/app/workspace/router.py +++ b/app/workspace/router.py @@ -12,7 +12,8 @@ from fastapi.responses import FileResponse from pydantic import BaseModel, ConfigDict, Field, ValidationError -from app.workspace import service +from app.workspace import bff, service +from app.workspace.bff import BFFNotFoundError, BFFUnavailableError from shared.storage import DB_PATH WORKSPACE_PREFIX = "/" + "workspace" @@ -191,6 +192,40 @@ def workspace_status(request: Request) -> dict[str, object]: return _command_error(lambda: service.workspace_status(db_path=DB_PATH)) +def _bff_error(action): + try: + return action() + except BFFNotFoundError as exc: + raise HTTPException(status_code=404, detail="workspace object was not found") from exc + except (BFFUnavailableError, RuntimeError) as exc: + raise HTTPException(status_code=503, detail="workspace projection is unavailable") from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.get("/api/v1/home") +def workspace_bff_home(request: Request) -> dict[str, object]: + """Read-only v1 home projection; persistence IDs never cross this boundary.""" + _local_principal(request) + return _bff_error(lambda: bff.home(db_path=DB_PATH)) + + +@router.get("/api/v1/activity") +def workspace_bff_activity( + request: Request, limit: int = 20, cursor: str | None = None +) -> dict[str, object]: + """Stable cursor-paginated activity projection.""" + _local_principal(request) + return _bff_error(lambda: bff.activity(db_path=DB_PATH, limit=limit, cursor=cursor)) + + +@router.get("/api/v1/objects/{public_ref}") +def workspace_bff_object(public_ref: str, request: Request) -> dict[str, object]: + """Resolve only opaque public references to read-only object DTOs.""" + _local_principal(request) + return _bff_error(lambda: bff.object_by_ref(db_path=DB_PATH, reference=public_ref)) + + @router.get("/api/_desktop/ready") def desktop_readiness(request: Request) -> dict[str, str]: launch_token = os.getenv("COGNITIVE_DESKTOP_LAUNCH_TOKEN", "") diff --git a/docs/contracts/WORKSPACE_BFF_V1.md b/docs/contracts/WORKSPACE_BFF_V1.md new file mode 100644 index 0000000..9645d2c --- /dev/null +++ b/docs/contracts/WORKSPACE_BFF_V1.md @@ -0,0 +1,113 @@ +# Workspace BFF v1 Contract + +Status: **BE01 implementation candidate — read-only contract** +Owner: Cognitive-OS workspace boundary +Scope: local loopback product shell only + +## 1. Boundary + +The v1 BFF is a server-owned projection boundary for the product shell. It is not a +second persistence API and it is not an authorization credential. + +- Base path: `/workspace/api/v1` +- Transport: same-origin loopback HTTP only +- Methods in this contract: `GET` only +- No API key, JWT, approval reason, retry command, cancellation command, or manual + action is required by the local product shell +- Existing `/workspace/api/*` endpoints remain a compatibility surface and are not + part of this contract. UI migration must not silently mix the two surfaces. + +## 2. Privacy and identity rules + +Responses MUST NOT contain `command_id`, `job_id`, `package_id`, `artifact_id`, +`unit_id`, database paths, backup paths, SQL/table names, credentials, or raw +persistence payloads. + +Object identity is represented by `public_ref`: + +- format: `wr1_` plus 32 lowercase hexadecimal characters; +- stable for the same object key and contract version; +- does not encode a persistence identifier; +- is not an authorization token; loopback and same-origin checks remain mandatory; +- an unknown or malformed reference returns the same 404 object-not-found shape. + +## 3. DTOs + +### `GET /workspace/api/v1/home` + +```json +{ + "schema_version": "v1", + "observed_at": "2026-08-01T00:00:00Z", + "release": { "version": "0.4.1", "status": "..." }, + "components": { "api": "available", "database": "available" }, + "counts": {}, + "capabilities": {}, + "recent_activity": [Activity] +} +``` + +The existing truthful aggregate status is reused; no fabricated progress or ETA is +allowed. If its projection cannot be read, the endpoint returns HTTP 503 with +`{"detail":"workspace projection is unavailable"}`. + +### `GET /workspace/api/v1/activity` + +Parameters: + +- `limit`: integer `1..50`, default `20`; +- `cursor`: opaque cursor returned by the previous page. + +```json +{ + "schema_version": "v1", + "items": [Activity], + "next_cursor": "..." +} +``` + +`items` are stably ordered by `updated_at` descending, then `public_ref` +descending. Invalid cursors return HTTP 422. There is no offset pagination. + +`Activity`: + +```json +{ + "public_ref": "wr1_...", + "kind": "job|source", + "label": "资料导入|研究资料", + "state": "candidate|queued|succeeded|...", + "updated_at": "2026-08-01T00:00:00Z" +} +``` + +### `GET /workspace/api/v1/objects/{public_ref}` + +Supported kinds in this first slice are `job` and `source`. A successful object +response contains only the corresponding public DTO. Unknown, expired, malformed, +or unsupported references return HTTP 404; the response must not reveal which +lookup failed. + +## 4. Failure semantics + +| Condition | HTTP | Meaning | +|---|---:|---| +| malformed limit/cursor | 422 | caller input invalid | +| object is unknown | 404 | no object is revealed | +| projection table/data unavailable | 503 | retry/readiness issue, no partial fake data | +| non-loopback, cross-site, or wrong-origin request | 403 | local boundary rejected | + +## 5. Action boundary + +The v1 BFF has no write routes. Approval, retry, delivery, learning, practice, +Runtime promotion, intake, and cancellation remain outside BE01 and must not be +triggered by a GET or hidden browser-side side effect. + +## 6. Acceptance gates + +- API route inventory shows only GET operations under `/workspace/api/v1`. +- Integration tests prove public-ref mapping, cursor pagination, 404/503 behavior, + and absence of internal identifiers. +- `git diff --check`, Ruff, and targeted pytest pass. +- A later UI01 task may consume this contract only after deep-link and route + migration tests are added in its own branch/PR. diff --git a/tests/test_workspace_bff_contract.py b/tests/test_workspace_bff_contract.py new file mode 100644 index 0000000..d48fc94 --- /dev/null +++ b/tests/test_workspace_bff_contract.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import sqlite3 + +from fastapi.testclient import TestClient + + +def _database(tmp_path): + path = tmp_path / "workspace.sqlite" + with sqlite3.connect(path) as connection: + connection.executescript( + """ + CREATE TABLE workspace_jobs_v1 ( + job_id TEXT PRIMARY KEY, + state TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE research_packages_v1 ( + canonical_url TEXT PRIMARY KEY, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ); + INSERT INTO workspace_jobs_v1 VALUES + ('job-internal-1', 'succeeded', '2026-08-01T02:00:00Z'), + ('job-internal-2', 'queued', '2026-08-01T01:00:00Z'); + INSERT INTO research_packages_v1 VALUES + ('https://example.com/source', 'candidate', '2026-08-01T03:00:00Z'); + """ + ) + return path + + +def test_bff_v1_is_read_only_and_hides_persistence_identifiers(monkeypatch, tmp_path) -> None: + from app.main import app + from app.workspace import router + + database = _database(tmp_path) + monkeypatch.setattr(router, "DB_PATH", database) + client = TestClient(app) + + response = client.get("/workspace/api/v1/activity?limit=2") + assert response.status_code == 200 + payload = response.json() + assert payload["schema_version"] == "v1" + assert len(payload["items"]) == 2 + assert payload["next_cursor"] + assert all(item["public_ref"].startswith("wr1_") for item in payload["items"]) + assert "job-internal-1" not in response.text + assert "job-internal-2" not in response.text + assert "command_id" not in response.text + assert "package_id" not in response.text + + next_page = client.get( + "/workspace/api/v1/activity", + params={"limit": 2, "cursor": payload["next_cursor"]}, + ) + assert next_page.status_code == 200 + assert len(next_page.json()["items"]) == 1 + assert next_page.json()["items"][0]["kind"] == "job" + assert next_page.json()["items"][0]["updated_at"] == "2026-08-01T01:00:00Z" + + assert client.post("/workspace/api/v1/activity").status_code == 405 + + +def test_bff_v1_object_resolution_and_uniform_unknown_reference(monkeypatch, tmp_path) -> None: + from app.main import app + from app.workspace import bff, router + + database = _database(tmp_path) + monkeypatch.setattr(router, "DB_PATH", database) + client = TestClient(app) + + reference = bff.public_ref("source", "https://example.com/source") + resolved = client.get(f"/workspace/api/v1/objects/{reference}") + assert resolved.status_code == 200 + assert resolved.json() == { + "schema_version": "v1", + "kind": "source", + "public_ref": reference, + "label": "研究资料", + "source": "https://example.com/source", + "state": "candidate", + "updated_at": "2026-08-01T03:00:00Z", + } + assert "canonical_url" not in resolved.text + + for unknown in ("bad", "wr1_" + "0" * 32): + missing = client.get(f"/workspace/api/v1/objects/{unknown}") + assert missing.status_code == 404 + assert missing.json() == {"detail": "workspace object was not found"} + + +def test_bff_v1_rejects_invalid_cursor_and_reports_unavailable(monkeypatch, tmp_path) -> None: + from app.main import app + from app.workspace import router + + database = _database(tmp_path) + monkeypatch.setattr(router, "DB_PATH", database) + client = TestClient(app) + assert client.get("/workspace/api/v1/activity?limit=0").status_code == 422 + assert client.get("/workspace/api/v1/activity?cursor=not-a-cursor").status_code == 422 + + missing_database = tmp_path / "missing.sqlite" + monkeypatch.setattr(router, "DB_PATH", missing_database) + unavailable = client.get("/workspace/api/v1/activity") + assert unavailable.status_code == 503 + assert unavailable.json() == {"detail": "workspace projection is unavailable"} + + +def test_bff_v1_home_uses_the_real_workspace_status_projection(monkeypatch, tmp_path) -> None: + from app.main import app + from app.workspace import router + from shared.migration_runner import MigrationOperator + from tests.test_phase5_mcs_closed_loop import _database + + database = _database(tmp_path) + MigrationOperator(db_path=database, backup_dir=tmp_path / "backups").apply( + "workspace.sqlite" + ) + monkeypatch.setattr(router, "DB_PATH", database) + + response = TestClient(app).get("/workspace/api/v1/home") + assert response.status_code == 200 + payload = response.json() + assert payload["schema_version"] == "v1" + assert payload["components"]["api"] == "available" + assert payload["components"]["database"] == "available" + assert "job_id" not in response.text + assert "package_id" not in response.text + assert "database_path" not in response.text