create-workframe 0.1.12 → 0.1.13

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 (188) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +2 -2
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +1 -0
  8. package/scripts/verify-public-deploy.sh +65 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/package.json +2 -2
  51. package/workframe-api/profile_api_lifecycle.py +66 -0
  52. package/workframe-api/profile_gateway.py +436 -0
  53. package/workframe-api/provider_bindings.py +673 -0
  54. package/workframe-api/provider_bootstrap.py +347 -0
  55. package/workframe-api/provider_catalog.py +180 -0
  56. package/workframe-api/rooms.py +1613 -0
  57. package/workframe-api/route_registry.py +331 -191
  58. package/workframe-api/run-typecheck.mjs +2 -1
  59. package/workframe-api/run_authority.py +287 -0
  60. package/workframe-api/run_ledger.py +470 -0
  61. package/workframe-api/run_surface_wiring.py +344 -0
  62. package/workframe-api/runtime_cohort.py +752 -0
  63. package/workframe-api/runtime_tokens.py +127 -0
  64. package/workframe-api/server.py +1453 -19513
  65. package/workframe-api/snapshot_feed.py +49 -0
  66. package/workframe-api/stack_config.py +33 -1
  67. package/workframe-api/supervisor_client.py +154 -0
  68. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  69. package/workframe-api/test_cell_authority.py +79 -0
  70. package/workframe-api/test_chat_bind.py +48 -0
  71. package/workframe-api/test_credential_lease_matrix.py +171 -0
  72. package/workframe-api/test_credential_resolve.py +51 -0
  73. package/workframe-api/test_credential_store.py +27 -0
  74. package/workframe-api/test_dogfood_flows.py +346 -0
  75. package/workframe-api/test_domain_entities.py +152 -0
  76. package/workframe-api/test_exception_hygiene.py +12 -0
  77. package/workframe-api/test_hermes_admin.py +72 -0
  78. package/workframe-api/test_install_wizard_resume.py +78 -0
  79. package/workframe-api/test_local_bootstrap.py +67 -0
  80. package/workframe-api/test_mention_helpers.py +35 -0
  81. package/workframe-api/test_mention_invoke.py +26 -0
  82. package/workframe-api/test_model_surface_consistency.py +1 -0
  83. package/workframe-api/test_oauth_pending.py +41 -0
  84. package/workframe-api/test_provider_bindings.py +52 -0
  85. package/workframe-api/test_provider_catalog.py +28 -0
  86. package/workframe-api/test_route_registry.py +171 -35
  87. package/workframe-api/test_run_authority.py +112 -0
  88. package/workframe-api/test_run_ledger.py +157 -0
  89. package/workframe-api/test_run_surface_wiring.py +130 -0
  90. package/workframe-api/test_runtime_cohort.py +85 -0
  91. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  92. package/workframe-api/test_server_reexports.py +99 -0
  93. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  94. package/workframe-api/turn_credentials.py +28 -13
  95. package/workframe-api/turn_overlay.py +715 -0
  96. package/workframe-api/updates.py +16 -1
  97. package/workframe-api/user_prefs.py +387 -0
  98. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  99. package/workframe-api/workspace_bootstrap.py +536 -0
  100. package/workframe-api/workspace_files.py +344 -0
  101. package/workframe-api/workspace_messaging.py +206 -0
  102. package/workframe-api/zk_auth.py +3 -2
  103. package/workframe-supervisor/server.py +10 -1
  104. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  105. package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-aUUffclm.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  110. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-mQWmB3z6.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  146. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  147. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-BTCEHb7l.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-CkbydpnK.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-yj_1x_qj.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-D6mvlP96.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  176. package/workframe-ui/public/index.html +7 -5
  177. package/workframe-ui/public/workframe-build.json +5 -0
  178. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
  179. package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
  180. package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
  181. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
  182. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
  183. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
  184. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
  185. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
  186. package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
  187. package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
  188. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
