validops-east-1 commited on
Commit
a980424
·
1 Parent(s): 3d1f304

feat: friendly oauth scope map and sheets spreadsheet delete/list

Browse files
app/api/v1/google_oauth.py CHANGED
@@ -17,6 +17,7 @@ from app.models.schemas import (
17
  GoogleOAuthVerifyResponse,
18
  )
19
  from app.services.google_oauth_service import GoogleOAuthError, GoogleOAuthService
 
20
  from app.services.jwt_service import JWTService
21
  from app.config import get_settings
22
 
@@ -63,12 +64,20 @@ async def create_auth_url(
63
  service: GoogleOAuthService = Depends(get_oauth_service),
64
  ):
65
  start = time.perf_counter()
 
 
 
 
 
 
 
 
66
  state = _generate_state()
67
  auth_url = service.build_auth_url(
68
  client_id=body.client_id,
69
  redirect_uri=body.redirect_uri,
70
  state=state,
71
- scope=body.scope,
72
  prompt=body.prompt,
73
  access_type=body.access_type,
74
  login_hint=body.login_hint,
@@ -83,6 +92,8 @@ async def create_auth_url(
83
  success=True,
84
  auth_url=auth_url,
85
  state=state,
 
 
86
  )
87
 
88
 
 
17
  GoogleOAuthVerifyResponse,
18
  )
19
  from app.services.google_oauth_service import GoogleOAuthError, GoogleOAuthService
20
+ from app.services.google_scope_map import resolve_scope_list
21
  from app.services.jwt_service import JWTService
22
  from app.config import get_settings
23
 
 
64
  service: GoogleOAuthService = Depends(get_oauth_service),
65
  ):
66
  start = time.perf_counter()
67
+
68
+ resolved_scopes, scope_errors, dropped_scopes = resolve_scope_list(body.scopes)
69
+ if scope_errors:
70
+ raise HTTPException(
71
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
72
+ detail="; ".join(scope_errors),
73
+ )
74
+
75
  state = _generate_state()
76
  auth_url = service.build_auth_url(
77
  client_id=body.client_id,
78
  redirect_uri=body.redirect_uri,
79
  state=state,
80
+ scope=" ".join(resolved_scopes),
81
  prompt=body.prompt,
82
  access_type=body.access_type,
83
  login_hint=body.login_hint,
 
92
  success=True,
93
  auth_url=auth_url,
94
  state=state,
95
+ resolved_scopes=resolved_scopes,
96
+ dropped_scopes=dropped_scopes,
97
  )
98
 
99
 
app/api/v1/scopes/__init__.py CHANGED
@@ -2,11 +2,12 @@ from __future__ import annotations
2
 
3
  from fastapi import APIRouter
4
 
5
- from . import detail, list, names, set
6
 
7
  router = APIRouter(tags=["Google Scopes"])
8
  _PREFIX = "/google/scopes"
9
  router.include_router(list.router, prefix=_PREFIX)
10
  router.include_router(names.router, prefix=_PREFIX)
 
11
  router.include_router(detail.router, prefix=_PREFIX)
12
  router.include_router(set.router, prefix=_PREFIX)
 
2
 
3
  from fastapi import APIRouter
4
 
5
+ from . import detail, list, map, names, set
6
 
7
  router = APIRouter(tags=["Google Scopes"])
8
  _PREFIX = "/google/scopes"
9
  router.include_router(list.router, prefix=_PREFIX)
10
  router.include_router(names.router, prefix=_PREFIX)
11
+ router.include_router(map.router, prefix=_PREFIX)
12
  router.include_router(detail.router, prefix=_PREFIX)
13
  router.include_router(set.router, prefix=_PREFIX)
