deeptutor-server 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. deeptutor/api/__init__.py +0 -0
  2. deeptutor/api/contracts/__init__.py +21 -0
  3. deeptutor/api/contracts/export.py +110 -0
  4. deeptutor/api/contracts/turn_protocol.py +313 -0
  5. deeptutor/api/main.py +547 -0
  6. deeptutor/api/module_routes.py +42 -0
  7. deeptutor/api/router_index.json +5752 -0
  8. deeptutor/api/router_loader.py +139 -0
  9. deeptutor/api/routers/__init__.py +1 -0
  10. deeptutor/api/routers/agent_config.py +59 -0
  11. deeptutor/api/routers/attachments.py +79 -0
  12. deeptutor/api/routers/auth.py +1937 -0
  13. deeptutor/api/routers/capabilities.py +44 -0
  14. deeptutor/api/routers/capabilities_settings.py +38 -0
  15. deeptutor/api/routers/dashboard.py +292 -0
  16. deeptutor/api/routers/file_library.py +169 -0
  17. deeptutor/api/routers/file_preview.py +205 -0
  18. deeptutor/api/routers/imports.py +146 -0
  19. deeptutor/api/routers/learning_journal.py +18 -0
  20. deeptutor/api/routers/mcp_settings.py +133 -0
  21. deeptutor/api/routers/modules.py +12 -0
  22. deeptutor/api/routers/multi_user.py +943 -0
  23. deeptutor/api/routers/outputs.py +97 -0
  24. deeptutor/api/routers/personas.py +154 -0
  25. deeptutor/api/routers/plugins.py +416 -0
  26. deeptutor/api/routers/quiz_judge.py +464 -0
  27. deeptutor/api/routers/sessions.py +801 -0
  28. deeptutor/api/routers/settings.py +2503 -0
  29. deeptutor/api/routers/skills.py +315 -0
  30. deeptutor/api/routers/space_cli_apps.py +222 -0
  31. deeptutor/api/routers/space_mcp.py +460 -0
  32. deeptutor/api/routers/subagents.py +306 -0
  33. deeptutor/api/routers/system.py +633 -0
  34. deeptutor/api/routers/task_board.py +139 -0
  35. deeptutor/api/routers/tools.py +268 -0
  36. deeptutor/api/routers/unified_ws.py +385 -0
  37. deeptutor/api/routers/workspace.py +371 -0
  38. deeptutor/api/run_server.py +99 -0
  39. deeptutor/api/security_guard.py +91 -0
  40. deeptutor/api/utils/http_headers.py +27 -0
  41. deeptutor/api/utils/progress_broadcaster.py +73 -0
  42. deeptutor/api/utils/task_id_manager.py +120 -0
  43. deeptutor/api/utils/task_log_stream.py +362 -0
  44. deeptutor/api/utils/tool_options.py +133 -0
  45. deeptutor_server-1.7.0.dist-info/METADATA +14 -0
  46. deeptutor_server-1.7.0.dist-info/RECORD +48 -0
  47. deeptutor_server-1.7.0.dist-info/WHEEL +5 -0
  48. deeptutor_server-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ """Request-scoped delivery of generated output artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, status
8
+ from fastapi.responses import FileResponse
9
+
10
+ from deeptutor.api.routers.auth import require_auth
11
+ from deeptutor.multi_user.context import get_current_user_or_none
12
+ from deeptutor.multi_user.partner_access import visible_partners
13
+ from deeptutor.multi_user.paths import get_current_path_service, get_path_service_for_scope
14
+ from deeptutor.services.auth import TokenPayload
15
+ from deeptutor.services.path_service import PathService
16
+
17
+ router = APIRouter()
18
+
19
+ # Video lessons are pages of model-written script. Served under this CSP they
20
+ # run in an opaque origin even when opened directly, never with the API's
21
+ # cookies or same-origin access.
22
+ _SANDBOXED_PAGE_CSP = "sandbox allow-scripts allow-popups"
23
+
24
+
25
+ def _request_path_service() -> PathService:
26
+ """Resolve the workspace installed by ``require_auth`` without fallback.
27
+
28
+ The general-purpose ``get_path_service()`` retains a compatibility fallback
29
+ to the local admin workspace for non-request callers. A download endpoint
30
+ must fail closed instead: otherwise an authentication/context regression
31
+ could expose an administrator artifact to an ordinary request.
32
+ """
33
+ user = get_current_user_or_none()
34
+ if user is None:
35
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Output not found")
36
+ return get_current_path_service()
37
+
38
+
39
+ def _resolve_output(path_service: PathService, relative_path: str) -> Path:
40
+ output_path = path_service.resolve_public_output_path(relative_path)
41
+ if output_path is None:
42
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Output not found")
43
+ return output_path
44
+
45
+
46
+ def _resolve_partner_output(relative_path: str) -> Path | None:
47
+ """Resolve *relative_path* against partner scopes the caller may use.
48
+
49
+ Partner web chats run inside a synthetic workspace under
50
+ ``data/partners/<id>/workspace``, so generated files are not beneath the
51
+ human caller's own ``data/users/<uid>`` tree. The public artifact URL shape
52
+ is still ``/files/outputs/<relative path>``, which means the download surface
53
+ has to search the caller's visible partner workspaces when their own
54
+ workspace misses (#1012).
55
+
56
+ The first-party chat flow already scopes the request to one conversation, so
57
+ the same relative path colliding across multiple partners is vanishingly
58
+ unlikely. Fail closed anyway: only a unique partner match is served.
59
+ """
60
+ from deeptutor.modules.catalog import module_enabled
61
+ from deeptutor.services.workspace.context import current_workspace_id
62
+
63
+ if current_workspace_id() or not module_enabled("partners"):
64
+ return None
65
+ from deeptutor.services.partners.scope import partner_scope
66
+
67
+ matches: list[Path] = []
68
+ for partner in visible_partners():
69
+ partner_id = str(partner.get("partner_id") or "").strip()
70
+ if not partner_id:
71
+ continue
72
+ candidate = get_path_service_for_scope(
73
+ partner_scope(partner_id)
74
+ ).resolve_public_output_path(relative_path)
75
+ if candidate is not None:
76
+ matches.append(candidate)
77
+ if len(matches) > 1:
78
+ return None
79
+ return matches[0] if matches else None
80
+
81
+
82
+ @router.get("/{output_path:path}", operation_id="read_output_get")
83
+ @router.head("/{output_path:path}", operation_id="read_output_head")
84
+ async def read_output(
85
+ output_path: str,
86
+ _auth: TokenPayload | None = Depends(require_auth),
87
+ ) -> FileResponse:
88
+ """Serve one allowlisted artifact from the authenticated user's reach."""
89
+ try:
90
+ path = _resolve_output(_request_path_service(), output_path)
91
+ except HTTPException:
92
+ path = _resolve_partner_output(output_path)
93
+ if path is None:
94
+ raise
95
+ if path.suffix.lower() == ".html" and "video_lesson" in path.parts:
96
+ return FileResponse(path, headers={"Content-Security-Policy": _SANDBOXED_PAGE_CSP})
97
+ return FileResponse(path)
@@ -0,0 +1,154 @@
1
+ """
2
+ Personas API Router
3
+ ===================
4
+
5
+ CRUD endpoints for user-authored PERSONA.md files stored under
6
+ ``data/user/workspace/personas/<name>/PERSONA.md``.
7
+
8
+ Personas are behaviour/voice presets, not capability skills: admin-authored
9
+ personas are visible to every user as read-only deployment presets (no grant
10
+ mechanism — a persona carries no privileged workflow, only style guidance).
11
+ Users create and manage their own personas in their own workspace; a user
12
+ persona shadows an admin persona of the same name.
13
+
14
+ Mounted at ``/api/personas``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from fastapi import APIRouter, HTTPException
20
+ from pydantic import BaseModel, Field
21
+
22
+ from deeptutor.multi_user.paths import get_admin_path_service
23
+ from deeptutor.services.i18n import t
24
+ from deeptutor.services.persona import (
25
+ InvalidPersonaNameError,
26
+ PersonaExistsError,
27
+ PersonaNotFoundError,
28
+ PersonaService,
29
+ get_persona_service,
30
+ )
31
+
32
+ router = APIRouter()
33
+
34
+
35
+ class CreatePersonaRequest(BaseModel):
36
+ name: str = Field(..., min_length=1, max_length=64)
37
+ description: str = ""
38
+ content: str = ""
39
+
40
+
41
+ class UpdatePersonaRequest(BaseModel):
42
+ description: str | None = None
43
+ content: str | None = None
44
+ rename_to: str | None = None
45
+
46
+
47
+ def _admin_persona_service() -> PersonaService:
48
+ return PersonaService(root=get_admin_path_service().get_workspace_dir() / "personas")
49
+
50
+
51
+ def _persona_presets(service: PersonaService) -> PersonaService | None:
52
+ """The read-only deployment presets, unless ``service`` already holds them.
53
+
54
+ ``None`` means the caller is reading the very directory the presets live in
55
+ — the admin on their account-level workspace — where merging would only
56
+ duplicate every row. Deciding that on ``user.is_admin`` instead took "the
57
+ caller is an admin" to mean "the caller is reading the preset directory",
58
+ which stopped being true once each workspace got its own personas
59
+ directory: inside an explicit workspace an admin saw nothing but Default
60
+ while a non-admin on the same workspace saw every preset (#1534).
61
+ """
62
+ presets = _admin_persona_service()
63
+ if presets.root.resolve() == service.root.resolve():
64
+ return None
65
+ return presets
66
+
67
+
68
+ @router.get("/personas")
69
+ async def list_personas() -> dict[str, list[dict[str, object]]]:
70
+ service = get_persona_service()
71
+ own = [info.to_dict() for info in service.list_personas()]
72
+ presets = _persona_presets(service)
73
+ if presets is None:
74
+ return {"personas": own}
75
+ own_names = {item["name"] for item in own}
76
+ merged = list(own)
77
+ for preset in presets.list_personas():
78
+ if preset.name in own_names:
79
+ continue
80
+ entry = preset.to_dict()
81
+ entry.update({"source": "admin", "read_only": True})
82
+ merged.append(entry)
83
+ return {"personas": merged}
84
+
85
+
86
+ @router.get("/personas/{name}")
87
+ async def get_persona(name: str) -> dict[str, object]:
88
+ service = get_persona_service()
89
+ try:
90
+ return service.get_detail(name).to_dict()
91
+ except PersonaNotFoundError:
92
+ pass
93
+ except InvalidPersonaNameError as exc:
94
+ raise HTTPException(status_code=400, detail=str(exc))
95
+
96
+ presets = _persona_presets(service)
97
+ if presets is not None:
98
+ try:
99
+ detail = presets.get_detail(name).to_dict()
100
+ detail.update({"source": "admin", "read_only": True})
101
+ return detail
102
+ except (PersonaNotFoundError, InvalidPersonaNameError):
103
+ pass
104
+ raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
105
+
106
+
107
+ @router.post("/personas")
108
+ async def create_persona(payload: CreatePersonaRequest) -> dict[str, object]:
109
+ service = get_persona_service()
110
+ try:
111
+ info = service.create(
112
+ name=payload.name,
113
+ description=payload.description,
114
+ content=payload.content,
115
+ )
116
+ return info.to_dict()
117
+ except PersonaExistsError:
118
+ raise HTTPException(
119
+ status_code=409,
120
+ detail=t("api.persona_already_exists", name=payload.name),
121
+ )
122
+ except InvalidPersonaNameError as exc:
123
+ raise HTTPException(status_code=400, detail=str(exc))
124
+
125
+
126
+ @router.put("/personas/{name}")
127
+ async def update_persona(name: str, payload: UpdatePersonaRequest) -> dict[str, object]:
128
+ service = get_persona_service()
129
+ try:
130
+ info = service.update(
131
+ name,
132
+ description=payload.description,
133
+ content=payload.content,
134
+ rename_to=payload.rename_to,
135
+ )
136
+ return info.to_dict()
137
+ except PersonaNotFoundError:
138
+ raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
139
+ except PersonaExistsError as exc:
140
+ raise HTTPException(status_code=409, detail=str(exc))
141
+ except InvalidPersonaNameError as exc:
142
+ raise HTTPException(status_code=400, detail=str(exc))
143
+
144
+
145
+ @router.delete("/personas/{name}")
146
+ async def delete_persona(name: str) -> dict[str, str]:
147
+ service = get_persona_service()
148
+ try:
149
+ service.delete(name)
150
+ return {"status": "deleted", "name": name}
151
+ except PersonaNotFoundError:
152
+ raise HTTPException(status_code=404, detail=t("api.persona_not_found", name=name))
153
+ except InvalidPersonaNameError as exc:
154
+ raise HTTPException(status_code=400, detail=str(exc))
@@ -0,0 +1,416 @@
1
+ """Authenticated introspection and managed plugin HTTP route dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException, Request
12
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
13
+ from pydantic import BaseModel, Field
14
+
15
+ from deeptutor.api.routers.auth import AUTH_COOKIE_NAME, require_admin, require_auth
16
+ from deeptutor.plugins.catalog import load_catalog
17
+ from deeptutor.plugins.distribution import CatalogInstallManager, PluginDistributionError
18
+ from deeptutor.plugins.frontend import (
19
+ FrontendPageError,
20
+ load_frontend_page_manifest,
21
+ resolve_frontend_page_asset,
22
+ )
23
+ from deeptutor.plugins.lifecycle import PluginLifecycleError, PluginLifecycleManager
24
+ from deeptutor.plugins.manifest import PluginExtension
25
+ from deeptutor.plugins.registry import (
26
+ PluginInstallation,
27
+ PluginRecord,
28
+ PluginRegistry,
29
+ PluginStateError,
30
+ )
31
+ from deeptutor.plugins.runtime import (
32
+ PluginHttpResponse,
33
+ PluginRuntimeError,
34
+ PluginWorkerHttpRoute,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+ router = APIRouter()
39
+ _registry_factory = PluginRegistry
40
+ _MAX_JSON_BODY_BYTES = 1024 * 1024
41
+ _FRONTEND_CSP = (
42
+ "default-src 'none'; "
43
+ "script-src 'self'; "
44
+ "style-src 'self' 'unsafe-inline'; "
45
+ "img-src 'self' data: blob:; "
46
+ "font-src 'self' data:; "
47
+ "media-src 'self' data: blob:; "
48
+ "connect-src 'none'; frame-src 'none'; object-src 'none'; "
49
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'self'; "
50
+ "sandbox allow-scripts"
51
+ )
52
+
53
+
54
+ async def _require_authenticated(request: Request):
55
+ return await require_auth(
56
+ authorization=request.headers.get("Authorization"),
57
+ dt_token=request.cookies.get(AUTH_COOKIE_NAME),
58
+ request=request,
59
+ )
60
+
61
+
62
+ async def _require_admin(request: Request):
63
+ return await require_admin(
64
+ payload=await _require_authenticated(request),
65
+ )
66
+
67
+
68
+ def _managed_records(registry: PluginRegistry) -> list[PluginRecord]:
69
+ return [
70
+ record
71
+ for record in registry.list_plugins(include_catalog=False)
72
+ if record.status == "enabled"
73
+ and record.distribution == "managed"
74
+ and record.manifest is not None
75
+ and record.installation is not None
76
+ ]
77
+
78
+
79
+ def _extensions_payload(records: list[PluginRecord]) -> list[dict[str, Any]]:
80
+ result: list[dict[str, Any]] = []
81
+ for record in records:
82
+ if record.manifest is None:
83
+ continue
84
+ extensions: list[dict[str, Any]] = []
85
+ for extension in record.manifest.extensions:
86
+ payload = extension.to_dict()
87
+ if extension.type == "frontend_page" and extension.manifest:
88
+ payload["entry_url"] = f"/api/plugins/{record.id}/pages/{extension.id}/"
89
+ extensions.append(payload)
90
+ result.append(
91
+ {
92
+ "id": record.id,
93
+ "name": record.name,
94
+ "version": record.version,
95
+ "extensions": extensions,
96
+ }
97
+ )
98
+ return result
99
+
100
+
101
+ def _find_route(
102
+ *,
103
+ plugin_id: str,
104
+ plugin_path: str,
105
+ ) -> tuple[PluginRecord, PluginExtension, PluginInstallation] | None:
106
+ normalized_path = _normalize_plugin_path(plugin_path)
107
+ if normalized_path is None:
108
+ return None
109
+ for record in _managed_records(_registry_factory()):
110
+ if record.id != plugin_id or record.manifest is None or record.installation is None:
111
+ continue
112
+ for extension in record.manifest.extensions:
113
+ if extension.type == "http_route" and extension.path == normalized_path:
114
+ return record, extension, record.installation
115
+ return None
116
+
117
+
118
+ def _find_frontend_page(
119
+ *,
120
+ plugin_id: str,
121
+ extension_id: str,
122
+ ) -> tuple[PluginRecord, PluginExtension, PluginInstallation, Path] | None:
123
+ for record in _managed_records(_registry_factory()):
124
+ if record.id != plugin_id or record.manifest is None or record.installation is None:
125
+ continue
126
+ if record.installation.package_path is None:
127
+ continue
128
+ for extension in record.manifest.extensions:
129
+ if extension.type == "frontend_page" and extension.id == extension_id:
130
+ return record, extension, record.installation, record.installation.package_path
131
+ return None
132
+
133
+
134
+ def _normalize_plugin_path(value: str) -> str | None:
135
+ if not value or "\x00" in value or "%" in value or "\\" in value:
136
+ return None
137
+ return "/" + value.strip("/")
138
+
139
+
140
+ async def _authenticate_plugin_route(request: Request) -> None:
141
+ payload = await _require_authenticated(request)
142
+ matched = _find_route(
143
+ plugin_id=request.path_params["plugin_id"],
144
+ plugin_path=request.path_params["plugin_path"],
145
+ )
146
+ if matched is None:
147
+ return
148
+ request.state.plugin_route = matched
149
+ policy = matched[1].auth
150
+ if policy == "admin":
151
+ await require_admin(payload=payload)
152
+
153
+
154
+ async def _authenticate_plugin_page(request: Request) -> None:
155
+ payload = await _require_authenticated(request)
156
+ matched = _find_frontend_page(
157
+ plugin_id=request.path_params["plugin_id"],
158
+ extension_id=request.path_params["extension_id"],
159
+ )
160
+ if matched is None:
161
+ return
162
+ request.state.plugin_page = matched
163
+ policy = matched[1].auth
164
+ if policy == "admin":
165
+ await require_admin(payload=payload)
166
+
167
+
168
+ @router.get("/extensions", dependencies=[Depends(_require_authenticated)])
169
+ def list_extensions() -> dict[str, Any]:
170
+ return {"plugins": _extensions_payload(_managed_records(_registry_factory()))}
171
+
172
+
173
+ @router.get(
174
+ "/{plugin_id}/pages/{extension_id}",
175
+ dependencies=[Depends(_authenticate_plugin_page)],
176
+ )
177
+ def redirect_plugin_frontend_page(request: Request) -> RedirectResponse:
178
+ matched = getattr(request.state, "plugin_page", None)
179
+ if matched is None:
180
+ raise HTTPException(status_code=404, detail="Plugin page not found")
181
+ return RedirectResponse(url=f"{request.url.path}/", status_code=307)
182
+
183
+
184
+ @router.get(
185
+ "/{plugin_id}/pages/{extension_id}/",
186
+ dependencies=[Depends(_authenticate_plugin_page)],
187
+ )
188
+ def get_plugin_frontend_page(request: Request) -> FileResponse:
189
+ matched = getattr(request.state, "plugin_page", None)
190
+ if matched is None:
191
+ raise HTTPException(status_code=404, detail="Plugin page not found")
192
+ _, extension, _, package_path = matched
193
+ if not extension.manifest:
194
+ raise HTTPException(status_code=404, detail="Plugin page not found")
195
+ try:
196
+ page = load_frontend_page_manifest(package_path / extension.manifest)
197
+ path = resolve_frontend_page_asset(page, package_path, page.entry)
198
+ except FrontendPageError as exc:
199
+ logger.warning(
200
+ "Managed frontend page %s/%s failed validation",
201
+ matched[0].id,
202
+ extension.id,
203
+ exc_info=exc,
204
+ )
205
+ raise HTTPException(status_code=404, detail="Plugin page not found") from exc
206
+ return _frontend_file_response(path)
207
+
208
+
209
+ @router.get(
210
+ "/{plugin_id}/pages/{extension_id}/assets/{asset_path:path}",
211
+ dependencies=[Depends(_authenticate_plugin_page)],
212
+ )
213
+ def get_plugin_frontend_asset(request: Request) -> FileResponse:
214
+ matched = getattr(request.state, "plugin_page", None)
215
+ if matched is None:
216
+ raise HTTPException(status_code=404, detail="Plugin page not found")
217
+ _, extension, _, package_path = matched
218
+ if not extension.manifest:
219
+ raise HTTPException(status_code=404, detail="Plugin page not found")
220
+ requested = request.path_params["asset_path"]
221
+ try:
222
+ page = load_frontend_page_manifest(package_path / extension.manifest)
223
+ path = resolve_frontend_page_asset(page, package_path, requested)
224
+ except FrontendPageError as exc:
225
+ logger.warning(
226
+ "Managed frontend asset %s/%s failed validation",
227
+ matched[0].id,
228
+ extension.id,
229
+ exc_info=exc,
230
+ )
231
+ raise HTTPException(status_code=404, detail="Plugin page not found") from exc
232
+ return _frontend_file_response(path, asset=True)
233
+
234
+
235
+ async def dispatch_plugin_route(request: Request) -> Response:
236
+ matched = getattr(request.state, "plugin_route", None)
237
+ if matched is None:
238
+ raise HTTPException(status_code=404, detail="Plugin route not found")
239
+ record, extension, installation = matched
240
+ if request.method not in extension.methods:
241
+ raise HTTPException(
242
+ status_code=405,
243
+ detail="Method not allowed",
244
+ headers={"Allow": ", ".join(extension.methods)},
245
+ )
246
+
247
+ chunks = []
248
+ size = 0
249
+ async for chunk in request.stream():
250
+ size += len(chunk)
251
+ if size > _MAX_JSON_BODY_BYTES:
252
+ raise HTTPException(status_code=413, detail="Plugin request body is too large")
253
+ chunks.append(chunk)
254
+ raw_body = b"".join(chunks)
255
+ body = None
256
+ if raw_body:
257
+ try:
258
+ body = json.loads(raw_body)
259
+ except ValueError as exc:
260
+ raise HTTPException(status_code=400, detail="Request body must be JSON") from exc
261
+
262
+ query: dict[str, tuple[str, ...]] = {
263
+ name: tuple(request.query_params.getlist(name)) for name in request.query_params.keys()
264
+ }
265
+ worker = PluginWorkerHttpRoute(
266
+ manifest=record.manifest,
267
+ installation=installation,
268
+ extension_id=extension.id,
269
+ registry=_registry_factory(),
270
+ )
271
+ try:
272
+ result = await asyncio.to_thread(
273
+ worker.handle,
274
+ method=request.method,
275
+ path=extension.path,
276
+ query=query,
277
+ body=body,
278
+ )
279
+ except PluginRuntimeError as exc:
280
+ logger.warning(
281
+ "Managed HTTP route %s/%s failed its worker contract",
282
+ record.id,
283
+ extension.id,
284
+ exc_info=exc,
285
+ )
286
+ raise HTTPException(status_code=500, detail="Plugin route failed") from exc
287
+ return _http_response(result)
288
+
289
+
290
+ class ApprovalRequest(BaseModel):
291
+ snapshot_digest: str = Field(pattern="^[0-9a-f]{64}$")
292
+
293
+
294
+ class InstallRequest(BaseModel):
295
+ version: str = Field(default="latest", max_length=64)
296
+ allow_deprecated: bool = False
297
+
298
+
299
+ @router.get("/catalog", dependencies=[Depends(_require_authenticated)])
300
+ def plugin_catalog():
301
+ return {"entries": [entry.to_dict() for entry in load_catalog()]}
302
+
303
+
304
+ @router.get("/state", dependencies=[Depends(_require_authenticated)])
305
+ def plugin_state():
306
+ return {"plugins": [record.to_dict() for record in _registry_factory().list_plugins()]}
307
+
308
+
309
+ @router.get("/{plugin_id}/info", dependencies=[Depends(_require_authenticated)])
310
+ def plugin_info(plugin_id: str):
311
+ record = _registry_factory().get_plugin(plugin_id)
312
+ if record is None:
313
+ raise HTTPException(404, "Plugin not found")
314
+ return record.to_dict()
315
+
316
+
317
+ @router.post("/{plugin_id}/approve", dependencies=[Depends(_require_admin)])
318
+ def approve_plugin(plugin_id: str, payload: ApprovalRequest):
319
+ try:
320
+ return (
321
+ _registry_factory()
322
+ .approve(plugin_id, expected_digest=payload.snapshot_digest)
323
+ .to_dict()
324
+ )
325
+ except PluginStateError as exc:
326
+ raise HTTPException(409, str(exc)) from exc
327
+
328
+
329
+ @router.post("/{plugin_id}/enable", dependencies=[Depends(_require_admin)])
330
+ def enable_plugin(plugin_id: str):
331
+ try:
332
+ return _registry_factory().set_enabled(plugin_id, True).to_dict()
333
+ except PluginStateError as exc:
334
+ raise HTTPException(409, str(exc)) from exc
335
+
336
+
337
+ @router.post("/{plugin_id}/disable", dependencies=[Depends(_require_admin)])
338
+ def disable_plugin(plugin_id: str):
339
+ try:
340
+ return _registry_factory().set_enabled(plugin_id, False).to_dict()
341
+ except PluginStateError as exc:
342
+ raise HTTPException(409, str(exc)) from exc
343
+
344
+
345
+ @router.post("/{plugin_id}/install", dependencies=[Depends(_require_admin)])
346
+ def install_plugin(plugin_id: str, payload: InstallRequest):
347
+ try:
348
+ result = CatalogInstallManager(
349
+ lifecycle_factory=lambda: PluginLifecycleManager(registry=_registry_factory())
350
+ ).install(plugin_id, version=payload.version, allow_deprecated=payload.allow_deprecated)
351
+ return {
352
+ "id": result.lifecycle.plugin_id,
353
+ "version": result.lifecycle.version,
354
+ "action": result.lifecycle.action,
355
+ "approval_required": True,
356
+ }
357
+ except (PluginStateError, PluginLifecycleError, PluginDistributionError) as exc:
358
+ raise HTTPException(409, str(exc)) from exc
359
+
360
+
361
+ @router.post("/{plugin_id}/rollback", dependencies=[Depends(_require_admin)])
362
+ def rollback_plugin(plugin_id: str):
363
+ try:
364
+ result = PluginLifecycleManager(registry=_registry_factory()).rollback(plugin_id)
365
+ return {"id": result.plugin_id, "version": result.version, "approval_required": True}
366
+ except (PluginStateError, PluginLifecycleError) as exc:
367
+ raise HTTPException(409, str(exc)) from exc
368
+
369
+
370
+ @router.delete("/{plugin_id}/installation", dependencies=[Depends(_require_admin)])
371
+ def uninstall_plugin(plugin_id: str):
372
+ try:
373
+ result = PluginLifecycleManager(registry=_registry_factory()).uninstall(plugin_id)
374
+ return {"id": result.plugin_id, "action": result.action}
375
+ except (PluginStateError, PluginLifecycleError) as exc:
376
+ raise HTTPException(409, str(exc)) from exc
377
+
378
+
379
+ for _method in ("GET", "POST", "PUT", "PATCH", "DELETE"):
380
+ router.add_api_route(
381
+ "/{plugin_id}/{plugin_path:path}",
382
+ dispatch_plugin_route,
383
+ methods=[_method],
384
+ operation_id=f"dispatch_plugin_route_{_method.lower()}",
385
+ dependencies=[Depends(_authenticate_plugin_route)],
386
+ )
387
+
388
+
389
+ def _http_response(result: PluginHttpResponse) -> Response:
390
+ if result.status == 204:
391
+ return Response(status_code=204, headers=dict(result.headers or {}))
392
+ return JSONResponse(
393
+ status_code=result.status,
394
+ content=result.body,
395
+ headers=dict(result.headers or {}),
396
+ )
397
+
398
+
399
+ def _frontend_file_response(path: Path, *, asset: bool = False) -> FileResponse:
400
+ media_type = "text/html; charset=utf-8" if path.suffix.lower() == ".html" else None
401
+ return FileResponse(
402
+ path,
403
+ media_type=media_type,
404
+ headers={
405
+ "Cache-Control": "no-store",
406
+ "Vary": "Authorization, Cookie",
407
+ "Content-Security-Policy": _FRONTEND_CSP,
408
+ "Cross-Origin-Resource-Policy": "cross-origin" if asset else "same-origin",
409
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
410
+ "Referrer-Policy": "no-referrer",
411
+ "X-Content-Type-Options": "nosniff",
412
+ },
413
+ )
414
+
415
+
416
+ __all__ = ["router"]