@@ -0,0 +1,1420 @@
1
+ """Workspace / room / board / content route handlers (WF-032 slice)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import queue
8
+ import re
9
+ import secrets
10
+ import sqlite3
11
+ import time
12
+ import urllib.parse
13
+ import uuid
14
+
15
+ import activity_feed
16
+ import chat_bind
17
+ from email_sender import APP_BASE_URL, send_branded_invite_email
18
+
19
+ list_room_sessions = chat_bind.list_room_sessions
20
+ room_activity_data = activity_feed.room_activity_data
21
+ room_chat_bind = chat_bind.room_chat_bind
22
+ profile_chat_activate_room_session = chat_bind.profile_chat_activate_room_session
23
+
24
+
25
+ def _srv():
26
+ import server
27
+
28
+ return server
29
+
30
+
31
+ class WorkspaceRoutesMixin:
32
+
33
+ def _route_get_board(self, qs: dict[str, list[str]]) -> None:
34
+ srv = _srv()
35
+ self._json(200, srv.board_list())
36
+
37
+ def _route_get_content(self, qs: dict[str, list[str]]) -> None:
38
+ srv = _srv()
39
+ self._json(200, srv.content_list())
40
+
41
+ def _route_get_content_get(self, qs: dict[str, list[str]]) -> None:
42
+ srv = _srv()
43
+ rel = qs.get("path", [""])[0]
44
+ reason = srv._workspace_protected_reason(rel)
45
+ if reason:
46
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
47
+ return
48
+ fp = srv._safe_content_path(rel)
49
+ if not fp or not fp.is_file():
50
+ self._json(404, {"error": "not found"})
51
+ return
52
+ text = fp.read_text(encoding="utf-8", errors="replace")
53
+ self._json(200, {"path": rel, "content": text, "text": text})
54
+
55
+ def _route_pattern_get_files_workspace(self, path: str, qs: dict[str, list[str]]) -> None:
56
+ srv = _srv()
57
+ prefix = "/api/files/workspace/"
58
+ if not path.startswith(prefix):
59
+ self._json(404, {"error": "not found"})
60
+ return
61
+ rel = urllib.parse.unquote(path[len(prefix) :].lstrip("/"))
62
+ reason = srv._workspace_protected_reason(rel)
63
+ if reason:
64
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
65
+ return
66
+ try:
67
+ data, mime = srv.file_raw(rel)
68
+ except ValueError:
69
+ self._json(404, {"error": "not found"})
70
+ return
71
+ self._send(200, data, mime)
72
+
73
+ def _route_post_board(self, body: dict) -> None:
74
+ srv = _srv()
75
+ if not srv._check_auth(self):
76
+ self._json(401, {"error": "unauthorized"})
77
+ return
78
+ self._json(201, srv.board_create(body))
79
+
80
+ def _route_post_board_update(self, body: dict) -> None:
81
+ srv = _srv()
82
+ if not srv._check_auth(self):
83
+ self._json(401, {"error": "unauthorized"})
84
+ return
85
+ qs = getattr(self, "_post_qs", {})
86
+ task_id = qs.get("id", [""])[0]
87
+ if not task_id:
88
+ self._json(400, {"error": "id required"})
89
+ return
90
+ self._json(200, srv.board_update(task_id, body))
91
+
92
+ def _route_post_board_delete(self, body: dict) -> None:
93
+ srv = _srv()
94
+ if not srv._check_auth(self):
95
+ self._json(401, {"error": "unauthorized"})
96
+ return
97
+ qs = getattr(self, "_post_qs", {})
98
+ task_id = qs.get("id", [""])[0]
99
+ if not task_id:
100
+ self._json(400, {"error": "id required"})
101
+ return
102
+ srv.board_delete(task_id)
103
+ self._json(200, {"ok": True})
104
+
105
+ def _route_post_content_save(self, body: dict) -> None:
106
+ srv = _srv()
107
+ if not srv._check_auth(self):
108
+ self._json(401, {"error": "unauthorized"})
109
+ return
110
+ rel = str(body.get("path") or "").strip()
111
+ reason = srv._workspace_protected_reason(rel)
112
+ if reason:
113
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
114
+ return
115
+ content = str(body.get("content") or "")
116
+ fp = srv._safe_content_path(rel)
117
+ if not fp:
118
+ self._json(400, {"error": "invalid path"})
119
+ return
120
+ fp.parent.mkdir(parents=True, exist_ok=True)
121
+ fp.write_text(content, encoding="utf-8")
122
+ srv._bump_workspace_state(fp)
123
+ self._json(200, {"ok": True, "path": rel.replace("\\", "/")})
124
+
125
+ def _route_post_content_delete(self, body: dict) -> None:
126
+ srv = _srv()
127
+ if not srv._check_auth(self):
128
+ self._json(401, {"error": "unauthorized"})
129
+ return
130
+ qs = getattr(self, "_post_qs", {})
131
+ rel = qs.get("path", [""])[0] or str(body.get("path") or "")
132
+ reason = srv._workspace_protected_reason(rel)
133
+ if reason:
134
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
135
+ return
136
+ fp = srv._safe_content_path(rel)
137
+ if not fp or not fp.is_file():
138
+ self._json(404, {"error": "not found"})
139
+ return
140
+ fp.unlink()
141
+ srv._bump_workspace_state(fp.parent)
142
+ self._json(200, {"ok": True})
143
+
144
+ def _route_pattern_get_workspace_rooms(self, path: str, qs: dict[str, list[str]]) -> None:
145
+ srv = _srv()
146
+ parts = path.strip("/").split("/")
147
+ if len(parts) != 4:
148
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
149
+ return
150
+ ws_id = srv._resolve_wid(parts[2])
151
+ if not ws_id:
152
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
153
+ return
154
+ include_members = qs.get("include_members", [""])[0].lower() in {"1", "true", "yes"}
155
+ self._json(*srv._list_rooms(ws_id, include_members=include_members))
156
+
157
+ def _route_pattern_get_room(self, path: str, qs: dict[str, list[str]]) -> None:
158
+ srv = _srv()
159
+ rid = path.strip("/").split("/")[-1]
160
+ self._json(*srv._get_room(rid))
161
+
162
+ def _route_pattern_get_room_members(self, path: str, qs: dict[str, list[str]]) -> None:
163
+ srv = _srv()
164
+ rid = path.strip("/").split("/")[-2]
165
+ viewer_id = str(getattr(self, "auth_user", "") or "")
166
+ try:
167
+ conn = srv._workframe_db()
168
+ conn.row_factory = sqlite3.Row
169
+ if not srv._user_can_access_room(conn, rid, viewer_id):
170
+ conn.close()
171
+ self._json(403, {"ok": False, "error": "forbidden"})
172
+ return
173
+ members = conn.execute(
174
+ "SELECT * FROM room_memberships WHERE room_id = ? AND deleted_at IS NULL",
175
+ (rid,),
176
+ ).fetchall()
177
+ payload = srv._enrich_room_members(conn, [dict(m) for m in members])
178
+ conn.close()
179
+ except Exception:
180
+ payload = []
181
+ self._json(200, {"ok": True, "room_id": rid, "members": payload})
182
+
183
+ def _route_pattern_get_room_live(self, path: str, qs: dict[str, list[str]]) -> None:
184
+ srv = _srv()
185
+ rid = path.strip("/").split("/")[-2]
186
+ user_id = str(getattr(self, "auth_user", "") or "")
187
+ try:
188
+ conn = srv._workframe_db()
189
+ if not srv._user_can_access_room(conn, rid, user_id):
190
+ conn.close()
191
+ self._json(403, {"ok": False, "error": "forbidden"})
192
+ return
193
+ conn.close()
194
+ except Exception:
195
+ self._json(500, {"ok": False, "error": "db_error"})
196
+ return
197
+ self.send_response(200)
198
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
199
+ self.send_header("Cache-Control", "no-cache")
200
+ self.send_header("Connection", "keep-alive")
201
+ cors_origin = srv._cors_origin_for(self.headers)
202
+ if cors_origin:
203
+ self.send_header("Access-Control-Allow-Origin", cors_origin)
204
+ self.send_header("Vary", "Origin")
205
+ self.end_headers()
206
+ sub = srv._room_live_subscribe(rid)
207
+ try:
208
+ for turn in srv._room_live_active_for_room(rid):
209
+ payload = json.dumps(turn)
210
+ self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
211
+ self.wfile.flush()
212
+ while True:
213
+ try:
214
+ line = sub.get(timeout=15)
215
+ self.wfile.write(f"data: {line}\n\n".encode("utf-8"))
216
+ self.wfile.flush()
217
+ except queue.Empty:
218
+ heartbeat = json.dumps(
219
+ {"type": "heartbeat", "room_id": rid, "ts": int(time.time())}
220
+ )
221
+ self.wfile.write(f"data: {heartbeat}\n\n".encode("utf-8"))
222
+ self.wfile.flush()
223
+ except (BrokenPipeError, ConnectionResetError, OSError):
224
+ pass
225
+ finally:
226
+ srv._room_live_unsubscribe(rid, sub)
227
+
228
+ def _route_pattern_get_room_activity(self, path: str, qs: dict[str, list[str]]) -> None:
229
+ srv = _srv()
230
+ rid = path.strip("/").split("/")[-2]
231
+ user_id = str(getattr(self, "auth_user", "") or "")
232
+ if not user_id:
233
+ self._json(401, {"ok": False, "error": "no_session"})
234
+ return
235
+ try:
236
+ activity = srv.room_activity_data(rid, user_id)
237
+ except ValueError as exc:
238
+ self._json(400, {"ok": False, "error": str(exc)})
239
+ return
240
+ self._json(200, {"ok": True, "room_id": rid, "activity": activity})
241
+
242
+ def _route_pattern_get_room_sessions(self, path: str, qs: dict[str, list[str]]) -> None:
243
+ srv = _srv()
244
+ rid = path.strip("/").split("/")[-2]
245
+ user_id = str(getattr(self, "auth_user", "") or "")
246
+ if not user_id:
247
+ self._json(401, {"ok": False, "error": "no_session"})
248
+ return
249
+ try:
250
+ payload = srv.list_room_sessions(rid, user_id)
251
+ except ValueError as exc:
252
+ err = str(exc)
253
+ if "not_found" in err:
254
+ self._json(404, {"ok": False, "error": err})
255
+ return
256
+ if "denied" in err or "forbidden" in err:
257
+ self._json(403, {"ok": False, "error": err})
258
+ return
259
+ self._json(400, {"ok": False, "error": err})
260
+ return
261
+ self._json(200, payload)
262
+
263
+ def _route_pattern_get_room_messages(self, path: str, qs: dict[str, list[str]]) -> None:
264
+ srv = _srv()
265
+ slug_or_id = path.strip("/").split("/")[-2]
266
+ user_id = str(getattr(self, "auth_user", "") or "")
267
+ if not user_id:
268
+ self._json(401, {"ok": False, "error": "no_session"})
269
+ return
270
+ limit = int(qs.get("limit", ["50"])[0])
271
+ offset = int(qs.get("offset", ["0"])[0])
272
+ try:
273
+ conn = srv._workframe_db()
274
+ room = conn.execute(
275
+ "SELECT id FROM rooms WHERE (id = ? OR slug = ?) AND deleted_at IS NULL",
276
+ (slug_or_id, slug_or_id),
277
+ ).fetchone()
278
+ if not room:
279
+ conn.close()
280
+ self._json(404, {"error": "room_not_found"})
281
+ return
282
+ rid = room["id"]
283
+ if not srv._user_can_access_room(conn, rid, user_id):
284
+ conn.close()
285
+ self._json(403, {"ok": False, "error": "forbidden"})
286
+ return
287
+ messages = conn.execute(
288
+ """
289
+ SELECT * FROM (
290
+ SELECT * FROM messages
291
+ WHERE room_id = ? AND deleted_at IS NULL
292
+ ORDER BY created_at DESC
293
+ LIMIT ? OFFSET ?
294
+ ) AS recent
295
+ ORDER BY created_at ASC
296
+ """,
297
+ (rid, limit, offset),
298
+ ).fetchall()
299
+ total = conn.execute(
300
+ "SELECT COUNT(*) FROM messages WHERE room_id = ? AND deleted_at IS NULL",
301
+ (rid,),
302
+ ).fetchone()[0]
303
+ payload = srv._enrich_room_messages(conn, messages)
304
+ conn.close()
305
+ except Exception:
306
+ messages = []
307
+ total = 0
308
+ payload = []
309
+ self._json(200, {"ok": True, "messages": payload, "total": total, "limit": limit, "offset": offset})
310
+
311
+ def _route_pattern_post_workspace_rooms(self, path: str, body: dict) -> None:
312
+ srv = _srv()
313
+ parts = path.strip("/").split("/")
314
+ if len(parts) != 4:
315
+ self._json(404, {"error": "not_found"})
316
+ return
317
+ ws_id = srv._resolve_wid(parts[2])
318
+ if not ws_id:
319
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
320
+ return
321
+ created_by = str(getattr(self, "auth_user", "") or "")
322
+ if not srv._handler_is_active_workspace_member(self, ws_id):
323
+ self._json(403, {"ok": False, "error": "forbidden", "required_role": "workspace_member"})
324
+ return
325
+ status, payload = srv._create_room(ws_id, body, created_by)
326
+ if status == 201:
327
+ self._log_audit("room_created", "room", payload["room"]["id"], f"name={payload['room']['name']}")
328
+ self._json(status, payload)
329
+
330
+ def _route_pattern_post_workspace_rooms_create(self, path: str, body: dict) -> None:
331
+ srv = _srv()
332
+ parts = path.strip("/").split("/")
333
+ if len(parts) < 4:
334
+ self._json(404, {"error": "not_found"})
335
+ return
336
+ ws_id = srv._resolve_wid(parts[2])
337
+ if not ws_id:
338
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
339
+ return
340
+ name = str(body.get("name", "")).strip()
341
+ slug = str(body.get("slug", "") or name.lower().replace(" ", "-")).strip()
342
+ room_type = str(body.get("room_type", "channel")).strip()
343
+ topic = str(body.get("topic", "")).strip()
344
+ agent_id = str(body.get("agent_profile_id", "")).strip()
345
+ if not name:
346
+ self._json(400, {"error": "name required"})
347
+ return
348
+ rid = str(uuid.uuid4())
349
+ now_ts = str(int(time.time()))
350
+ try:
351
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
352
+ conn.execute(
353
+ "INSERT INTO rooms (id, workspace_id, agent_profile_id, name, slug, topic, room_type, status, created_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
354
+ (rid, ws_id, agent_id or None, name, slug, topic, room_type, "active", getattr(self, "auth_user", ""), now_ts, now_ts),
355
+ )
356
+ conn.commit()
357
+ conn.close()
358
+ except sqlite3.Error as exc:
359
+ self._json(500, {"error": f"db_error: {exc}"})
360
+ return
361
+ self._log_audit("room_created", "room", rid, f"name={name}")
362
+ self._json(200, {"ok": True, "room_id": rid})
363
+
364
+ def _route_pattern_post_room_members(self, path: str, body: dict) -> None:
365
+ srv = _srv()
366
+ rid = path.strip("/").split("/")[-2]
367
+ actor_id = str(getattr(self, "auth_user", "") or "")
368
+ if not actor_id:
369
+ self._json(401, {"ok": False, "error": "no_session"})
370
+ return
371
+ user_id_to_add = str(body.get("user_id", "")).strip()
372
+ role = str(body.get("role", "member")).strip()
373
+ if not user_id_to_add:
374
+ self._json(400, {"error": "user_id required"})
375
+ return
376
+ mid = str(uuid.uuid4())
377
+ now_ts = str(int(time.time()))
378
+ try:
379
+ conn = srv._workframe_db()
380
+ if not srv._user_can_manage_room_members(conn, rid, actor_id):
381
+ conn.close()
382
+ self._json(403, {"ok": False, "error": "forbidden"})
383
+ return
384
+ conn.execute(
385
+ "INSERT INTO room_memberships (id, room_id, user_id, role, status, joined_at, updated_at) VALUES (?,?,?,?,?,?,?)",
386
+ (mid, rid, user_id_to_add, role, "active", now_ts, now_ts),
387
+ )
388
+ conn.commit()
389
+ conn.close()
390
+ except sqlite3.Error as exc:
391
+ self._json(500, {"error": f"db_error: {exc}"})
392
+ return
393
+ self._log_audit("room_member_added", "room_membership", mid, f"room={rid} user={user_id_to_add}")
394
+ self._json(200, {"ok": True, "membership_id": mid})
395
+
396
+ def _route_pattern_post_room_bind(self, path: str, body: dict) -> None:
397
+ srv = _srv()
398
+ rid = path.strip("/").split("/")[-2]
399
+ user_id = str(getattr(self, "auth_user", "") or "")
400
+ if not user_id:
401
+ self._json(401, {"ok": False, "error": "no_session"})
402
+ return
403
+ try:
404
+ self._json(200, srv.room_chat_bind(rid, body, user_id))
405
+ except ValueError as exc:
406
+ err = str(exc)
407
+ if "not_found" in err:
408
+ self._json(404, {"ok": False, "error": err})
409
+ elif "denied" in err or "forbidden" in err:
410
+ self._json(403, {"ok": False, "error": err})
411
+ else:
412
+ self._json(400, {"ok": False, "error": err})
413
+
414
+ def _route_pattern_post_room_sessions_activate(self, path: str, body: dict) -> None:
415
+ srv = _srv()
416
+ rid = path.strip("/").split("/")[-3]
417
+ user_id = str(getattr(self, "auth_user", "") or "")
418
+ if not user_id:
419
+ self._json(401, {"ok": False, "error": "no_session"})
420
+ return
421
+ session_id = str(body.get("session_id", "")).strip()
422
+ template_prof = str(body.get("profile", "")).strip()
423
+ source_id = str(body.get("source_id") or "ui").strip() or "ui"
424
+ client_id = str(body.get("client_id") or "default").strip() or "default"
425
+ binding_version = srv._binding_version(body.get("binding_version"))
426
+ if not session_id:
427
+ self._json(400, {"error": "session_id required"})
428
+ return
429
+ try:
430
+ payload = srv.profile_chat_activate_room_session(
431
+ rid,
432
+ session_id,
433
+ user_id,
434
+ template_prof,
435
+ source_id=source_id,
436
+ client_id=client_id,
437
+ binding_version=binding_version,
438
+ )
439
+ except ValueError as exc:
440
+ err = str(exc)
441
+ if "not_found" in err:
442
+ self._json(404, {"ok": False, "error": err})
443
+ elif "denied" in err:
444
+ self._json(403, {"ok": False, "error": err})
445
+ else:
446
+ self._json(400, {"ok": False, "error": err})
447
+ return
448
+ self._json(200, payload)
449
+
450
+ def _route_pattern_post_room_messages_send(self, path: str, body: dict) -> None:
451
+ srv = _srv()
452
+ slug_or_id = path.strip("/").split("/")[-3]
453
+ user_id = str(getattr(self, "auth_user", "") or "")
454
+ if not user_id:
455
+ self._json(401, {"ok": False, "error": "no_session"})
456
+ return
457
+ sender_agent = str(body.get("sender_agent_id", "")).strip()
458
+ content = str(body.get("content", "")).strip()
459
+ content_type = str(body.get("content_type", "text")).strip()
460
+ parent_id = str(body.get("parent_message_id", "") or None)
461
+ if not content:
462
+ self._json(400, {"error": "content required"})
463
+ return
464
+ try:
465
+ conn = srv._workframe_db()
466
+ room = conn.execute(
467
+ """
468
+ SELECT id, workspace_id, room_type, agent_profile_id
469
+ FROM rooms WHERE (id = ? OR slug = ?) AND deleted_at IS NULL
470
+ """,
471
+ (slug_or_id, slug_or_id),
472
+ ).fetchone()
473
+ if not room:
474
+ conn.close()
475
+ self._json(404, {"error": "room_not_found"})
476
+ return
477
+ rid = str(room["id"])
478
+ workspace_id = str(room["workspace_id"])
479
+ if not srv._user_can_access_room(conn, rid, user_id):
480
+ conn.close()
481
+ self._json(403, {"ok": False, "error": "forbidden"})
482
+ return
483
+ if sender_agent:
484
+ sender_user = ""
485
+ agent_member = conn.execute(
486
+ """
487
+ SELECT 1 FROM room_memberships
488
+ WHERE room_id = ? AND agent_profile_id = ? AND deleted_at IS NULL
489
+ """,
490
+ (rid, sender_agent),
491
+ ).fetchone()
492
+ if not agent_member:
493
+ conn.close()
494
+ self._json(403, {"ok": False, "error": "agent_not_in_room"})
495
+ return
496
+ else:
497
+ sender_user = str(body.get("sender_user_id", "") or user_id).strip()
498
+ if sender_user != user_id:
499
+ conn.close()
500
+ self._json(403, {"ok": False, "error": "forbidden"})
501
+ return
502
+ mid = str(uuid.uuid4())
503
+ now_ts = str(int(time.time()))
504
+ conn.execute(
505
+ "INSERT INTO messages (id, room_id, sender_user_id, sender_agent_id, parent_message_id, content, content_type, is_edited, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
506
+ (mid, rid, sender_user or None, sender_agent or None, parent_id, content, content_type, 0, now_ts, now_ts),
507
+ )
508
+ conn.execute("UPDATE rooms SET updated_at = ? WHERE id = ?", (now_ts, rid))
509
+ mention_meta: dict[str, Any] = {}
510
+ if srv._is_space_room(str(room["room_type"]), room["agent_profile_id"]) and not sender_agent:
511
+ invoke_agents = body.get("invoke_agents", True)
512
+ if isinstance(invoke_agents, str):
513
+ invoke_agents = invoke_agents.lower() not in ("0", "false", "no")
514
+ mention_meta = srv._process_space_message_mentions(
515
+ rid,
516
+ workspace_id,
517
+ user_id,
518
+ content,
519
+ mid,
520
+ invoke_agents=bool(invoke_agents),
521
+ )
522
+ conn.commit()
523
+ conn.close()
524
+ except sqlite3.Error as exc:
525
+ self._json(500, {"error": f"db_error: {exc}"})
526
+ return
527
+ self._log_audit("message_sent", "message", mid, f"room={rid}")
528
+ self._json(200, {"ok": True, "message_id": mid, **mention_meta})
529
+
530
+ def _route_pattern_patch_room(self, path: str, body: dict) -> None:
531
+ srv = _srv()
532
+ room_patch_match = re.fullmatch(r"/api/rooms/([^/]+)", path)
533
+ if room_patch_match:
534
+ user_id = str(getattr(self, "auth_user", "") or "")
535
+ status, payload = srv._patch_room(room_patch_match.group(1), body, user_id)
536
+ if status == 200:
537
+ self._log_audit(
538
+ "room_updated",
539
+ "room",
540
+ room_patch_match.group(1),
541
+ f"room={room_patch_match.group(1)}",
542
+ {"changes": body},
543
+ )
544
+ self._json(status, payload)
545
+
546
+ def _route_pattern_delete_room(self, path: str, body: dict) -> None:
547
+ srv = _srv()
548
+ rid = path.strip("/").split("/")[-1]
549
+ try:
550
+ db = srv._workframe_db()
551
+ cur = db.execute(
552
+ "UPDATE rooms SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
553
+ (str(int(time.time())), rid),
554
+ )
555
+ db.commit()
556
+ affected = cur.rowcount
557
+ db.close()
558
+ except Exception:
559
+ self._json(500, {"ok": False, "error": "delete_failed"})
560
+ return
561
+ if not affected:
562
+ self._json(404, {"ok": False, "error": "room_not_found"})
563
+ return
564
+ self._log_audit("room_deleted", "room", rid, f"room={rid}")
565
+ self._json(200, {"ok": True, "room_id": rid, "status": "deleted"})
566
+
567
+ def _route_pattern_get_workspace(self, path: str, qs: dict[str, list[str]]) -> None:
568
+ srv = _srv()
569
+ match = re.fullmatch(r"/api/workspace/([^/]+)", path)
570
+ if not match:
571
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
572
+ return
573
+ user_id = str(getattr(self, "auth_user", "") or "")
574
+ ws_id = srv._resolve_wid(match.group(1))
575
+ if not ws_id:
576
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
577
+ return
578
+ self._json(*srv._get_workspace(ws_id, user_id))
579
+
580
+ def _route_pattern_get_workspace_members(self, path: str, qs: dict[str, list[str]]) -> None:
581
+ srv = _srv()
582
+ parts = path.strip("/").split("/")
583
+ if len(parts) != 4:
584
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
585
+ return
586
+ ws_id = srv._resolve_wid(parts[2])
587
+ if not ws_id:
588
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
589
+ return
590
+ self._json(*srv._list_workspace_members(ws_id, self))
591
+
592
+ def _route_pattern_get_workspace_activity(self, path: str, qs: dict[str, list[str]]) -> None:
593
+ srv = _srv()
594
+ parts = path.strip("/").split("/")
595
+ if len(parts) != 4:
596
+ self._json(404, {"error": "not_found"})
597
+ return
598
+ wid = srv._resolve_wid(parts[2])
599
+ user_id = str(getattr(self, "auth_user", "") or "")
600
+ if not user_id:
601
+ self._json(401, {"ok": False, "error": "no_session"})
602
+ return
603
+ if not wid:
604
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
605
+ return
606
+ try:
607
+ activity = srv.workspace_activity_data(wid, user_id)
608
+ except ValueError as exc:
609
+ self._json(400, {"ok": False, "error": str(exc)})
610
+ return
611
+ self._json(200, {"ok": True, "workspace_id": wid, "activity": activity})
612
+
613
+ def _route_pattern_get_workspace_kanban_tasks(self, path: str, qs: dict[str, list[str]]) -> None:
614
+ srv = _srv()
615
+ parts = path.strip("/").split("/")
616
+ if len(parts) != 5:
617
+ self._json(404, {"error": "not_found"})
618
+ return
619
+ wid = srv._resolve_wid(parts[2])
620
+ user_id = str(getattr(self, "auth_user", "") or "")
621
+ if not user_id:
622
+ self._json(401, {"ok": False, "error": "no_session"})
623
+ return
624
+ if not wid:
625
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
626
+ return
627
+ try:
628
+ payload = srv.kanban_proxy_list_tasks(wid, user_id)
629
+ except PermissionError as exc:
630
+ self._json(403, {"ok": False, "error": str(exc)})
631
+ return
632
+ self._json(200, payload)
633
+
634
+ def _route_pattern_get_workspace_delegation_grants(self, path: str, qs: dict[str, list[str]]) -> None:
635
+ srv = _srv()
636
+ parts = path.strip("/").split("/")
637
+ if len(parts) != 4:
638
+ self._json(404, {"error": "not_found"})
639
+ return
640
+ wid = srv._resolve_wid(parts[2])
641
+ user_id = str(getattr(self, "auth_user", "") or "")
642
+ if not user_id:
643
+ self._json(401, {"ok": False, "error": "no_session"})
644
+ return
645
+ if not wid:
646
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
647
+ return
648
+ try:
649
+ payload = srv.list_delegation_grants(wid, user_id)
650
+ except PermissionError as exc:
651
+ self._json(403, {"ok": False, "error": str(exc)})
652
+ return
653
+ self._json(200, payload)
654
+
655
+ def _route_pattern_get_workspace_invites(self, path: str, qs: dict[str, list[str]]) -> None:
656
+ srv = _srv()
657
+ parts = path.strip("/").split("/")
658
+ if len(parts) != 4:
659
+ self._json(404, {"error": "not_found"})
660
+ return
661
+ wid = parts[2]
662
+ ws_id = srv._resolve_wid(wid)
663
+ if not ws_id:
664
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
665
+ return
666
+ try:
667
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
668
+ conn.row_factory = sqlite3.Row
669
+ invites = conn.execute(
670
+ "SELECT id, workspace_id, email, role, invited_by_user_id, expires_at, created_at, accepted_at, deleted_at FROM workspace_invites WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC",
671
+ (ws_id,)).fetchall()
672
+ conn.close()
673
+ except Exception:
674
+ invites = []
675
+ self._json(200, {"ok": True, "invites": [dict(i) for i in invites]})
676
+
677
+ def _route_pattern_get_invite(self, path: str, qs: dict[str, list[str]]) -> None:
678
+ srv = _srv()
679
+ parts = path.strip("/").split("/")
680
+ if len(parts) != 3:
681
+ self._json(404, {"error": "not_found"})
682
+ return
683
+ token = parts[2]
684
+ token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
685
+ try:
686
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
687
+ conn.row_factory = sqlite3.Row
688
+ inv = conn.execute(
689
+ "SELECT id, workspace_id, email, role, invited_by_user_id, expires_at, created_at, accepted_at, deleted_at FROM workspace_invites WHERE token_hash = ? AND deleted_at IS NULL",
690
+ (token_hash,)).fetchone()
691
+ conn.close()
692
+ except Exception:
693
+ inv = None
694
+ if not inv:
695
+ self._json(404, {"ok": False, "error": "invite_not_found"})
696
+ return
697
+ self._json(200, {"ok": True, "invite": dict(inv)})
698
+
699
+ def _route_pattern_get_workspace_memory(self, path: str, qs: dict[str, list[str]]) -> None:
700
+ srv = _srv()
701
+ parts = path.strip("/").split("/")
702
+ if len(parts) != 4:
703
+ self._json(404, {"error": "not_found"})
704
+ return
705
+ wid = parts[2]
706
+ ws_id = srv._resolve_wid(wid)
707
+ if not ws_id:
708
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
709
+ return
710
+ try:
711
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
712
+ conn.row_factory = sqlite3.Row
713
+ items = conn.execute(
714
+ "SELECT * FROM memory_items WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC",
715
+ (ws_id,)).fetchall()
716
+ conn.close()
717
+ except Exception:
718
+ items = []
719
+ self._json(200, {"ok": True, "items": [dict(i) for i in items]})
720
+
721
+ def _route_pattern_get_workspace_budget(self, path: str, qs: dict[str, list[str]]) -> None:
722
+ srv = _srv()
723
+ parts = path.strip("/").split("/")
724
+ if len(parts) != 4:
725
+ self._json(404, {"error": "not_found"})
726
+ return
727
+ ws_id = srv._resolve_wid(parts[2])
728
+ if not ws_id:
729
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
730
+ return
731
+ try:
732
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
733
+ conn.row_factory = sqlite3.Row
734
+ policies = conn.execute(
735
+ "SELECT * FROM budget_policies WHERE workspace_id = ? ORDER BY created_at DESC",
736
+ (ws_id,)).fetchall()
737
+ conn.close()
738
+ except Exception:
739
+ policies = []
740
+ self._json(200, {"ok": True, "budget_policies": [dict(p) for p in policies]})
741
+
742
+ def _route_pattern_get_workspace_grants(self, path: str, qs: dict[str, list[str]]) -> None:
743
+ srv = _srv()
744
+ parts = path.strip("/").split("/")
745
+ if len(parts) != 4:
746
+ self._json(404, {"error": "not_found"})
747
+ return
748
+ ws_id = srv._resolve_wid(parts[2])
749
+ if not ws_id:
750
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
751
+ return
752
+ try:
753
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
754
+ conn.row_factory = sqlite3.Row
755
+ grants = conn.execute(
756
+ "SELECT cg.*, cb.provider AS binding_provider FROM credential_grants cg LEFT JOIN credential_bindings cb ON cb.id = cg.credential_binding_id WHERE cb.workspace_id = ? OR cg.grantee_id = ? ORDER BY cg.created_at DESC",
757
+ (ws_id, ws_id)).fetchall()
758
+ conn.close()
759
+ except Exception:
760
+ grants = []
761
+ self._json(200, {"ok": True, "grants": [dict(g) for g in grants]})
762
+
763
+ def _route_pattern_get_workspace_events(self, path: str, qs: dict[str, list[str]]) -> None:
764
+ srv = _srv()
765
+ parts = path.strip("/").split("/")
766
+ if len(parts) < 4:
767
+ self._json(404, {"error": "not_found"})
768
+ return
769
+ wid = parts[2]
770
+ self.send_response(200)
771
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
772
+ self.send_header("Cache-Control", "no-cache")
773
+ self.send_header("Connection", "keep-alive")
774
+ cors_origin = srv._cors_origin_for(self.headers)
775
+ if cors_origin:
776
+ self.send_header("Access-Control-Allow-Origin", cors_origin)
777
+ self.send_header("Vary", "Origin")
778
+ self.end_headers()
779
+ last_revision = ""
780
+ last_heartbeat = time.time()
781
+ try:
782
+ while True:
783
+ try:
784
+ conn = srv._workframe_db()
785
+ revision = srv._workspace_sse_revision(conn, wid)
786
+ conn.close()
787
+ except Exception:
788
+ revision = "error"
789
+ if revision != last_revision:
790
+ payload = json.dumps(srv._workspace_event_payload(wid, revision))
791
+ self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
792
+ self.wfile.flush()
793
+ last_revision = revision
794
+ last_heartbeat = time.time()
795
+ elif time.time() - last_heartbeat >= 15:
796
+ heartbeat = json.dumps({"type": "heartbeat", "workspace_id": wid, "ts": int(time.time())})
797
+ self.wfile.write(f"data: {heartbeat}\n\n".encode("utf-8"))
798
+ self.wfile.flush()
799
+ last_heartbeat = time.time()
800
+ time.sleep(1)
801
+ except (BrokenPipeError, ConnectionResetError, OSError):
802
+ pass
803
+
804
+ def _route_pattern_get_workspace_credentials(self, path: str, qs: dict[str, list[str]]) -> None:
805
+ srv = _srv()
806
+ parts = path.strip("/").split("/")
807
+ if len(parts) != 4:
808
+ self._json(404, {"error": "not_found"})
809
+ return
810
+ ws_id = srv._resolve_wid(parts[2])
811
+ if not ws_id:
812
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
813
+ return
814
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
815
+ self._json(403, {"ok": False, "error": "forbidden"})
816
+ return
817
+ try:
818
+ conn = sqlite3.connect(str(srv._workframe_db_path()), timeout=3.0)
819
+ conn.row_factory = sqlite3.Row
820
+ rows = conn.execute(
821
+ """SELECT id, workspace_id, user_id, agent_profile_id, provider,
822
+ credential_type, credential_ref, label, is_active,
823
+ expires_at, created_by, created_at, updated_at
824
+ FROM credential_bindings
825
+ WHERE workspace_id = ? AND deleted_at IS NULL
826
+ ORDER BY created_at DESC""",
827
+ (ws_id,),
828
+ ).fetchall()
829
+ conn.close()
830
+ except sqlite3.Error as exc:
831
+ self._json(500, {"ok": False, "error": str(exc)})
832
+ return
833
+ self._json(200, {
834
+ "ok": True,
835
+ "scope": "workspace",
836
+ "workspace_id": ws_id,
837
+ "credentials": [dict(r) for r in rows],
838
+ })
839
+
840
+ def _route_pattern_post_workspace_credentials_store(self, path: str, body: dict) -> None:
841
+ srv = _srv()
842
+ # POST /api/workspace/:wid/credentials/store
843
+ parts = path.strip("/").split("/")
844
+ if len(parts) < 4:
845
+ self._json(404, {"error": "not_found"})
846
+ return
847
+ ws_id = srv._resolve_wid(parts[2])
848
+ if not ws_id:
849
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
850
+ return
851
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
852
+ self._json(403, {"ok": False, "error": "forbidden"})
853
+ return
854
+ provider = str(body.get("provider", "")).strip().lower()
855
+ api_key = str(body.get("api_key", "") or body.get("secret", "")).strip()
856
+ label = str(body.get("label", "") or provider)
857
+ if not provider or not api_key:
858
+ self._json(400, {"error": "provider and api_key required"})
859
+ return
860
+ spec = srv._catalog_provider(provider)
861
+ if not spec:
862
+ self._json(400, {"error": "provider_not_found"})
863
+ return
864
+ if spec.get("user_only"):
865
+ self._json(403, {"ok": False, "error": "provider_user_only"})
866
+ return
867
+ env_var = str(spec.get("env_var") or srv._default_credential_env_var(provider, "api_key"))
868
+ try:
869
+ payload = srv._store_workspace_credential(
870
+ ws_id,
871
+ provider,
872
+ "api_key",
873
+ api_key,
874
+ env_var,
875
+ label,
876
+ str(getattr(self, "auth_user", "") or ""),
877
+ )
878
+ except ValueError as exc:
879
+ self._json(400, {"error": str(exc)})
880
+ return
881
+ cred_id = str(payload["credential_id"])
882
+ self._log_audit("credential_stored", "credential_binding", cred_id, f"provider={provider}")
883
+ try:
884
+ srv._bootstrap_model_after_llm_connect("", ws_id, provider)
885
+ except (OSError, RuntimeError, ValueError) as exc:
886
+ srv._log_handler_error("POST workspace credentials/store bootstrap", exc)
887
+ if str(spec.get("category") or "") == "messaging":
888
+ sync_result = srv._sync_workspace_messaging_gateway(ws_id)
889
+ if not sync_result.get("ok"):
890
+ self._json(500, {"ok": False, "error": sync_result.get("error") or "messaging_sync_failed"})
891
+ return
892
+ self._json(200, {"ok": True, "credential_id": cred_id, "credential_ref": payload["credential_ref"]})
893
+
894
+ def _route_pattern_post_workspace_credentials_revoke(self, path: str, body: dict) -> None:
895
+ srv = _srv()
896
+ # POST /api/workspace/:wid/credentials/:bindingId/revoke
897
+ parts = path.strip("/").split("/")
898
+ if len(parts) < 5:
899
+ self._json(404, {"error": "not_found"})
900
+ return
901
+ ws_id = srv._resolve_wid(parts[2])
902
+ if not ws_id:
903
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
904
+ return
905
+ binding_id = parts[4]
906
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
907
+ self._json(403, {"ok": False, "error": "forbidden"})
908
+ return
909
+ try:
910
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
911
+ conn.row_factory = sqlite3.Row
912
+ row = conn.execute(
913
+ "SELECT provider FROM credential_bindings WHERE id = ? AND workspace_id = ?",
914
+ (binding_id, ws_id),
915
+ ).fetchone()
916
+ provider = str(row["provider"] or "").strip().lower() if row else ""
917
+ cur = conn.execute(
918
+ "UPDATE credential_bindings SET is_active = 0, updated_at = ? WHERE id = ? AND workspace_id = ?",
919
+ (str(int(time.time())), binding_id, ws_id),
920
+ )
921
+ conn.commit()
922
+ affected = cur.rowcount
923
+ conn.close()
924
+ except sqlite3.Error as exc:
925
+ self._json(500, {"error": f"db_error: {exc}"})
926
+ return
927
+ if not affected:
928
+ self._json(404, {"error": "credential_not_found"})
929
+ return
930
+ self._log_audit("credential_revoked", "credential_binding", binding_id, f"workspace={ws_id}")
931
+ spec = srv._catalog_provider(provider)
932
+ if spec and str(spec.get("category") or "") == "messaging":
933
+ sync_result = srv._sync_workspace_messaging_gateway(ws_id)
934
+ if not sync_result.get("ok"):
935
+ self._json(500, {"ok": False, "error": sync_result.get("error") or "messaging_sync_failed"})
936
+ return
937
+ self._json(200, {"ok": True, "credential_id": binding_id, "status": "revoked"})
938
+
939
+ def _route_pattern_post_workspace_invites(self, path: str, body: dict) -> None:
940
+ srv = _srv()
941
+ parts = path.strip("/").split("/")
942
+ if len(parts) != 4:
943
+ self._json(404, {"error": "not_found"})
944
+ return
945
+ ws_id = srv._resolve_wid(parts[2])
946
+ if not ws_id:
947
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
948
+ return
949
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
950
+ self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
951
+ return
952
+ email = str(body.get("email", "")).strip()
953
+ role = str(body.get("role", "member")).strip()
954
+ if not email:
955
+ self._json(400, {"error": "email required"})
956
+ return
957
+ invite_id = str(uuid.uuid4())
958
+ token = secrets.token_hex(32)
959
+ token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
960
+ now_ts = str(int(time.time()))
961
+ expires_at = str(int(time.time()) + 86400 * 7)
962
+ try:
963
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
964
+ conn.execute(
965
+ "INSERT INTO workspace_invites (id, workspace_id, email, role, token_hash, invited_by_user_id, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)",
966
+ (invite_id, ws_id, email, role, token_hash, getattr(self, "auth_user", ""), expires_at, now_ts),
967
+ )
968
+ conn.commit()
969
+ conn.close()
970
+ except sqlite3.Error as exc:
971
+ self._json(500, {"error": f"db_error: {exc}"})
972
+ return
973
+ invite_url = f"{APP_BASE_URL}/?invite_token={urllib.parse.quote(token)}&email={urllib.parse.quote(email)}"
974
+ email_sent = False
975
+ email_error = None
976
+ ws_name = ws_id
977
+ logo_url = ""
978
+ try:
979
+ conn = sqlite3.connect(str(srv._workframe_db_path()), timeout=3.0)
980
+ conn.row_factory = sqlite3.Row
981
+ ws_row = conn.execute(
982
+ "SELECT display_name, avatar_url FROM workspaces WHERE id = ?",
983
+ (ws_id,),
984
+ ).fetchone()
985
+ conn.close()
986
+ if ws_row:
987
+ ws_name = str(ws_row["display_name"] or ws_id)
988
+ logo_url = str(ws_row["avatar_url"] or "")
989
+ except sqlite3.Error:
990
+ pass
991
+ try:
992
+ send_branded_invite_email(email, ws_name, invite_url, logo_url)
993
+ email_sent = True
994
+ except Exception as exc:
995
+ email_error = str(exc)
996
+ self._log_audit("invite_created", "workspace_invite", invite_id, f"workspace={ws_id} email={email}")
997
+ payload = {"ok": True, "invite_id": invite_id, "token": token, "email": email, "role": role, "expires_at": expires_at, "invite_url": invite_url, "email_sent": email_sent}
998
+ if email_error:
999
+ payload["email_error"] = email_error
1000
+ self._json(201, payload)
1001
+
1002
+ def _route_pattern_post_invites_accept(self, path: str, body: dict) -> None:
1003
+ srv = _srv()
1004
+ parts = path.strip("/").split("/")
1005
+ if len(parts) != 4 or parts[3] != "accept":
1006
+ self._json(404, {"error": "not_found"})
1007
+ return
1008
+ token = parts[2]
1009
+ token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
1010
+ user_id = str(getattr(self, "auth_user", "") or "")
1011
+ if not user_id:
1012
+ if srv.DEV_LOCAL_UNSAFE:
1013
+ user_id = "dev"
1014
+ else:
1015
+ self._json(401, {"ok": False, "error": "no_authenticated_user"})
1016
+ return
1017
+ try:
1018
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
1019
+ conn.row_factory = sqlite3.Row
1020
+ inv = conn.execute(
1021
+ "SELECT * FROM workspace_invites WHERE token_hash = ? AND deleted_at IS NULL AND accepted_at IS NULL",
1022
+ (token_hash,)).fetchone()
1023
+ if not inv:
1024
+ conn.close()
1025
+ self._json(404, {"ok": False, "error": "invite_not_found_or_expired"})
1026
+ return
1027
+ if int(inv["expires_at"]) < int(time.time()):
1028
+ conn.close()
1029
+ self._json(410, {"ok": False, "error": "invite_expired"})
1030
+ return
1031
+ wid = inv["workspace_id"]
1032
+ role = inv["role"]
1033
+ membership_id = str(uuid.uuid4())
1034
+ now_ts = str(int(time.time()))
1035
+ inviter_id = str(inv["invited_by_user_id"] or "").strip()
1036
+ existing = conn.execute(
1037
+ "SELECT id FROM workspace_memberships WHERE workspace_id = ? AND user_id = ? AND deleted_at IS NULL",
1038
+ (wid, user_id)).fetchone()
1039
+ if existing:
1040
+ room_join = srv._onboard_workspace_member_rooms(
1041
+ conn,
1042
+ wid,
1043
+ user_id,
1044
+ inviter_user_id=inviter_id or None,
1045
+ )
1046
+ dm_room = srv._ensure_user_dm_room(conn, wid, user_id, inviter_id)
1047
+ conn.execute("UPDATE workspace_invites SET accepted_by_user_id = ?, accepted_at = ?, deleted_at = ? WHERE id = ?",
1048
+ (user_id, now_ts, now_ts, inv["id"]))
1049
+ conn.commit()
1050
+ conn.close()
1051
+ srv._set_user_current_workspace(user_id, wid)
1052
+ self._json(200, {"ok": True, "already_member": True, "workspace_id": wid, "room": room_join, "dm_room": dm_room})
1053
+ return
1054
+ conn.execute(
1055
+ "INSERT INTO workspace_memberships (id, workspace_id, user_id, role, status, invited_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
1056
+ (membership_id, wid, user_id, role, "active", inv["invited_by_user_id"], now_ts, now_ts),
1057
+ )
1058
+ room_join = srv._onboard_workspace_member_rooms(
1059
+ conn,
1060
+ wid,
1061
+ user_id,
1062
+ inviter_user_id=inviter_id or None,
1063
+ )
1064
+ dm_room = srv._ensure_user_dm_room(conn, wid, user_id, inviter_id)
1065
+ conn.execute("UPDATE workspace_invites SET accepted_by_user_id = ?, accepted_at = ?, deleted_at = ? WHERE id = ?",
1066
+ (user_id, now_ts, now_ts, inv["id"]))
1067
+ conn.commit()
1068
+ conn.close()
1069
+ except sqlite3.Error as exc:
1070
+ self._json(500, {"error": f"db_error: {exc}"})
1071
+ return
1072
+ srv._provision_invited_member_agent_runtimes(wid, user_id)
1073
+ srv._set_user_current_workspace(user_id, wid)
1074
+ self._log_audit("invite_accepted", "workspace_invite", inv["id"], f"workspace={wid} user={user_id}")
1075
+ self._json(
1076
+ 200,
1077
+ {
1078
+ "ok": True,
1079
+ "workspace_id": wid,
1080
+ "role": role,
1081
+ "membership_id": membership_id,
1082
+ "room": room_join,
1083
+ "dm_room": dm_room,
1084
+ },
1085
+ )
1086
+
1087
+ def _route_pattern_post_workspace_members(self, path: str, body: dict) -> None:
1088
+ srv = _srv()
1089
+ parts = path.strip("/").split("/")
1090
+ if len(parts) != 4:
1091
+ self._json(404, {"error": "not_found"})
1092
+ return
1093
+ ws_id = srv._resolve_wid(parts[2])
1094
+ if not ws_id:
1095
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1096
+ return
1097
+ target_user_id = str(body.get("user_id", "") or body.get("email", "")).strip()
1098
+ role = str(body.get("role", "member")).strip()
1099
+ if not target_user_id:
1100
+ self._json(400, {"ok": False, "error": "user_id required"})
1101
+ return
1102
+ # Check if membership already exists
1103
+ try:
1104
+ db = srv._workframe_db()
1105
+ existing = db.execute("SELECT id FROM workspace_memberships WHERE workspace_id = ? AND user_id = ? AND deleted_at IS NULL", (ws_id, target_user_id)).fetchone()
1106
+ db.close()
1107
+ except Exception:
1108
+ existing = None
1109
+ if existing:
1110
+ # Update existing membership
1111
+ status, payload = srv._patch_workspace_members(ws_id, {"user_id": target_user_id, "role": role}, self)
1112
+ else:
1113
+ # Create new membership
1114
+ mid = str(uuid.uuid4())
1115
+ now_ts = str(int(time.time()))
1116
+ try:
1117
+ db = srv._workframe_db()
1118
+ db.execute("INSERT INTO workspace_memberships (id, workspace_id, user_id, role, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", (mid, ws_id, target_user_id, role, "active", now_ts, now_ts))
1119
+ srv._onboard_workspace_member_rooms(db, ws_id, target_user_id)
1120
+ db.commit()
1121
+ db.close()
1122
+ status, payload = 200, {"ok": True, "membership_id": mid, "user_id": target_user_id, "role": role}
1123
+ except sqlite3.Error as exc:
1124
+ status, payload = 500, {"ok": False, "error": str(exc)}
1125
+ if status == 200:
1126
+ self._log_audit("workspace_member_added", "workspace_membership", f"{ws_id}/{target_user_id}", f"workspace={ws_id} user={target_user_id}")
1127
+ self._json(status, payload)
1128
+
1129
+ def _route_pattern_post_workspace_kanban_tasks(self, path: str, body: dict) -> None:
1130
+ srv = _srv()
1131
+ parts = path.strip("/").split("/")
1132
+ if len(parts) != 5:
1133
+ self._json(404, {"error": "not_found"})
1134
+ return
1135
+ ws_id = srv._resolve_wid(parts[2])
1136
+ user_id = str(getattr(self, "auth_user", "") or "")
1137
+ if not user_id:
1138
+ self._json(401, {"ok": False, "error": "no_session"})
1139
+ return
1140
+ if not ws_id:
1141
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1142
+ return
1143
+ try:
1144
+ payload = srv.kanban_proxy_create_task(ws_id, user_id, body)
1145
+ except PermissionError as exc:
1146
+ self._json(403, {"ok": False, "error": str(exc)})
1147
+ return
1148
+ except ValueError as exc:
1149
+ self._json(400, {"ok": False, "error": str(exc)})
1150
+ return
1151
+ self._json(201, payload)
1152
+
1153
+ def _route_pattern_post_workspace_delegation_grants(self, path: str, body: dict) -> None:
1154
+ srv = _srv()
1155
+ parts = path.strip("/").split("/")
1156
+ user_id = str(getattr(self, "auth_user", "") or "")
1157
+ if not user_id:
1158
+ self._json(401, {"ok": False, "error": "no_session"})
1159
+ return
1160
+ if len(parts) != 4:
1161
+ self._json(404, {"error": "not_found"})
1162
+ return
1163
+ ws_id = srv._resolve_wid(parts[2])
1164
+ if not ws_id:
1165
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1166
+ return
1167
+ grantee_user_id = str(body.get("grantee_user_id") or "").strip()
1168
+ scope = str(body.get("scope") or srv.DELEGATION_SCOPE_AGENTS_DELEGATE).strip()
1169
+ try:
1170
+ payload = srv.create_delegation_grant(ws_id, user_id, grantee_user_id, scope=scope)
1171
+ except PermissionError as exc:
1172
+ self._json(403, {"ok": False, "error": str(exc)})
1173
+ return
1174
+ except ValueError as exc:
1175
+ self._json(400, {"ok": False, "error": str(exc)})
1176
+ return
1177
+ self._json(201, payload)
1178
+
1179
+ def _route_pattern_post_workspace_runtime_profiles_purge(self, path: str, body: dict) -> None:
1180
+ srv = _srv()
1181
+ parts = path.strip("/").split("/")
1182
+ if len(parts) != 5:
1183
+ self._json(404, {"error": "not_found"})
1184
+ return
1185
+ ws_id = srv._resolve_wid(parts[2])
1186
+ user_id = str(getattr(self, "auth_user", "") or "")
1187
+ if not user_id:
1188
+ self._json(401, {"ok": False, "error": "no_session"})
1189
+ return
1190
+ if not ws_id:
1191
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1192
+ return
1193
+ conn = srv._workframe_db()
1194
+ try:
1195
+ role = srv._workspace_member_role(conn, ws_id, user_id)
1196
+ finally:
1197
+ conn.close()
1198
+ if role not in srv.OWNER_ADMIN_ROLES:
1199
+ self._json(403, {"ok": False, "error": "forbidden"})
1200
+ return
1201
+ try:
1202
+ payload = srv.purge_stale_runtime_profiles(ws_id)
1203
+ except ValueError as exc:
1204
+ self._json(400, {"ok": False, "error": str(exc)})
1205
+ return
1206
+ self._json(200, payload)
1207
+
1208
+ def _route_pattern_post_workspace_memory(self, path: str, body: dict) -> None:
1209
+ srv = _srv()
1210
+ parts = path.strip("/").split("/")
1211
+ if len(parts) != 4:
1212
+ self._json(404, {"error": "not_found"})
1213
+ return
1214
+ ws_id = srv._resolve_wid(parts[2])
1215
+ if not ws_id:
1216
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1217
+ return
1218
+ content = str(body.get("content", "")).strip()
1219
+ if not content:
1220
+ self._json(400, {"error": "content required"})
1221
+ return
1222
+ agent_profile_id = str(body.get("agent_profile_id", "") or None)
1223
+ scope = str(body.get("scope", "workspace")).strip()
1224
+ room_id = str(body.get("room_id") or None) if body.get("room_id") else None
1225
+ user_id_mem = str(body.get("user_id") or None) if body.get("user_id") else None
1226
+ visibility = str(body.get("visibility", "shared")).strip()
1227
+ mem_id = str(uuid.uuid4())
1228
+ now_ts = str(int(time.time()))
1229
+ try:
1230
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
1231
+ conn.execute(
1232
+ "INSERT INTO memory_items (id, workspace_id, agent_profile_id, scope, room_id, user_id, content, visibility, created_by_user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
1233
+ (mem_id, ws_id, agent_profile_id, scope, room_id, user_id_mem, content, visibility, getattr(self, "auth_user", ""), now_ts, now_ts),
1234
+ )
1235
+ conn.commit()
1236
+ conn.close()
1237
+ except sqlite3.Error as exc:
1238
+ self._json(500, {"error": f"db_error: {exc}"})
1239
+ return
1240
+ self._log_audit("memory_created", "memory_item", mem_id, f"workspace={ws_id} scope={scope}")
1241
+ self._json(201, {"ok": True, "memory_id": mem_id})
1242
+
1243
+ def _route_pattern_post_workspace_budget(self, path: str, body: dict) -> None:
1244
+ srv = _srv()
1245
+ parts = path.strip("/").split("/")
1246
+ if len(parts) != 4:
1247
+ self._json(404, {"error": "not_found"})
1248
+ return
1249
+ ws_id = srv._resolve_wid(parts[2])
1250
+ if not ws_id:
1251
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1252
+ return
1253
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
1254
+ self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
1255
+ return
1256
+ subject_type = str(body.get("subject_type", "workspace")).strip()
1257
+ subject_id = str(body.get("subject_id") or ws_id)
1258
+ provider = str(body.get("provider", "") or None)
1259
+ daily_limit = body.get("daily_limit_cents")
1260
+ monthly_limit = body.get("monthly_limit_cents")
1261
+ allowed_models = str(body.get("allowed_models", "") or None)
1262
+ now_ts = str(int(time.time()))
1263
+ policy_id = str(uuid.uuid4())
1264
+ try:
1265
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
1266
+ conn.execute(
1267
+ "INSERT INTO budget_policies (id, workspace_id, subject_type, subject_id, provider, daily_limit_cents, monthly_limit_cents, allowed_models, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
1268
+ (policy_id, ws_id, subject_type, subject_id, provider, daily_limit, monthly_limit, allowed_models, now_ts, now_ts),
1269
+ )
1270
+ conn.commit()
1271
+ conn.close()
1272
+ except sqlite3.Error as exc:
1273
+ self._json(500, {"error": f"db_error: {exc}"})
1274
+ return
1275
+ self._log_audit("budget_created", "budget_policy", policy_id, f"workspace={ws_id} subject={subject_type}")
1276
+ self._json(201, {"ok": True, "budget_policy_id": policy_id})
1277
+
1278
+ def _route_pattern_post_workspace_grants(self, path: str, body: dict) -> None:
1279
+ srv = _srv()
1280
+ parts = path.strip("/").split("/")
1281
+ if len(parts) != 4:
1282
+ self._json(404, {"error": "not_found"})
1283
+ return
1284
+ ws_id = srv._resolve_wid(parts[2])
1285
+ if not ws_id:
1286
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1287
+ return
1288
+ if not srv._role_allows(self, srv.OWNER_ADMIN_ROLES):
1289
+ self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
1290
+ return
1291
+ credential_binding_id = str(body.get("credential_binding_id", "")).strip()
1292
+ if not credential_binding_id:
1293
+ self._json(400, {"error": "credential_binding_id required"})
1294
+ return
1295
+ grantee_type = str(body.get("grantee_type", "user")).strip()
1296
+ grantee_id = str(body.get("grantee_id", "")).strip()
1297
+ if not grantee_id:
1298
+ self._json(400, {"error": "grantee_id required"})
1299
+ return
1300
+ provider = str(body.get("provider", "") or None)
1301
+ max_daily = body.get("max_daily_cents")
1302
+ max_total = body.get("max_total_cents")
1303
+ allowed_models = str(body.get("allowed_models", "") or None)
1304
+ allowed_agent_ids = str(body.get("allowed_agent_profile_ids", "") or None)
1305
+ allowed_room_ids = str(body.get("allowed_room_ids", "") or None)
1306
+ expires_at = str(body.get("expires_at") or None) if body.get("expires_at") else None
1307
+ grant_id = str(uuid.uuid4())
1308
+ now_ts = str(int(time.time()))
1309
+ user_id = str(getattr(self, "auth_user", "") or "")
1310
+ try:
1311
+ conn = sqlite3.connect(str(srv.AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
1312
+ conn.execute(
1313
+ "INSERT INTO credential_grants (id, credential_binding_id, granted_by_user_id, grantee_type, grantee_id, provider, max_daily_cents, max_total_cents, allowed_models, allowed_agent_profile_ids, allowed_room_ids, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
1314
+ (grant_id, credential_binding_id, user_id, grantee_type, grantee_id, provider, max_daily, max_total, allowed_models, allowed_agent_ids, allowed_room_ids, expires_at, now_ts),
1315
+ )
1316
+ conn.commit()
1317
+ conn.close()
1318
+ except sqlite3.Error as exc:
1319
+ self._json(500, {"error": f"db_error: {exc}"})
1320
+ return
1321
+ self._log_audit("grant_created", "credential_grant", grant_id, f"workspace={ws_id} grantee={grantee_type}:{grantee_id}")
1322
+ self._json(201, {"ok": True, "grant_id": grant_id})
1323
+
1324
+ def _route_pattern_patch_workspace_members(self, path: str, body: dict) -> None:
1325
+ srv = _srv()
1326
+ parts = path.strip("/").split("/")
1327
+ if len(parts) != 4:
1328
+ self._json(404, {"ok": False, "error": "not_found"})
1329
+ return
1330
+ ws_id = srv._resolve_wid(parts[2])
1331
+ if not ws_id:
1332
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1333
+ return
1334
+ status, payload = srv._patch_workspace_members(ws_id, body, self)
1335
+ if status == 200:
1336
+ self._log_audit(
1337
+ "workspace_member_updated",
1338
+ "workspace_membership",
1339
+ ws_id,
1340
+ f"workspace={ws_id}",
1341
+ {"workspace_id": ws_id, "changes": body},
1342
+ )
1343
+ self._json(status, payload)
1344
+
1345
+ def _route_pattern_patch_workspace_integrations(self, path: str, body: dict) -> None:
1346
+ srv = _srv()
1347
+ workspace_integrations_match = re.fullmatch(r"/api/workspace/([^/]+)/integrations", path)
1348
+ if workspace_integrations_match:
1349
+ user_id = str(getattr(self, "auth_user", "") or "")
1350
+ ws_id = srv._resolve_wid(workspace_integrations_match.group(1))
1351
+ if not ws_id:
1352
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1353
+ return
1354
+ status, payload = srv._patch_workspace_integrations(ws_id, body, user_id)
1355
+ if status == 200:
1356
+ self._log_audit(
1357
+ "workspace_integrations_updated",
1358
+ "workspace",
1359
+ ws_id,
1360
+ f"workspace={ws_id}",
1361
+ {"changes": list(body.keys())},
1362
+ )
1363
+ self._json(status, payload)
1364
+
1365
+ def _route_pattern_patch_workspace(self, path: str, body: dict) -> None:
1366
+ srv = _srv()
1367
+ workspace_patch_match = re.fullmatch(r"/api/workspace/([^/]+)", path)
1368
+ if workspace_patch_match:
1369
+ user_id = str(getattr(self, "auth_user", "") or "")
1370
+ ws_id = srv._resolve_wid(workspace_patch_match.group(1))
1371
+ if not ws_id:
1372
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1373
+ return
1374
+ status, payload = srv._patch_workspace(ws_id, body, user_id)
1375
+ if status == 200:
1376
+ self._log_audit(
1377
+ "workspace_updated",
1378
+ "workspace",
1379
+ ws_id,
1380
+ f"workspace={ws_id}",
1381
+ {"changes": body},
1382
+ )
1383
+ self._json(status, payload)
1384
+
1385
+ def _route_pattern_delete_workspace_members(self, path: str, body: dict) -> None:
1386
+ srv = _srv()
1387
+ parts = path.strip("/").split("/")
1388
+ if len(parts) != 4:
1389
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1390
+ return
1391
+ ws_id = srv._resolve_wid(parts[2])
1392
+ if not ws_id:
1393
+ self._json(404, {"ok": False, "error": "workspace_not_found"})
1394
+ return
1395
+ target_user_id = str(body.get("user_id", "")).strip()
1396
+ if not target_user_id:
1397
+ self._json(400, {"ok": False, "error": "user_id required"})
1398
+ return
1399
+ try:
1400
+ db = srv._workframe_db()
1401
+ cur = db.execute(
1402
+ "UPDATE workspace_memberships SET deleted_at = ?, status = 'removed' WHERE workspace_id = ? AND user_id = ? AND deleted_at IS NULL",
1403
+ (str(int(time.time())), ws_id, target_user_id),
1404
+ )
1405
+ db.commit()
1406
+ affected = cur.rowcount
1407
+ db.close()
1408
+ except Exception:
1409
+ self._json(500, {"ok": False, "error": "delete_failed"})
1410
+ return
1411
+ if not affected:
1412
+ self._json(404, {"ok": False, "error": "member_not_found"})
1413
+ return
1414
+ self._log_audit(
1415
+ "workspace_member_removed",
1416
+ "workspace_membership",
1417
+ f"{ws_id}/{target_user_id}",
1418
+ f"workspace={ws_id} user={target_user_id}",
1419
+ )
1420
+ self._json(200, {"ok": True, "user_id": target_user_id, "status": "removed"})