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).
This commit is contained in:
@@ -170,6 +170,9 @@ async def _oauth_authorization_url(provider: str, redirect_uri: str, state_token
|
|||||||
if provider == "google_drive":
|
if provider == "google_drive":
|
||||||
from google_auth_oauthlib.flow import Flow # lazy import
|
from google_auth_oauthlib.flow import Flow # lazy import
|
||||||
|
|
||||||
|
# D-17: Phase 13 requests the full `drive` scope so authorized users can
|
||||||
|
# operate on all existing Drive items, not just files created by this app.
|
||||||
|
# Consent text must make the expanded scope explicit.
|
||||||
flow = Flow.from_client_config(
|
flow = Flow.from_client_config(
|
||||||
{
|
{
|
||||||
"web": {
|
"web": {
|
||||||
@@ -179,7 +182,7 @@ async def _oauth_authorization_url(provider: str, redirect_uri: str, state_token
|
|||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scopes=["https://www.googleapis.com/auth/drive.file"],
|
scopes=["https://www.googleapis.com/auth/drive"],
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
authorization_url, _ = flow.authorization_url(
|
authorization_url, _ = flow.authorization_url(
|
||||||
@@ -211,6 +214,7 @@ async def _oauth_credentials_from_code(provider: str, redirect_uri: str, code: O
|
|||||||
if provider == "google_drive":
|
if provider == "google_drive":
|
||||||
from google_auth_oauthlib.flow import Flow # lazy import
|
from google_auth_oauthlib.flow import Flow # lazy import
|
||||||
|
|
||||||
|
# D-17: Broader Phase 13 drive scope (must match initiation scope above).
|
||||||
flow = Flow.from_client_config(
|
flow = Flow.from_client_config(
|
||||||
{
|
{
|
||||||
"web": {
|
"web": {
|
||||||
@@ -220,7 +224,7 @@ async def _oauth_credentials_from_code(provider: str, redirect_uri: str, code: O
|
|||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scopes=["https://www.googleapis.com/auth/drive.file"],
|
scopes=["https://www.googleapis.com/auth/drive"],
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(flow.fetch_token, code=code)
|
await asyncio.to_thread(flow.fetch_token, code=code)
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ Whitelisted Pydantic response schemas for the cloud API package.
|
|||||||
|
|
||||||
All schemas are explicit allowlists — credentials_enc, tokens, and passwords
|
All schemas are explicit allowlists — credentials_enc, tokens, and passwords
|
||||||
are deliberately absent. T-12-03: credential exclusion by design.
|
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 __future__ import annotations
|
||||||
|
|
||||||
@@ -84,3 +90,115 @@ class ConnectionRenameRequest(BaseModel):
|
|||||||
if not stripped:
|
if not stripped:
|
||||||
raise ValueError("display_name must not be blank")
|
raise ValueError("display_name must not be blank")
|
||||||
return stripped
|
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
|
||||||
|
|||||||
@@ -87,3 +87,149 @@ export function updateWebDavCredentials(connectionId, { serverUrl, username, pas
|
|||||||
if (password !== undefined && password !== '') body.password = password
|
if (password !== undefined && password !== '') body.password = password
|
||||||
return jsonRequest(`/api/cloud/connections/${connectionId}/credentials`, 'PUT', body)
|
return jsonRequest(`/api/cloud/connections/${connectionId}/credentials`, 'PUT', body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phase 13: Connection health, reconnect, and test ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the health status of a cloud connection without probing the provider.
|
||||||
|
*
|
||||||
|
* D-12: Health is available explicitly (not inferred from browse).
|
||||||
|
* Returns {status, connection_id, provider, display_name}.
|
||||||
|
* status: 'healthy' | 'degraded' | 'auth_failed' | 'offline'
|
||||||
|
*/
|
||||||
|
export function getConnectionHealth(connectionId) {
|
||||||
|
return request(`/api/cloud/connections/${connectionId}/health`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect an AUTH_FAILED connection in-place.
|
||||||
|
*
|
||||||
|
* CONN-01: Patches the existing row — no new connection row created.
|
||||||
|
* CONN-02: Re-encrypts refreshed credentials in place.
|
||||||
|
* CONN-03: Response is credential-free.
|
||||||
|
* D-14: Cached metadata preserved as stale; caches invalidated server-side.
|
||||||
|
*/
|
||||||
|
export function reconnectCloudConnection(connectionId) {
|
||||||
|
return jsonRequest(`/api/cloud/connections/${connectionId}/reconnect`, 'POST', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicitly test a cloud connection by probing the provider.
|
||||||
|
*
|
||||||
|
* D-13: Explicit Test action — not triggered on every folder navigation.
|
||||||
|
* Updates the connection status row and returns the new health status.
|
||||||
|
*/
|
||||||
|
export function testCloudConnection(connectionId) {
|
||||||
|
return jsonRequest(`/api/cloud/connections/${connectionId}/test`, 'POST', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 13: Authorized cloud content access ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a cloud file via a DocuVault-authorized URL.
|
||||||
|
*
|
||||||
|
* D-02: Provider credentials and raw provider URLs are never exposed.
|
||||||
|
* Returns {kind: 'open', url: '<authorized-docuvault-url>'}.
|
||||||
|
* The caller must navigate to the returned URL — never window.open to a provider URL.
|
||||||
|
*/
|
||||||
|
export function openCloudFile(connectionId, itemId) {
|
||||||
|
return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/open`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an in-app preview of a cloud file (binary formats only).
|
||||||
|
*
|
||||||
|
* D-18: Preview is binary-only. Unsupported formats return
|
||||||
|
* {kind: 'unsupported_preview', reason: 'unsupported_format'} and the caller
|
||||||
|
* should fall back to openCloudFile() for authorized download.
|
||||||
|
* D-02: No raw provider URL or credentials in the response.
|
||||||
|
*/
|
||||||
|
export function previewCloudFile(connectionId, itemId) {
|
||||||
|
return request(`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/preview`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 13: Cloud mutations ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a folder in cloud storage.
|
||||||
|
*
|
||||||
|
* D-05: Collision auto-naming: 'Projects (1)', 'Projects (2)', etc.
|
||||||
|
* Returns {kind: 'folder', provider_item_id, name} on success.
|
||||||
|
* Returns {kind: 'unsupported_operation', reason: 'provider_unsupported'} when
|
||||||
|
* the provider does not support folder creation.
|
||||||
|
*/
|
||||||
|
export function createCloudFolder(connectionId, parentRef, name) {
|
||||||
|
return jsonRequest(`/api/cloud/connections/${connectionId}/folders`, 'POST', {
|
||||||
|
parent_ref: parentRef,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a cloud item.
|
||||||
|
*
|
||||||
|
* D-05: Counter-suffix collision policy applies.
|
||||||
|
* D-07: etag guards against operating on stale metadata.
|
||||||
|
* Returns {kind: 'stale', reason: 'item_changed'} when externally changed.
|
||||||
|
* Returns {kind: 'renamed', name} on success.
|
||||||
|
*/
|
||||||
|
export function renameCloudItem(connectionId, itemId, newName, etag) {
|
||||||
|
return jsonRequest(
|
||||||
|
`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/rename`,
|
||||||
|
'PATCH',
|
||||||
|
{ new_name: newName, etag },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a cloud item within the same connection.
|
||||||
|
*
|
||||||
|
* D-08: Moves are restricted to the same cloud connection.
|
||||||
|
* D-09: Self and descendant destinations are rejected.
|
||||||
|
* Returns {kind: 'moved', parent_ref} on success.
|
||||||
|
* Returns {kind: 'invalid_destination', reason: '...'} for invalid moves.
|
||||||
|
*/
|
||||||
|
export function moveCloudItem(connectionId, itemId, destinationParentRef, etag) {
|
||||||
|
return jsonRequest(
|
||||||
|
`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}/move`,
|
||||||
|
'POST',
|
||||||
|
{ destination_parent_ref: destinationParentRef, etag },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a cloud item (trash preferred, permanent when unavailable).
|
||||||
|
*
|
||||||
|
* D-11: Response discloses whether trash or permanent delete was performed via
|
||||||
|
* {kind: 'deleted', reason: 'trashed' | 'permanent'}.
|
||||||
|
* D-10: Callers must confirm before calling — the API deletes immediately.
|
||||||
|
*/
|
||||||
|
export function deleteCloudItem(connectionId, itemId) {
|
||||||
|
return request(
|
||||||
|
`/api/cloud/connections/${connectionId}/items/${encodeURIComponent(itemId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to a cloud folder.
|
||||||
|
*
|
||||||
|
* D-03: Same-name upload returns {kind: 'conflict', reason: 'name_collision'} —
|
||||||
|
* never overwrites silently.
|
||||||
|
* D-04: Caller manages the sequential upload queue; this function uploads one file.
|
||||||
|
*
|
||||||
|
* @param {string} connectionId - Connection UUID
|
||||||
|
* @param {string} parentRef - Parent folder provider_item_id
|
||||||
|
* @param {string} filename - Target filename
|
||||||
|
* @param {File|Blob} file - File bytes
|
||||||
|
*/
|
||||||
|
export function uploadCloudFile(connectionId, parentRef, filename, file) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file, filename)
|
||||||
|
formData.append('parent_ref', parentRef ?? '')
|
||||||
|
formData.append('filename', filename)
|
||||||
|
return request(`/api/cloud/connections/${connectionId}/items/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user