app/api/v1/scopes/map.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import List, Optional
5
+
6
+ from fastapi import APIRouter, HTTPException, Query
7
+
8
+ from app.models.schemas import GoogleScopeMapResponse
9
+ from app.services.google_scope_map import CATEGORY_ORDER, SCOPE_MAP
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.get(
15
+ "/map",
16
+ response_model=GoogleScopeMapResponse,
17
+ summary="Friendly Google OAuth scope alias map (grouped by API category)",
18
+ )
19
+ async def get_scope_map(
20
+ category: Optional[str] = Query(None, description="Return a single category only (e.g. gmail)"),
21
+ search: Optional[str] = Query(None, description="Free-text search across aliases and scope URIs"),
22
+ ) -> GoogleScopeMapResponse:
23
+ """Return the friendly scope alias -> full URI mapping.
24
+
25
+ Clients can read this map to pick short aliases (e.g. ``gmail_full``,
26
+ ``sheets_readonly``) and then pass them as a list in
27
+ ``POST /google/oauth/auth-url`` instead of pasting long scope URLs.
28
+ """
29
+ started = time.perf_counter()
30
+
31
+ def _matches(alias: str, uri: str, needle: Optional[str]) -> bool:
32
+ if not needle:
33
+ return True
34
+ needle_l = needle.lower()
35
+ return needle_l in alias.lower() or needle_l in uri.lower()
36
+
37
+ if category:
38
+ if category not in SCOPE_MAP:
39
+ valid = ", ".join(CATEGORY_ORDER)
40
+ raise HTTPException(
41
+ status_code=404,
42
+ detail=f"Unknown category '{category}'. Valid categories: {valid}",
43
+ )
44
+ selected: dict = {category: SCOPE_MAP[category]}
45
+ else:
46
+ selected = dict(SCOPE_MAP)
47
+
48
+ if search:
49
+ selected = {
50
+ cat: {
51
+ alias: uri
52
+ for alias, uri in entries.items()
53
+ if _matches(alias, uri, search)
54
+ }
55
+ for cat, entries in selected.items()
56
+ if any(_matches(a, u, search) for a, u in entries.items())
57
+ }
58
+
59
+ total_aliases = sum(len(entries) for entries in selected.values())
60
+ return GoogleScopeMapResponse(
61
+ success=True,
62
+ time_ms=round((time.perf_counter() - started) * 1000, 3),
63
+ count=total_aliases,
64
+ categories=list(selected.keys()),
65
+ map=selected,
66
+ )
app/api/v1/sheets.py CHANGED
@@ -162,6 +162,32 @@ async def create_spreadsheet(
162
  return _ok(start, creds, data)
163
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  @router.get("/spreadsheets/{spreadsheet_id}", response_model=SheetsGenericResponse,
166
  summary="Get spreadsheet metadata (spreadsheets.get)")
167
  async def get_spreadsheet(
@@ -185,6 +211,22 @@ async def get_spreadsheet(
185
  return _ok(start, creds, data)
186
 
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  @router.get("/spreadsheets/{spreadsheet_id}/sheets/{sheet_id}",
189
  response_model=SheetsGenericResponse,
190
  summary="Get a single sheet's metadata (spreadsheets.sheets.get)")
 
162
  return _ok(start, creds, data)
163
 
164
 
165
+ @router.get("/spreadsheets", response_model=SheetsGenericResponse,
166
+ summary="List all spreadsheets (drive.files.list, filtered to sheets)")
167
+ async def list_spreadsheets(
168
+ creds: SheetsCredentials = Depends(_credentials),
169
+ service: SheetsService = Depends(get_sheets_service),
170
+ page_size: int = 100,
171
+ page_token: Optional[str] = None,
172
+ include_trashed: bool = False,
173
+ ):
174
+ start = time.perf_counter()
175
+ if page_size < 1 or page_size > 1000:
176
+ raise HTTPException(status_code=400, detail="page_size must be between 1 and 1000.")
177
+ try:
178
+ data = await service.list_spreadsheets(
179
+ creds,
180
+ page_size=page_size,
181
+ page_token=page_token,
182
+ include_trashed=include_trashed,
183
+ )
184
+ except SheetsAPIError as exc:
185
+ raise _http_error(exc) from exc
186
+ files = (data or {}).get("files", []) or []
187
+ _logger.info("Sheets listed %d spreadsheets (%.2fms)", len(files), _elapsed_ms(start))
188
+ return _ok(start, creds, data)
189
+
190
+
191
  @router.get("/spreadsheets/{spreadsheet_id}", response_model=SheetsGenericResponse,
192
  summary="Get spreadsheet metadata (spreadsheets.get)")
193
  async def get_spreadsheet(
 
211
  return _ok(start, creds, data)
212
 
213
 
214
+ @router.delete("/spreadsheets/{spreadsheet_id}", response_model=SheetsGenericResponse,
215
+ summary="Delete a spreadsheet (drive.files.delete, pre-validated as a sheet)")
216
+ async def delete_spreadsheet(
217
+ spreadsheet_id: str,
218
+ creds: SheetsCredentials = Depends(_credentials),
219
+ service: SheetsService = Depends(get_sheets_service),
220
+ ):
221
+ start = time.perf_counter()
222
+ try:
223
+ data = await service.delete_spreadsheet(creds, spreadsheet_id)
224
+ except SheetsAPIError as exc:
225
+ raise _http_error(exc) from exc
226
+ _logger.info("Sheets spreadsheet %s deleted (%.2fms)", spreadsheet_id, _elapsed_ms(start))
227
+ return _ok(start, creds, data)
228
+
229
+
230
  @router.get("/spreadsheets/{spreadsheet_id}/sheets/{sheet_id}",
231
  response_model=SheetsGenericResponse,
232
  summary="Get a single sheet's metadata (spreadsheets.sheets.get)")
app/config.py CHANGED
@@ -151,6 +151,11 @@ class Settings(BaseSettings):
151
  sheets_default_scope: str = "https://www.googleapis.com/auth/spreadsheets"
152
  sheets_refresh_buffer_seconds: int = 60
153
 
 
 
 
 
 
154
  # Media-to-Media conversion settings
155
  media_output_dir: str = "./data/media-convert"
156
  media_max_workers: int = 4
 
151
  sheets_default_scope: str = "https://www.googleapis.com/auth/spreadsheets"
152
  sheets_refresh_buffer_seconds: int = 60
153
 
154
+ # Google Drive API settings (used to delete spreadsheets — the Sheets API
155
+ # has no delete method, so removal goes through drive.files.delete).
156
+ drive_api_base_url: str = "https://www.googleapis.com/drive/v3"
157
+ drive_default_scope: str = "https://www.googleapis.com/auth/drive.file"
158
+
159
  # Media-to-Media conversion settings
160
  media_output_dir: str = "./data/media-convert"
161
  media_max_workers: int = 4
app/models/schemas.py CHANGED
@@ -904,17 +904,47 @@ class GoogleOAuthUserInfo(BaseModel):
904
  class GoogleOAuthAuthUrlRequest(BaseModel):
905
  client_id: str = Field(..., min_length=1, max_length=500, description="Google OAuth Client ID")
906
  redirect_uri: str = Field(..., min_length=1, max_length=1000, description="Registered callback URL")
907
- scope: str = Field(default="openid email profile", min_length=1, max_length=2000, description="Space-separated OAuth scopes")
 
 
 
 
 
 
 
 
908
  prompt: Optional[str] = Field(None, pattern="^(none|consent|select_account)$", description="Google prompt parameter")
909
  access_type: Optional[str] = Field(None, pattern="^(online|offline)$", description="Whether to return a refresh token (offline)")
910
  login_hint: Optional[str] = Field(None, max_length=500, description="Prefilled user email to sign in as")
911
  include_granted_scopes: bool = Field(default=False, description="Append previously granted scopes")
912
 
 
 
 
 
 
 
 
913
 
914
  class GoogleOAuthAuthUrlResponse(BaseModel):
915
  success: bool
916
  auth_url: str
917
  state: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
918
  error: Optional[str] = None
919
 
920
 
@@ -1055,6 +1085,26 @@ class GoogleApiIdsResponse(BaseModel):
1055
  error: Optional[str] = None
1056
 
1057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1058
  class GoogleSetScopesRequest(BaseModel):
1059
  """Set the current scopes for one or more Google integrations."""
1060
 
@@ -2021,16 +2071,19 @@ class GmailRefreshResponse(BaseModel):
2021
  class SheetsScope(Enum):
2022
  """All Sheets-specific OAuth scopes (developers.google.com/sheets/api/auth).
2023
 
2024
- Drive scopes are intentionally excluded herethey belong to the separate
2025
- Google Drive API integration.
 
2026
  """
2027
 
2028
  READONLY = "https://www.googleapis.com/auth/spreadsheets.readonly"
2029
  SPREADSHEETS = "https://www.googleapis.com/auth/spreadsheets"
 
 
2030
 
2031
  @property
2032
  def permission_level(self) -> str:
2033
- return "sensitive"
2034
 
2035
 
2036
  class SheetsCreateSpreadsheetRequest(BaseModel):
 
904
  class GoogleOAuthAuthUrlRequest(BaseModel):
905
  client_id: str = Field(..., min_length=1, max_length=500, description="Google OAuth Client ID")
906
  redirect_uri: str = Field(..., min_length=1, max_length=1000, description="Registered callback URL")
907
+ scopes: Union[str, List[str]] = Field(
908
+ default=["openid", "email", "profile"],
909
+ description=(
910
+ "OAuth scopes. Accepts a list of friendly aliases (e.g. 'gmail_full') "
911
+ "and/or full scope URIs, or a single space-separated string for backward "
912
+ "compatibility. Aliases are resolved via the scope map; unknown names are "
913
+ "rejected."
914
+ ),
915
+ )
916
  prompt: Optional[str] = Field(None, pattern="^(none|consent|select_account)$", description="Google prompt parameter")
917
  access_type: Optional[str] = Field(None, pattern="^(online|offline)$", description="Whether to return a refresh token (offline)")
918
  login_hint: Optional[str] = Field(None, max_length=500, description="Prefilled user email to sign in as")
919
  include_granted_scopes: bool = Field(default=False, description="Append previously granted scopes")
920
 
921
+ @field_validator("scopes")
922
+ @classmethod
923
+ def _normalize_scopes(cls, v: Union[str, List[str]]) -> List[str]:
924
+ if isinstance(v, str):
925
+ return [s for s in v.split() if s]
926
+ return [str(s).strip() for s in v]
927
+
928
 
929
  class GoogleOAuthAuthUrlResponse(BaseModel):
930
  success: bool
931
  auth_url: str
932
  state: str
933
+ resolved_scopes: Optional[List[str]] = Field(
934
+ None,
935
+ description=(
936
+ "The final scope URIs sent to Google after alias resolution, "
937
+ "de-duplication and normalization (narrower scopes covered by a "
938
+ "broader one are dropped)."
939
+ ),
940
+ )
941
+ dropped_scopes: Optional[List[str]] = Field(
942
+ None,
943
+ description=(
944
+ "Scope URIs that were requested but omitted from the consent screen "
945
+ "because a broader scope already grants them."
946
+ ),
947
+ )
948
  error: Optional[str] = None
949
 
950
 
 
1085
  error: Optional[str] = None
1086
 
1087
 
1088
+ class GoogleScopeMapEntry(BaseModel):
1089
+ """A single friendly scope alias mapped to its full Google scope URI."""
1090
+
1091
+ alias: str = Field(..., description="Friendly alias the client can request")
1092
+ uri: str = Field(..., description="Full Google OAuth scope URI (or 'openid')")
1093
+ api: Optional[str] = Field(None, description="Human-friendly API/category label")
1094
+ description: Optional[str] = Field(None, description="What the scope allows")
1095
+
1096
+
1097
+ class GoogleScopeMapResponse(BaseModel):
1098
+ """The full friendly-alias to scope-URI mapping, grouped by category."""
1099
+
1100
+ success: bool
1101
+ time_ms: float
1102
+ count: int = 0
1103
+ categories: List[str] = Field(default_factory=list, description="Ordered category keys")
1104
+ map: Dict[str, Dict[str, str]] = Field(default_factory=dict, description="Category -> {alias: scope URI}")
1105
+ error: Optional[str] = None
1106
+
1107
+
1108
  class GoogleSetScopesRequest(BaseModel):
1109
  """Set the current scopes for one or more Google integrations."""
1110
 
 
2071
  class SheetsScope(Enum):
2072
  """All Sheets-specific OAuth scopes (developers.google.com/sheets/api/auth).
2073
 
2074
+ Includes the Drive scopes required to delete a spreadsheet — the Sheets API
2075
+ has no ``spreadsheets.delete`` method, so removal goes through the Drive
2076
+ API (``drive.files.delete``) which needs ``drive.file`` or ``drive``.
2077
  """
2078
 
2079
  READONLY = "https://www.googleapis.com/auth/spreadsheets.readonly"
2080
  SPREADSHEETS = "https://www.googleapis.com/auth/spreadsheets"
2081
+ DRIVE_FILE = "https://www.googleapis.com/auth/drive.file"
2082
+ DRIVE = "https://www.googleapis.com/auth/drive"
2083
 
2084
  @property
2085
  def permission_level(self) -> str:
2086
+ return "restricted" if self in (SheetsScope.DRIVE_FILE, SheetsScope.DRIVE) else "sensitive"
2087
 
2088
 
2089
  class SheetsCreateSpreadsheetRequest(BaseModel):
app/services/google_scope_map.py ADDED
@@ -0,0 +1,532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Optional, Tuple
4
+
5
+ # Friendly Google OAuth scope map.
6
+ #
7
+ # ``SCOPE_MAP`` groups every Google OAuth scope under a human-friendly category
8
+ # and gives each scope a short alias. Clients can request scopes by alias in
9
+ # POST /google/oauth/auth-url instead of pasting long scope URIs, e.g.
10
+ # ``["openid", "email", "gmail_full"]``.
11
+ #
12
+ # Generic full scope URIs are also accepted verbatim (the map is a convenience,
13
+ # not a restriction). Validation only rejects empty/unknown scope names.
14
+
15
+ SCOPE_MAP: Dict[str, Dict[str, str]] = {
16
+ "authentication": {
17
+ "openid": "openid",
18
+ "email": "https://www.googleapis.com/auth/userinfo.email",
19
+ "profile": "https://www.googleapis.com/auth/userinfo.profile",
20
+ "userinfo_email": "https://www.googleapis.com/auth/userinfo.email",
21
+ "userinfo_profile": "https://www.googleapis.com/auth/userinfo.profile",
22
+ },
23
+ "gmail": {
24
+ "gmail_full": "https://www.googleapis.com/auth/gmail.modify",
25
+ "gmail_readonly": "https://www.googleapis.com/auth/gmail.readonly",
26
+ "gmail_send": "https://www.googleapis.com/auth/gmail.send",
27
+ "gmail_compose": "https://www.googleapis.com/auth/gmail.compose",
28
+ "gmail_metadata": "https://www.googleapis.com/auth/gmail.metadata",
29
+ "gmail_insert": "https://www.googleapis.com/auth/gmail.insert",
30
+ "gmail_labels": "https://www.googleapis.com/auth/gmail.labels",
31
+ "gmail_settings_basic": "https://www.googleapis.com/auth/gmail.settings.basic",
32
+ "gmail_settings_sharing": "https://www.googleapis.com/auth/gmail.settings.sharing",
33
+ "gmail_permanent_delete": "https://mail.google.com/",
34
+ "gmail_addons_current_action_compose": "https://www.googleapis.com/auth/gmail.addons.current.action.compose",
35
+ "gmail_addons_current_message_action": "https://www.googleapis.com/auth/gmail.addons.current.message.action",
36
+ "gmail_addons_current_message_metadata": "https://www.googleapis.com/auth/gmail.addons.current.message.metadata",
37
+ "gmail_addons_current_message_readonly": "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
38
+ },
39
+ "google_sheets": {
40
+ "sheets_readwrite": "https://www.googleapis.com/auth/spreadsheets",
41
+ "sheets_readonly": "https://www.googleapis.com/auth/spreadsheets.readonly",
42
+ "sheets_drive_file": "https://www.googleapis.com/auth/drive.file",
43
+ "sheets_drive_readonly": "https://www.googleapis.com/auth/drive.readonly",
44
+ "sheets_drive_full": "https://www.googleapis.com/auth/drive",
45
+ },
46
+ "google_drive": {
47
+ "drive_file": "https://www.googleapis.com/auth/drive.file",
48
+ "drive_metadata_readonly": "https://www.googleapis.com/auth/drive.metadata.readonly",
49
+ "drive_readonly": "https://www.googleapis.com/auth/drive.readonly",
50
+ "drive_full": "https://www.googleapis.com/auth/drive",
51
+ "drive_metadata": "https://www.googleapis.com/auth/drive.metadata",
52
+ "drive_appdata": "https://www.googleapis.com/auth/drive.appdata",
53
+ "drive_apps_readonly": "https://www.googleapis.com/auth/drive.apps.readonly",
54
+ "drive_scripts": "https://www.googleapis.com/auth/drive.scripts",
55
+ "drive_install": "https://www.googleapis.com/auth/drive.install",
56
+ "drive_activity": "https://www.googleapis.com/auth/drive.activity",
57
+ "drive_activity_readonly": "https://www.googleapis.com/auth/drive.activity.readonly",
58
+ "drive_photos_readonly": "https://www.googleapis.com/auth/drive.photos.readonly",
59
+ "drive_meet_readonly": "https://www.googleapis.com/auth/drive.meet.readonly",
60
+ },
61
+ "google_docs": {
62
+ "docs_readwrite": "https://www.googleapis.com/auth/documents",
63
+ "docs_readonly": "https://www.googleapis.com/auth/documents.readonly",
64
+ "docs_drive_file": "https://www.googleapis.com/auth/drive.file",
65
+ "docs_drive_readonly": "https://www.googleapis.com/auth/drive.readonly",
66
+ "docs_drive_full": "https://www.googleapis.com/auth/drive",
67
+ },
68
+ "google_slides": {
69
+ "slides_readwrite": "https://www.googleapis.com/auth/presentations",
70
+ "slides_readonly": "https://www.googleapis.com/auth/presentations.readonly",
71
+ "slides_drive_file": "https://www.googleapis.com/auth/drive.file",
72
+ "slides_drive_readonly": "https://www.googleapis.com/auth/drive.readonly",
73
+ "slides_drive_full": "https://www.googleapis.com/auth/drive",
74
+ },
75
+ "google_calendar": {
76
+ "calendar_full": "https://www.googleapis.com/auth/calendar",
77
+ "calendar_readonly": "https://www.googleapis.com/auth/calendar.readonly",
78
+ "calendar_events": "https://www.googleapis.com/auth/calendar.events",
79
+ "calendar_events_readonly": "https://www.googleapis.com/auth/calendar.events.readonly",
80
+ "calendar_events_owned": "https://www.googleapis.com/auth/calendar.events.owned",
81
+ "calendar_events_owned_readonly": "https://www.googleapis.com/auth/calendar.events.owned.readonly",
82
+ "calendar_events_public_readonly": "https://www.googleapis.com/auth/calendar.events.public.readonly",
83
+ "calendar_events_freebusy": "https://www.googleapis.com/auth/calendar.events.freebusy",
84
+ "calendar_freebusy": "https://www.googleapis.com/auth/calendar.freebusy",
85
+ "calendar_settings_readonly": "https://www.googleapis.com/auth/calendar.settings.readonly",
86
+ "calendar_calendarlist": "https://www.googleapis.com/auth/calendar.calendarlist",
87
+ "calendar_calendarlist_readonly": "https://www.googleapis.com/auth/calendar.calendarlist.readonly",
88
+ "calendar_calendars": "https://www.googleapis.com/auth/calendar.calendars",
89
+ "calendar_calendars_readonly": "https://www.googleapis.com/auth/calendar.calendars.readonly",
90
+ "calendar_acls": "https://www.googleapis.com/auth/calendar.acls",
91
+ "calendar_acls_readonly": "https://www.googleapis.com/auth/calendar.acls.readonly",
92
+ "calendar_app_created": "https://www.googleapis.com/auth/calendar.app.created",
93
+ "calendar_addons_execute": "https://www.googleapis.com/auth/calendar.addons.execute",
94
+ "calendar_addons_current_event_read": "https://www.googleapis.com/auth/calendar.addons.current.event.read",
95
+ "calendar_addons_current_event_write": "https://www.googleapis.com/auth/calendar.addons.current.event.write",
96
+ },
97
+ "google_contacts": {
98
+ "contacts_full": "https://www.googleapis.com/auth/contacts",
99
+ "contacts_readonly": "https://www.googleapis.com/auth/contacts.readonly",
100
+ "contacts_other_readonly": "https://www.googleapis.com/auth/contacts.other.readonly",
101
+ "contacts_directory_readonly": "https://www.googleapis.com/auth/directory.readonly",
102
+ },
103
+ "google_tasks": {
104
+ "tasks_readwrite": "https://www.googleapis.com/auth/tasks",
105
+ "tasks_readonly": "https://www.googleapis.com/auth/tasks.readonly",
106
+ },
107
+ "google_photos": {
108
+ "photos_library_full": "https://www.googleapis.com/auth/photoslibrary",
109
+ "photos_library_readonly": "https://www.googleapis.com/auth/photoslibrary.readonly",
110
+ "photos_library_appendonly": "https://www.googleapis.com/auth/photoslibrary.appendonly",
111
+ "photos_library_edit_appcreated": "https://www.googleapis.com/auth/photoslibrary.edit.appcreateddata",
112
+ "photos_library_readonly_appcreated": "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
113
+ "photos_library_sharing": "https://www.googleapis.com/auth/photoslibrary.sharing",
114
+ },
115
+ "google_chat": {
116
+ "chat_messages": "https://www.googleapis.com/auth/chat.messages",
117
+ "chat_messages_readonly": "https://www.googleapis.com/auth/chat.messages.readonly",
118
+ "chat_spaces": "https://www.googleapis.com/auth/chat.spaces",
119
+ "chat_spaces_readonly": "https://www.googleapis.com/auth/chat.spaces.readonly",
120
+ "chat_import": "https://www.googleapis.com/auth/chat.import",
121
+ "chat_apps_configuration": "https://www.googleapis.com/auth/chat.apps.configuration",
122
+ "chat_apps_configuration_readonly": "https://www.googleapis.com/auth/chat.apps.configuration.readonly",
123
+ "chat_apps_readonly": "https://www.googleapis.com/auth/chat.apps.readonly",
124
+ "chat_pushes": "https://www.googleapis.com/auth/chat.pushes",
125
+ "chat_meetings": "https://www.googleapis.com/auth/chat.meetings",
126
+ "chat_meetings_readonly": "https://www.googleapis.com/auth/chat.meetings.readonly",
127
+ },
128
+ "google_meet": {
129
+ "meet_recordings_readonly": "https://www.googleapis.com/auth/drive.meet.readonly",
130
+ },
131
+ "google_forms": {
132
+ "forms_full": "https://www.googleapis.com/auth/forms",
133
+ "forms_responses_readonly": "https://www.googleapis.com/auth/forms.responses.readonly",
134
+ },
135
+ "google_classroom": {
136
+ "classroom_courses": "https://www.googleapis.com/auth/classroom.courses",
137
+ "classroom_courses_readonly": "https://www.googleapis.com/auth/classroom.courses.readonly",
138
+ "classroom_rosters": "https://www.googleapis.com/auth/classroom.rosters",
139
+ "classroom_rosters_readonly": "https://www.googleapis.com/auth/classroom.rosters.readonly",
140
+ "classroom_profile_emails": "https://www.googleapis.com/auth/classroom.profile.emails",
141
+ "classroom_profile_photos": "https://www.googleapis.com/auth/classroom.profile.photos",
142
+ "classroom_announcements": "https://www.googleapis.com/auth/classroom.announcements",
143
+ "classroom_announcements_readonly": "https://www.googleapis.com/auth/classroom.announcements.readonly",
144
+ "classroom_topics": "https://www.googleapis.com/auth/classroom.topics",
145
+ "classroom_topics_readonly": "https://www.googleapis.com/auth/classroom.topics.readonly",
146
+ "classroom_coursework_students": "https://www.googleapis.com/auth/classroom.coursework.students",
147
+ "classroom_coursework_students_readonly": "https://www.googleapis.com/auth/classroom.coursework.students.readonly",
148
+ "classroom_coursework_me": "https://www.googleapis.com/auth/classroom.coursework.me",
149
+ "classroom_coursework_me_readonly": "https://www.googleapis.com/auth/classroom.coursework.me.readonly",
150
+ "classroom_courseworkmaterials": "https://www.googleapis.com/auth/classroom.courseworkmaterials",
151
+ "classroom_courseworkmaterials_readonly": "https://www.googleapis.com/auth/classroom.courseworkmaterials.readonly",
152
+ "classroom_guardianlinks_students": "https://www.googleapis.com/auth/classroom.guardianlinks.students",
153
+ "classroom_guardianlinks_students_readonly": "https://www.googleapis.com/auth/classroom.guardianlinks.students.readonly",
154
+ "classroom_student_submissions_me_readonly": "https://www.googleapis.com/auth/classroom.student-submissions.me.readonly",
155
+ "classroom_student_submissions_students_readonly": "https://www.googleapis.com/auth/classroom.student-submissions.students.readonly",
156
+ },
157
+ "google_analytics": {
158
+ "analytics_full": "https://www.googleapis.com/auth/analytics",
159
+ "analytics_readonly": "https://www.googleapis.com/auth/analytics.readonly",
160
+ "analytics_edit": "https://www.googleapis.com/auth/analytics.edit",
161
+ "analytics_manage_users": "https://www.googleapis.com/auth/analytics.manage.users",
162
+ "analytics_manage_users_readonly": "https://www.googleapis.com/auth/analytics.manage.users.readonly",
163
+ "analytics_manage_edit": "https://www.googleapis.com/auth/analytics.manage.edit",
164
+ "analytics_manage_partners": "https://www.googleapis.com/auth/analytics.manage.partners",
165
+ "analytics_provision": "https://www.googleapis.com/auth/analytics.provision",
166
+ },
167
+ "google_ads": {
168
+ "google_ads_full": "https://www.googleapis.com/auth/adwords",
169
+ "admanager_readonly": "https://www.googleapis.com/auth/admanager.readonly",
170
+ },
171
+ "google_cloud": {
172
+ "bigquery_readonly": "https://www.googleapis.com/auth/bigquery.readonly",
173
+ "bigquery_full": "https://www.googleapis.com/auth/bigquery",
174
+ "bigquery_insertdata": "https://www.googleapis.com/auth/bigquery.insertdata",
175
+ "cloud_platform": "https://www.googleapis.com/auth/cloud-platform",
176
+ "cloud_platform_readonly": "https://www.googleapis.com/auth/cloud-platform.read-only",
177
+ "devstorage_read_only": "https://www.googleapis.com/auth/devstorage.read_only",
178
+ "devstorage_read_write": "https://www.googleapis.com/auth/devstorage.read_write",
179
+ "devstorage_full_control": "https://www.googleapis.com/auth/devstorage.full_control",
180
+ "compute_readonly": "https://www.googleapis.com/auth/compute.readonly",
181
+ "compute_full": "https://www.googleapis.com/auth/compute",
182
+ "datastore": "https://www.googleapis.com/auth/datastore",
183
+ "sqlservice_admin": "https://www.googleapis.com/auth/sqlservice.admin",
184
+ "vision": "https://www.googleapis.com/auth/cloud-vision",
185
+ "translate": "https://www.googleapis.com/auth/cloud-translation",
186
+ "language": "https://www.googleapis.com/auth/cloud-language",
187
+ "genai": "https://www.googleapis.com/auth/genai",
188
+ },
189
+ "google_maps": {},
190
+ "youtube": {
191
+ "youtube_readonly": "https://www.googleapis.com/auth/youtube.readonly",
192
+ "youtube_full": "https://www.googleapis.com/auth/youtube",
193
+ "youtube_upload": "https://www.googleapis.com/auth/youtube.upload",
194
+ "youtube_force_ssl": "https://www.googleapis.com/auth/youtube.force-ssl",
195
+ "youtube_channel_memberships_creator": "https://www.googleapis.com/auth/youtube.channel-memberships.creator",
196
+ "youtube_partner": "https://www.googleapis.com/auth/youtubepartner",
197
+ "youtube_partner_channel_audit": "https://www.googleapis.com/auth/youtubepartner-channel-audit",
198
+ },
199
+ "google_play": {
200
+ "play_androidpublisher": "https://www.googleapis.com/auth/androidpublisher",
201
+ "play_games": "https://www.googleapis.com/auth/playgames",
202
+ "play_games_readonly": "https://www.googleapis.com/auth/playgames.readonly",
203
+ },
204
+ "firebase": {
205
+ "firebase_full": "https://www.googleapis.com/auth/firebase",
206
+ "firebase_readonly": "https://www.googleapis.com/auth/firebase.readonly",
207
+ "firebase_database": "https://www.googleapis.com/auth/firebase.database",
208
+ "firebase_database_readonly": "https://www.googleapis.com/auth/firebase.database.readonly",
209
+ "firebase_messaging": "https://www.googleapis.com/auth/firebase.messaging",
210
+ "firebase_rules": "https://www.googleapis.com/auth/firebase.rules",
211
+ "firebase_rules_readonly": "https://www.googleapis.com/auth/firebase.rules.readonly",
212
+ "firebase_analytics": "https://www.googleapis.com/auth/firebase.analytics",
213
+ },
214
+ "admin_sdk": {
215
+ "admin_directory_user_readonly": "https://www.googleapis.com/auth/admin.directory.user.readonly",
216
+ "admin_directory_user": "https://www.googleapis.com/auth/admin.directory.user",
217
+ "admin_directory_group_readonly": "https://www.googleapis.com/auth/admin.directory.group.readonly",
218
+ "admin_directory_group": "https://www.googleapis.com/auth/admin.directory.group",
219
+ "admin_directory_group_member_readonly": "https://www.googleapis.com/auth/admin.directory.group.member.readonly",
220
+ "admin_directory_group_member": "https://www.googleapis.com/auth/admin.directory.group.member",
221
+ "admin_directory_device_chromeos_readonly": "https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly",
222
+ "admin_directory_device_chromeos": "https://www.googleapis.com/auth/admin.directory.device.chromeos",
223
+ "admin_directory_orgunit_readonly": "https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
224
+ "admin_directory_orgunit": "https://www.googleapis.com/auth/admin.directory.orgunit",
225
+ "admin_directory_rolemanagement_readonly": "https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly",
226
+ "admin_directory_rolemanagement": "https://www.googleapis.com/auth/admin.directory.rolemanagement",
227
+ "admin_directory_domain_readonly": "https://www.googleapis.com/auth/admin.directory.domain.readonly",
228
+ "admin_directory_customer_readonly": "https://www.googleapis.com/auth/admin.directory.customer.readonly",
229
+ "admin_directory_userschema_readonly": "https://www.googleapis.com/auth/admin.directory.userschema.readonly",
230
+ "admin_reports_audit_readonly": "https://www.googleapis.com/auth/admin.reports.audit.readonly",
231
+ "admin_reports_usage_readonly": "https://www.googleapis.com/auth/admin.reports.usage.readonly",
232
+ "admin_datatransfer": "https://www.googleapis.com/auth/admin.datatransfer",
233
+ },
234
+ "people_api": {
235
+ "people_contacts_full": "https://www.googleapis.com/auth/contacts",
236
+ "people_contacts_readonly": "https://www.googleapis.com/auth/contacts.readonly",
237
+ "people_othercontacts": "https://www.googleapis.com/auth/people.otherContacts",
238
+ "people_othercontacts_readonly": "https://www.googleapis.com/auth/people.otherContacts.readonly",
239
+ "people_email_readonly": "https://www.googleapis.com/auth/people.emailAddresses.readonly",
240
+ "people_phone_readonly": "https://www.googleapis.com/auth/people.phoneNumbers.readonly",
241
+ "people_contactinfo": "https://www.googleapis.com/auth/people.contactInfo",
242
+ "people_contactinfo_readonly": "https://www.googleapis.com/auth/people.contactInfo.readonly",
243
+ "people_directory_readonly": "https://www.googleapis.com/auth/people.directory.readonly",
244
+ "user_emails_readonly": "https://www.googleapis.com/auth/user.emails.readonly",
245
+ "user_phonenumbers_readonly": "https://www.googleapis.com/auth/user.phonenumbers.readonly",
246
+ },
247
+ "business_profile": {
248
+ "business_manage": "https://www.googleapis.com/auth/business.manage",
249
+ },
250
+ "other": {
251
+ "openid": "openid",
252
+ "email": "https://www.googleapis.com/auth/userinfo.email",
253
+ "profile": "https://www.googleapis.com/auth/userinfo.profile",
254
+ },
255
+ }
256
+
257
+ CATEGORY_ORDER: List[str] = list(SCOPE_MAP.keys())
258
+
259
+ ALIAS_TO_URI: Dict[str, str] = {
260
+ alias: uri
261
+ for category in SCOPE_MAP.values()
262
+ for alias, uri in category.items()
263
+ }
264
+
265
+ ALL_ALIASES: List[str] = sorted(ALIAS_TO_URI)
266
+
267
+ # Scope containment for UX normalization. When a broader scope is already in
268
+ # the request, narrower scopes of the same family are redundant and are dropped
269
+ # so the Google consent screen stays minimal. Only documented supersets are
270
+ # listed; generic/unknown scopes are never touched.
271
+ _SCOPE_INCLUDED_BY: Dict[str, Tuple[str, ...]] = {
272
+ # --- Gmail family ---
273
+ "https://www.googleapis.com/auth/gmail.metadata": (
274
+ "https://www.googleapis.com/auth/gmail.readonly",
275
+ "https://www.googleapis.com/auth/gmail.modify",
276
+ "https://mail.google.com/",
277
+ ),
278
+ "https://www.googleapis.com/auth/gmail.readonly": (
279
+ "https://www.googleapis.com/auth/gmail.modify",
280
+ "https://mail.google.com/",
281
+ ),
282
+ "https://www.googleapis.com/auth/gmail.labels": (
283
+ "https://www.googleapis.com/auth/gmail.modify",
284
+ "https://mail.google.com/",
285
+ ),
286
+ "https://www.googleapis.com/auth/gmail.send": (
287
+ "https://www.googleapis.com/auth/gmail.compose",
288
+ "https://www.googleapis.com/auth/gmail.modify",
289
+ "https://mail.google.com/",
290
+ ),
291
+ "https://www.googleapis.com/auth/gmail.compose": (
292
+ "https://www.googleapis.com/auth/gmail.modify",
293
+ "https://mail.google.com/",
294
+ ),
295
+ "https://www.googleapis.com/auth/gmail.modify": (
296
+ "https://mail.google.com/",
297
+ ),
298
+ # --- Sheets / Drive family ---
299
+ "https://www.googleapis.com/auth/spreadsheets.readonly": (
300
+ "https://www.googleapis.com/auth/spreadsheets",
301
+ ),
302
+ "https://www.googleapis.com/auth/drive.metadata.readonly": (
303
+ "https://www.googleapis.com/auth/drive.readonly",
304
+ "https://www.googleapis.com/auth/drive.metadata",
305
+ "https://www.googleapis.com/auth/drive",
306
+ ),
307
+ "https://www.googleapis.com/auth/drive.metadata": (
308
+ "https://www.googleapis.com/auth/drive",
309
+ ),
310
+ "https://www.googleapis.com/auth/drive.readonly": (
311
+ "https://www.googleapis.com/auth/drive",
312
+ ),
313
+ "https://www.googleapis.com/auth/drive.file": (
314
+ "https://www.googleapis.com/auth/drive",
315
+ ),
316
+ "https://www.googleapis.com/auth/drive.appdata": (
317
+ "https://www.googleapis.com/auth/drive",
318
+ ),
319
+ "https://www.googleapis.com/auth/drive.scripts": (
320
+ "https://www.googleapis.com/auth/drive",
321
+ ),
322
+ "https://www.googleapis.com/auth/drive.activity.readonly": (
323
+ "https://www.googleapis.com/auth/drive.activity",
324
+ "https://www.googleapis.com/auth/drive",
325
+ ),
326
+ "https://www.googleapis.com/auth/drive.activity": (
327
+ "https://www.googleapis.com/auth/drive",
328
+ ),
329
+ "https://www.googleapis.com/auth/drive.photos.readonly": (
330
+ "https://www.googleapis.com/auth/drive",
331
+ ),
332
+ "https://www.googleapis.com/auth/drive.meet.readonly": (
333
+ "https://www.googleapis.com/auth/drive",
334
+ ),
335
+ # --- Calendar family ---
336
+ "https://www.googleapis.com/auth/calendar.events.readonly": (
337
+ "https://www.googleapis.com/auth/calendar.events",
338
+ "https://www.googleapis.com/auth/calendar.readonly",
339
+ "https://www.googleapis.com/auth/calendar",
340
+ ),
341
+ "https://www.googleapis.com/auth/calendar.events": (
342
+ "https://www.googleapis.com/auth/calendar",
343
+ ),
344
+ "https://www.googleapis.com/auth/calendar.events.owned.readonly": (
345
+ "https://www.googleapis.com/auth/calendar.events.owned",
346
+ "https://www.googleapis.com/auth/calendar.events.readonly",
347
+ "https://www.googleapis.com/auth/calendar.readonly",
348
+ "https://www.googleapis.com/auth/calendar",
349
+ ),
350
+ "https://www.googleapis.com/auth/calendar.events.owned": (
351
+ "https://www.googleapis.com/auth/calendar.events",
352
+ "https://www.googleapis.com/auth/calendar",
353
+ ),
354
+ "https://www.googleapis.com/auth/calendar.events.public.readonly": (
355
+ "https://www.googleapis.com/auth/calendar.readonly",
356
+ "https://www.googleapis.com/auth/calendar",
357
+ ),
358
+ "https://www.googleapis.com/auth/calendar.events.freebusy": (
359
+ "https://www.googleapis.com/auth/calendar.freebusy",
360
+ "https://www.googleapis.com/auth/calendar",
361
+ ),
362
+ "https://www.googleapis.com/auth/calendar.readonly": (
363
+ "https://www.googleapis.com/auth/calendar",
364
+ ),
365
+ "https://www.googleapis.com/auth/calendar.freebusy": (
366
+ "https://www.googleapis.com/auth/calendar",
367
+ ),
368
+ "https://www.googleapis.com/auth/calendar.settings.readonly": (
369
+ "https://www.googleapis.com/auth/calendar",
370
+ ),
371
+ "https://www.googleapis.com/auth/calendar.calendarlist.readonly": (
372
+ "https://www.googleapis.com/auth/calendar.calendarlist",
373
+ "https://www.googleapis.com/auth/calendar",
374
+ ),
375
+ "https://www.googleapis.com/auth/calendar.calendarlist": (
376
+ "https://www.googleapis.com/auth/calendar",
377
+ ),
378
+ "https://www.googleapis.com/auth/calendar.calendars.readonly": (
379
+ "https://www.googleapis.com/auth/calendar.calendars",
380
+ "https://www.googleapis.com/auth/calendar",
381
+ ),
382
+ "https://www.googleapis.com/auth/calendar.calendars": (
383
+ "https://www.googleapis.com/auth/calendar",
384
+ ),
385
+ "https://www.googleapis.com/auth/calendar.acls.readonly": (
386
+ "https://www.googleapis.com/auth/calendar.acls",
387
+ "https://www.googleapis.com/auth/calendar",
388
+ ),
389
+ "https://www.googleapis.com/auth/calendar.acls": (
390
+ "https://www.googleapis.com/auth/calendar",
391
+ ),
392
+ # --- Contacts / People family ---
393
+ "https://www.googleapis.com/auth/contacts.readonly": (
394
+ "https://www.googleapis.com/auth/contacts",
395
+ ),
396
+ "https://www.googleapis.com/auth/contacts.other.readonly": (
397
+ "https://www.googleapis.com/auth/contacts",
398
+ ),
399
+ # --- Tasks family ---
400
+ "https://www.googleapis.com/auth/tasks.readonly": (
401
+ "https://www.googleapis.com/auth/tasks",
402
+ ),
403
+ # --- YouTube family ---
404
+ "https://www.googleapis.com/auth/youtube.readonly": (
405
+ "https://www.googleapis.com/auth/youtube.force-ssl",
406
+ "https://www.googleapis.com/auth/youtube",
407
+ ),
408
+ "https://www.googleapis.com/auth/youtube.upload": (
409
+ "https://www.googleapis.com/auth/youtube",
410
+ ),
411
+ "https://www.googleapis.com/auth/youtube.force-ssl": (
412
+ "https://www.googleapis.com/auth/youtube",
413
+ ),
414
+ "https://www.googleapis.com/auth/youtube.channel-memberships.creator": (
415
+ "https://www.googleapis.com/auth/youtube",
416
+ ),
417
+ "https://www.googleapis.com/auth/youtubepartner-channel-audit": (
418
+ "https://www.googleapis.com/auth/youtubepartner",
419
+ ),
420
+ # --- Forms family ---
421
+ "https://www.googleapis.com/auth/forms.responses.readonly": (
422
+ "https://www.googleapis.com/auth/forms",
423
+ ),
424
+ # --- Analytics family ---
425
+ "https://www.googleapis.com/auth/analytics.readonly": (
426
+ "https://www.googleapis.com/auth/analytics.edit",
427
+ "https://www.googleapis.com/auth/analytics",
428
+ ),
429
+ "https://www.googleapis.com/auth/analytics.edit": (
430
+ "https://www.googleapis.com/auth/analytics",
431
+ ),
432
+ "https://www.googleapis.com/auth/analytics.manage.users.readonly": (
433
+ "https://www.googleapis.com/auth/analytics.manage.users",
434
+ ),
435
+ # --- BigQuery / Cloud family ---
436
+ "https://www.googleapis.com/auth/bigquery.readonly": (
437
+ "https://www.googleapis.com/auth/bigquery",
438
+ ),
439
+ "https://www.googleapis.com/auth/bigquery.insertdata": (
440
+ "https://www.googleapis.com/auth/bigquery",
441
+ ),
442
+ "https://www.googleapis.com/auth/cloud-platform.read-only": (
443
+ "https://www.googleapis.com/auth/cloud-platform",
444
+ ),
445
+ "https://www.googleapis.com/auth/devstorage.read_only": (
446
+ "https://www.googleapis.com/auth/devstorage.read_write",
447
+ "https://www.googleapis.com/auth/devstorage.full_control",
448
+ ),
449
+ "https://www.googleapis.com/auth/devstorage.read_write": (
450
+ "https://www.googleapis.com/auth/devstorage.full_control",
451
+ ),
452
+ # --- Admin SDK family ---
453
+ "https://www.googleapis.com/auth/admin.directory.user.readonly": (
454
+ "https://www.googleapis.com/auth/admin.directory.user",
455
+ ),
456
+ "https://www.googleapis.com/auth/admin.directory.group.readonly": (
457
+ "https://www.googleapis.com/auth/admin.directory.group",
458
+ ),
459
+ "https://www.googleapis.com/auth/admin.directory.group.member.readonly": (
460
+ "https://www.googleapis.com/auth/admin.directory.group.member",
461
+ ),
462
+ "https://www.googleapis.com/auth/admin.directory.device.chromeos.readonly": (
463
+ "https://www.googleapis.com/auth/admin.directory.device.chromeos",
464
+ ),
465
+ "https://www.googleapis.com/auth/admin.directory.orgunit.readonly": (
466
+ "https://www.googleapis.com/auth/admin.directory.orgunit",
467
+ ),
468
+ "https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly": (
469
+ "https://www.googleapis.com/auth/admin.directory.rolemanagement",
470
+ ),
471
+ }
472
+
473
+
474
+ def is_generic_scope(value: str) -> bool:
475
+ """True when the value is a full scope URI the client typed directly."""
476
+ return value.startswith(("http://", "https://", "mail.google.com/")) or value == "openid"
477
+
478
+
479
+ def _normalize_scopes(resolved: List[str]) -> Tuple[List[str], List[str]]:
480
+ """Drop scopes already covered by a broader scope in the same request.
481
+
482
+ Returns ``(kept_scopes, dropped_scopes)``. Order of the remaining scopes is
483
+ preserved. Unknown/generic scopes (not in the containment table) are never
484
+ dropped.
485
+ """
486
+ present = set(resolved)
487
+ kept: List[str] = []
488
+ dropped: List[str] = []
489
+ for uri in resolved:
490
+ supersets = _SCOPE_INCLUDED_BY.get(uri, ())
491
+ if any(superset in present for superset in supersets):
492
+ dropped.append(uri)
493
+ continue
494
+ kept.append(uri)
495
+ return kept, dropped
496
+
497
+
498
+ def resolve_scope_list(
499
+ scopes: Optional[List[str]], *, normalize: bool = True
500
+ ) -> Tuple[List[str], List[str], List[str]]:
501
+ """Resolve a list of scope aliases / URIs into a de-duplicated list of URIs.
502
+
503
+ Returns ``(resolved_uris, errors, dropped_scopes)``. Generic full scope URIs
504
+ are accepted verbatim; only empty or unknown scope names produce errors.
505
+ When ``normalize`` is true, narrower scopes that are fully covered by a
506
+ broader scope already present are dropped (best UX: minimal consent screen).
507
+ """
508
+ resolved: List[str] = []
509
+ errors: List[str] = []
510
+ dropped: List[str] = []
511
+ seen = set()
512
+ for raw in scopes or []:
513
+ value = str(raw or "").strip()
514
+ if not value:
515
+ errors.append("Scope name is missing (empty entry).")
516
+ continue
517
+ if value in ALIAS_TO_URI:
518
+ uri = ALIAS_TO_URI[value]
519
+ elif is_generic_scope(value):
520
+ uri = value
521
+ else:
522
+ errors.append(
523
+ f"Unknown scope '{value}'. It is not a known alias and not a full scope URI. "
524
+ f"Valid aliases: {', '.join(ALL_ALIASES)}"
525
+ )
526
+ continue
527
+ if uri not in seen:
528
+ seen.add(uri)
529
+ resolved.append(uri)
530
+ if normalize:
531
+ resolved, dropped = _normalize_scopes(resolved)
532
+ return resolved, errors, dropped
app/services/sheets_service.py CHANGED
@@ -91,8 +91,9 @@ class SheetsService:
91
  *,
92
  params: Optional[Dict[str, Any]] = None,
93
  json_body: Optional[Any] = None,
 
94
  ) -> Any:
95
- """Send a Sheets API request with proactive + on-401 refresh and retries."""
96
  if creds.needs_proactive_refresh:
97
  await self._refresh(creds, context=f"{method} {path}")
98
 
@@ -100,7 +101,7 @@ class SheetsService:
100
  attempt = 0
101
  while True:
102
  response = await self._send(
103
- creds, method, path, params=params, json_body=json_body
104
  )
105
  if 200 <= response.status_code < 300:
106
  return self._decode(response)
@@ -130,9 +131,10 @@ class SheetsService:
130
  *,
131
  params: Optional[Dict[str, Any]] = None,
132
  json_body: Optional[Any] = None,
 
133
  ) -> httpx.Response:
134
  client = await self._get_client()
135
- url = f"{_settings.sheets_api_base_url}{path}"
136
  headers = {
137
  "Authorization": f"Bearer {creds.access_token}",
138
  "Accept": "application/json",
@@ -388,6 +390,96 @@ class SheetsService:
388
  body["sheets"] = sheets
389
  return await self._request(creds, "POST", "/spreadsheets", json_body=body)
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  async def get_spreadsheet(
392
  self,
393
  creds: SheetsCredentials,
 
91
  *,
92
  params: Optional[Dict[str, Any]] = None,
93
  json_body: Optional[Any] = None,
94
+ base_url: Optional[str] = None,
95
  ) -> Any:
96
+ """Send a Sheets/Drive API request with proactive + on-401 refresh and retries."""
97
  if creds.needs_proactive_refresh:
98
  await self._refresh(creds, context=f"{method} {path}")
99
 
 
101
  attempt = 0
102
  while True:
103
  response = await self._send(
104
+ creds, method, path, params=params, json_body=json_body, base_url=base_url
105
  )
106
  if 200 <= response.status_code < 300:
107
  return self._decode(response)
 
131
  *,
132
  params: Optional[Dict[str, Any]] = None,
133
  json_body: Optional[Any] = None,
134
+ base_url: Optional[str] = None,
135
  ) -> httpx.Response:
136
  client = await self._get_client()
137
+ url = f"{base_url or _settings.sheets_api_base_url}{path}"
138
  headers = {
139
  "Authorization": f"Bearer {creds.access_token}",
140
  "Accept": "application/json",
 
390
  body["sheets"] = sheets
391
  return await self._request(creds, "POST", "/spreadsheets", json_body=body)
392
 
393
+ _SPREADSHEET_MIME_TYPE = "application/vnd.google-apps.spreadsheet"
394
+
395
+ async def get_drive_file(
396
+ self,
397
+ creds: SheetsCredentials,
398
+ file_id: str,
399
+ ) -> Dict[str, Any]:
400
+ """Read a Drive file's metadata (drive.files.get) — used to validate the
401
+ target before deletion so arbitrary non-sheet files cannot be removed."""
402
+ return await self._request(
403
+ creds,
404
+ "GET",
405
+ f"/files/{file_id}",
406
+ params={"fields": "id,name,mimeType,trashed"},
407
+ base_url=_settings.drive_api_base_url,
408
+ )
409
+
410
+ async def list_spreadsheets(
411
+ self,
412
+ creds: SheetsCredentials,
413
+ *,
414
+ page_size: int = 100,
415
+ page_token: Optional[str] = None,
416
+ include_trashed: bool = False,
417
+ ) -> Dict[str, Any]:
418
+ """List the user's Google Sheets via drive.files.list.
419
+
420
+ Filters to ``application/vnd.google-apps.spreadsheet`` so only
421
+ spreadsheets are returned (no arbitrary Drive files). The Sheets API has
422
+ no list endpoint; Drive's ``files.list`` with a ``q`` filter is the
423
+ documented way to enumerate spreadsheets.
424
+ """
425
+ query = f"mimeType='{self._SPREADSHEET_MIME_TYPE}'"
426
+ if not include_trashed:
427
+ query += " and trashed=false"
428
+
429
+ params: Dict[str, Any] = {
430
+ "q": query,
431
+ "pageSize": page_size,
432
+ "fields": "nextPageToken,files(id,name,mimeType,trashed,modifiedTime)",
433
+ }
434
+ if page_token:
435
+ params["pageToken"] = page_token
436
+ return await self._request(
437
+ creds,
438
+ "GET",
439
+ "/files",
440
+ params=params,
441
+ base_url=_settings.drive_api_base_url,
442
+ )
443
+
444
+
445
+ async def delete_spreadsheet(
446
+ self,
447
+ creds: SheetsCredentials,
448
+ spreadsheet_id: str,
449
+ ) -> Dict[str, Any]:
450
+ """Delete a spreadsheet via drive.files.delete.
451
+
452
+ Pre-validates with drive.files.get that the target file is a Google
453
+ Sheet (``mimeType == application/vnd.google-apps.spreadsheet``) so this
454
+ endpoint can never remove an arbitrary Drive file. Requires the
455
+ ``drive.file`` (or broader ``drive``) OAuth scope — the Sheets API has no
456
+ delete method.
457
+ """
458
+ file_meta = await self.get_drive_file(creds, spreadsheet_id)
459
+ mime = (file_meta or {}).get("mimeType")
460
+ if mime != self._SPREADSHEET_MIME_TYPE:
461
+ raise SheetsAPIError(
462
+ f"Refusing to delete '{spreadsheet_id}': it is not a Google Sheet "
463
+ f"(mimeType={mime or 'unknown'}). Only spreadsheets can be removed "
464
+ "through the spreadsheet delete API.",
465
+ status_code=400,
466
+ reason="INVALID_MIME_TYPE",
467
+ )
468
+
469
+ await self._request(
470
+ creds,
471
+ "DELETE",
472
+ f"/files/{spreadsheet_id}",
473
+ base_url=_settings.drive_api_base_url,
474
+ )
475
+ return {
476
+ "spreadsheetId": spreadsheet_id,
477
+ "mimeType": mime,
478
+ "name": file_meta.get("name"),
479
+ "deleted": True,
480
+ }
481
+
482
+
483
  async def get_spreadsheet(
484
  self,
485
  creds: SheetsCredentials,