Files
kite/backend/api/cloud/schemas.py
T
curo1305 5c0bc2a3b4 feat(13-04): upgrade Google Drive scope to drive, add health/reconnect/content API helpers
- connections.py: Update Google Drive OAuth to broader `drive` scope (D-17)
  instead of `drive.file` so authorized users can operate on all existing Drive
  items, not just files created by this app. Both initiation and callback flows
  updated to keep scopes consistent.
- schemas.py: Add Phase 13 typed response schemas — ConnectionHealthOut, ReconnectOut,
  ContentResultOut, MutationResultOut, and request schemas CreateFolderRequest,
  RenameItemRequest, MoveItemRequest. All credential-free (T-13-14, CONN-03).
- api/cloud.js: Add centralized frontend helpers for getConnectionHealth,
  reconnectCloudConnection, testCloudConnection (D-12, D-13), plus authorized
  cloud content and mutation helpers: openCloudFile, previewCloudFile,
  createCloudFolder, renameCloudItem, moveCloudItem, deleteCloudItem, uploadCloudFile.

All reconnect/health/test tests pass (14/14). Security suite passes (19/19).
2026-06-22 18:49:47 +02:00

205 lines
7.3 KiB
Python

"""
Whitelisted Pydantic response schemas for the cloud API package.
All schemas are explicit allowlists — credentials_enc, tokens, and passwords
are deliberately absent. T-12-03: credential exclusion by design.
Phase 13 additions:
- ConnectionHealthOut: typed health status response (D-12, CONN-03)
- ReconnectOut: typed reconnect response (CONN-01..03)
- ContentResultOut: typed open/preview/download response (D-02, D-18, T-13-14)
- MutationResultOut: typed kind/reason mutation response (D-05..11)
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
# ── Capability / item schemas ─────────────────────────────────────────────────
class CloudCapabilityOut(BaseModel):
"""Whitelisted capability descriptor. reason/message only when not supported."""
action: str
state: str # "supported" | "unsupported" | "temporarily_unavailable"
reason: Optional[str] = None
message: Optional[str] = None
class CloudItemOut(BaseModel):
"""Normalized cloud item metadata. No credentials or byte content."""
id: str # DocuVault stable UUID
provider_item_id: str
name: str
kind: str # "file" | "folder"
parent_ref: Optional[str] = None
content_type: Optional[str] = None
size: Optional[int] = None
modified_at: Optional[datetime] = None
etag: Optional[str] = None
capabilities: dict[str, CloudCapabilityOut] = {}
# ── Freshness / folder state schemas ─────────────────────────────────────────
class FolderFreshnessOut(BaseModel):
"""Freshness and error state for a browsed folder."""
refresh_state: str # "fresh" | "refreshing" | "warning"
last_refreshed_at: Optional[datetime] = None
error_code: Optional[str] = None
error_message: Optional[str] = None
# ── Browse response ───────────────────────────────────────────────────────────
class CloudBrowseResponse(BaseModel):
"""Owner-scoped connection-ID browse response.
T-12-01: items are always scoped to the resolved connection which is owned
by the requesting user. credentials_enc is never included.
"""
connection_id: str
provider: str
display_name: str
parent_ref: Optional[str]
items: List[CloudItemOut]
capabilities: dict[str, CloudCapabilityOut]
freshness: FolderFreshnessOut
# ── Connection schemas ────────────────────────────────────────────────────────
class ConnectionRenameRequest(BaseModel):
"""Validated PATCH body for renaming a connection display name.
Only display_name is accepted — mass assignment prevention.
"""
display_name: str = Field(..., max_length=255)
@field_validator("display_name")
@classmethod
def must_be_nonblank(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("display_name must not be blank")
return stripped
# ── Phase 13 health / reconnect schemas ────────────────────────────────────────
class ConnectionHealthOut(BaseModel):
"""Typed connection health response.
D-12: Explicit health status available without probing on every browse.
CONN-03: Never exposes credentials_enc, tokens, or raw provider URLs.
status: 'healthy' | 'degraded' | 'auth_failed' | 'offline'
"""
status: str
connection_id: str
provider: str
display_name: Optional[str] = None
class ReconnectOut(BaseModel):
"""Typed reconnect result.
CONN-01: No new connection row created (reconnected patches in place).
CONN-02: credentials_enc updated with re-encrypted token.
CONN-03: No credentials, tokens, or provider URLs in response.
D-14: Cached metadata preserved as stale.
"""
status: str
connection_id: str
provider: str
display_name: Optional[str] = None
reconnected: bool = True
# ── Phase 13 content result schemas ───────────────────────────────────────────
class ContentResultOut(BaseModel):
"""Typed open/preview result body.
D-02: Provider credentials and raw provider URLs must never appear in responses.
D-18: Preview is binary-only; unsupported formats fall back to download fallback.
T-13-14: Stable kind/reason codes let the frontend route without parsing provider payloads.
kind: 'open' | 'preview' | 'download' | 'unsupported_preview'
reason: discriminator code (e.g. 'binary_supported', 'unsupported_format', 'authorized')
url: DocuVault-scoped authorized URL (never a raw provider URL)
"""
kind: str
reason: Optional[str] = None
url: Optional[str] = None
content_type: Optional[str] = None
# ── Phase 13 mutation result schemas ──────────────────────────────────────────
class MutationResultOut(BaseModel):
"""Typed mutation result body used by rename, move, delete, and create-folder.
T-13-14: Stable kind/reason codes let the frontend route without parsing
raw provider error payloads.
kind: 'renamed' | 'moved' | 'deleted' | 'folder' | 'conflict' | 'stale' |
'offline' | 'reauth_required' | 'invalid_destination' | 'unsupported_operation'
reason: discriminator detail (e.g. 'trashed', 'permanent', 'name_collision',
'item_changed', 'provider_unreachable', 'token_expired',
'self_destination', 'cross_connection', 'provider_unsupported')
"""
kind: str
reason: Optional[str] = None
name: Optional[str] = None
provider_item_id: Optional[str] = None
parent_ref: Optional[str] = None
# ── Phase 13 mutation request schemas ─────────────────────────────────────────
class CreateFolderRequest(BaseModel):
"""Request body for POST /connections/{id}/folders."""
parent_ref: Optional[str] = None
name: str = Field(..., max_length=255)
@field_validator("name")
@classmethod
def must_be_nonblank(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("name must not be blank")
return stripped
class RenameItemRequest(BaseModel):
"""Request body for PATCH /connections/{id}/items/{item_id}/rename."""
new_name: str = Field(..., max_length=255)
etag: Optional[str] = None
@field_validator("new_name")
@classmethod
def must_be_nonblank(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("new_name must not be blank")
return stripped
class MoveItemRequest(BaseModel):
"""Request body for POST /connections/{id}/items/{item_id}/move."""
destination_parent_ref: str
destination_connection_id: Optional[str] = None
etag: Optional[str] = None