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
@@ -11,6 +11,8 @@ import urllib.request
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ import cell_authority
15
+
14
16
  HERMES_IMAGE = os.environ.get("WORKFRAME_HERMES_IMAGE", "nousresearch/hermes-agent")
15
17
  HERMES_TAG = os.environ.get("WORKFRAME_HERMES_TAG", "latest")
16
18
  NPM_PACKAGE = os.environ.get("WORKFRAME_NPM_PACKAGE", "create-workframe")
@@ -542,9 +544,22 @@ def _run_apply_scripts(target: str, env: dict[str, str]) -> dict[str, Any]:
542
544
  return {"ok": True, "target": target, "log": "\n".join(logs)[-12000:]}
543
545
 
544
546
 
545
- def apply_update(target: str) -> dict[str, Any]:
547
+ def apply_update(target: str, *, user_ack: bool = False) -> dict[str, Any]:
546
548
  if not _admin_stack_updates_enabled():
547
549
  raise ValueError("admin_updates_disabled")
550
+ open_decision = cell_authority.evaluate_open(_project_root())
551
+ if open_decision.decision == "deny":
552
+ raise ValueError(open_decision.reason or "cell_open_denied")
553
+ update_decision = cell_authority.evaluate_update(
554
+ _project_root(),
555
+ open_decision=open_decision,
556
+ user_ack=user_ack,
557
+ )
558
+ if update_decision.decision == "deny":
559
+ raise ValueError(update_decision.reason or "cell_update_denied")
560
+ if user_ack and update_decision.decision != "allow":
561
+ raise ValueError(update_decision.reason or "cell_update_denied")
562
+ # ponytail: without user_ack, needs_user_action does not block apply until UI sends ack
548
563
  target = str(target or "all").strip().lower()
549
564
  if target not in {"hermes", "workframe", "all"}:
550
565
  raise ValueError("invalid_update_target")
@@ -0,0 +1,387 @@
1
+ """User preferences, profile, and onboarding payloads (WF-032)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import sqlite3
7
+ import time
8
+ from typing import Any
9
+
10
+ import zk_auth as _zk
11
+
12
+ from auth_gate import OWNER_ADMIN_ROLES
13
+
14
+
15
+ def _srv():
16
+ import server as srv
17
+
18
+ return srv
19
+
20
+
21
+ def parse_user_platform_ids(value: Any) -> dict[str, str]:
22
+ if value is None:
23
+ return {}
24
+ if not isinstance(value, dict):
25
+ raise ValueError("platform_ids must be an object")
26
+ allowed = frozenset({"discord", "telegram", "slack"})
27
+ out: dict[str, str] = {}
28
+ for raw_key, raw_val in value.items():
29
+ key = str(raw_key or "").strip().lower()
30
+ if key not in allowed:
31
+ raise ValueError(f"unsupported platform_id: {raw_key}")
32
+ val = str(raw_val or "").strip()
33
+ if val:
34
+ out[key] = val
35
+ return out
36
+
37
+
38
+ def user_payload(row: sqlite3.Row) -> dict[str, Any]:
39
+ payload = {
40
+ "id": row["id"],
41
+ "email": row["email"],
42
+ "display_name": row["display_name"],
43
+ "avatar_url": row["avatar_url"],
44
+ "role": row["role"],
45
+ "status": row["status"],
46
+ "created_at": row["created_at"],
47
+ "updated_at": row["updated_at"],
48
+ }
49
+ keys = set(row.keys())
50
+ if "current_workspace_id" in keys:
51
+ payload["current_workspace_id"] = row["current_workspace_id"]
52
+ if "platform_ids" in keys:
53
+ raw = row["platform_ids"] or "{}"
54
+ try:
55
+ platform_ids = json.loads(raw) if isinstance(raw, str) else raw
56
+ except (TypeError, json.JSONDecodeError):
57
+ platform_ids = {}
58
+ payload["platform_ids"] = platform_ids if isinstance(platform_ids, dict) else {}
59
+ return payload
60
+
61
+
62
+ def display_name_from_email(email: str) -> str:
63
+ normalized = str(email or "").strip().lower()
64
+ if not normalized or "@" not in normalized:
65
+ return ""
66
+ local_part = normalized.split("@", 1)[0]
67
+ words = [part for part in re.split(r"[._\-\s]+", local_part) if part]
68
+ if not words:
69
+ return ""
70
+ return " ".join(word[:1].upper() + word[1:] for word in words)
71
+
72
+
73
+ def workspace_membership_payload(row: sqlite3.Row) -> dict[str, Any]:
74
+ return {
75
+ "id": row["id"],
76
+ "workspace_id": row["workspace_id"],
77
+ "workspace_slug": row["slug"],
78
+ "workspace_display_name": row["display_name"],
79
+ "role": row["role"],
80
+ "status": row["status"],
81
+ "created_at": row["membership_created_at"],
82
+ "updated_at": row["membership_updated_at"],
83
+ }
84
+
85
+
86
+ def get_workframe_user(user_id: str) -> dict[str, Any] | None:
87
+ try:
88
+ db = _srv()._workframe_db()
89
+ row = db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
90
+ db.close()
91
+ if not row:
92
+ return None
93
+ return user_payload(row)
94
+ except Exception:
95
+ return None
96
+
97
+
98
+ def merge_user_platform_ids(user_id: str, patch: dict[str, str]) -> None:
99
+ user_id = str(user_id or "").strip()
100
+ if not user_id or not patch:
101
+ return
102
+ current: dict[str, str] = {}
103
+ user = get_workframe_user(user_id)
104
+ if user and isinstance(user.get("platform_ids"), dict):
105
+ current = {str(k): str(v) for k, v in user["platform_ids"].items() if str(v or "").strip()}
106
+ merged = {**current, **{str(k): str(v) for k, v in patch.items() if str(v or "").strip()}}
107
+ apply_me_profile_updates(user_id, {"platform_ids": merged})
108
+
109
+
110
+ def read_user_llm_prefs(user_id: str) -> tuple[str, list[dict[str, str]]]:
111
+ user = get_workframe_user(str(user_id or "").strip())
112
+ pids = user.get("platform_ids") if user and isinstance(user.get("platform_ids"), dict) else {}
113
+ primary = str(pids.get("llm_primary") or "").strip()
114
+ chain_raw = pids.get("llm_fallback_chain")
115
+ chain: list[dict[str, str]] = []
116
+ if isinstance(chain_raw, str) and chain_raw.strip():
117
+ try:
118
+ parsed = json.loads(chain_raw)
119
+ if isinstance(parsed, list):
120
+ for entry in parsed:
121
+ if isinstance(entry, dict):
122
+ prov = str(entry.get("provider") or "").strip()
123
+ model = str(entry.get("model") or "").strip()
124
+ if prov and model:
125
+ chain.append({"provider": prov, "model": model})
126
+ except json.JSONDecodeError:
127
+ pass
128
+ return primary, chain
129
+
130
+
131
+ def write_user_llm_prefs(
132
+ user_id: str,
133
+ *,
134
+ primary: str = "",
135
+ fallback_chain: list[dict[str, str]] | None = None,
136
+ ) -> None:
137
+ user_id = str(user_id or "").strip()
138
+ if not user_id:
139
+ return
140
+ patch: dict[str, str] = {}
141
+ if primary:
142
+ patch["llm_primary"] = primary.strip()
143
+ if fallback_chain is not None:
144
+ patch["llm_fallback_chain"] = json.dumps(fallback_chain, separators=(",", ":"))
145
+ if patch:
146
+ merge_user_platform_ids(user_id, patch)
147
+
148
+
149
+ def validate_me_profile_updates(updates: dict[str, Any]) -> None:
150
+ avatar = updates.get("avatar_url")
151
+ if avatar is None:
152
+ return
153
+ text = str(avatar).strip()
154
+ if not text:
155
+ return
156
+ if text.startswith("data:") and len(text) > 380_000:
157
+ raise ValueError("Avatar image is too large. Use an image under 256 KB.")
158
+ if len(text) > 400_000:
159
+ raise ValueError("Avatar image is too large. Use an image under 256 KB.")
160
+
161
+
162
+ def apply_me_profile_updates(user_id: str, body: dict[str, Any]) -> dict[str, Any]:
163
+ user_id = str(user_id or "").strip()
164
+ allowed = {"display_name", "avatar_url", "tagline", "bio"}
165
+ updates = {k: v for k, v in body.items() if k in allowed}
166
+ validate_me_profile_updates(updates)
167
+ if "avatar_url" in updates:
168
+ updates["avatar_url"] = _srv()._normalize_user_avatar_url(str(updates.get("avatar_url") or ""))
169
+ profile = _zk.update_profile(user_id, updates) if updates else _zk.get_profile(user_id)
170
+ if "platform_ids" in body:
171
+ platform_ids = parse_user_platform_ids(body.get("platform_ids"))
172
+ conn = _srv()._workframe_db()
173
+ try:
174
+ now_ts = str(int(time.time()))
175
+ conn.execute(
176
+ "UPDATE users SET platform_ids = ?, updated_at = ? WHERE id = ?",
177
+ (json.dumps(platform_ids, sort_keys=True), now_ts, user_id),
178
+ )
179
+ conn.commit()
180
+ workspaces = _srv()._get_user_workspaces(user_id)
181
+ current = _srv()._resolve_current_workspace(user_id, workspaces)
182
+ if current and str(current.get("id") or ""):
183
+ sync_result = _srv()._sync_workspace_messaging_gateway(str(current["id"]))
184
+ if not sync_result.get("ok"):
185
+ raise ValueError(str(sync_result.get("error") or "messaging_sync_failed"))
186
+ finally:
187
+ conn.close()
188
+ sync_workframe_user_profile(user_id, updates)
189
+ return profile or {}
190
+
191
+
192
+ def sync_workframe_user_profile(user_id: str, fields: dict[str, Any]) -> None:
193
+ user_id = str(user_id or "").strip()
194
+ if not user_id or not fields:
195
+ return
196
+ conn = _srv()._workframe_db()
197
+ try:
198
+ row = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
199
+ if not row:
200
+ return
201
+ sets: list[str] = []
202
+ vals: list[Any] = []
203
+ if "avatar_url" in fields:
204
+ sets.append("avatar_url = ?")
205
+ vals.append(fields["avatar_url"])
206
+ if "display_name" in fields:
207
+ sets.append("display_name = ?")
208
+ vals.append(fields["display_name"])
209
+ if "platform_ids" in fields:
210
+ sets.append("platform_ids = ?")
211
+ vals.append(json.dumps(fields["platform_ids"], sort_keys=True))
212
+ if not sets:
213
+ return
214
+ sets.append("updated_at = ?")
215
+ vals.append(str(int(time.time())))
216
+ vals.append(user_id)
217
+ conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id = ?", vals)
218
+ conn.commit()
219
+ finally:
220
+ conn.close()
221
+
222
+
223
+ def current_user_me(user_id: str) -> dict[str, Any]:
224
+ if not user_id:
225
+ return {"ok": False, "error": "no_authenticated_user"}
226
+ try:
227
+ conn = _srv()._workframe_db()
228
+ except sqlite3.Error as exc:
229
+ return {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
230
+ try:
231
+ user = conn.execute(
232
+ """
233
+ SELECT id, email, display_name, avatar_url, role, status, created_at, updated_at
234
+ FROM users
235
+ WHERE id = ? AND deleted_at IS NULL AND status = 'active'
236
+ """,
237
+ (user_id,),
238
+ ).fetchone()
239
+ if not user:
240
+ return {"ok": False, "error": "user_not_found", "user_id": user_id}
241
+ memberships = conn.execute(
242
+ """
243
+ SELECT DISTINCT
244
+ wm.id,
245
+ wm.workspace_id,
246
+ wm.role,
247
+ wm.status,
248
+ wm.created_at AS membership_created_at,
249
+ wm.updated_at AS membership_updated_at,
250
+ w.slug,
251
+ w.display_name
252
+ FROM workspace_memberships wm
253
+ JOIN workspaces w ON w.id = wm.workspace_id
254
+ WHERE wm.user_id = ?
255
+ AND wm.deleted_at IS NULL
256
+ AND wm.status = 'active'
257
+ AND w.deleted_at IS NULL
258
+ AND w.status = 'active'
259
+ ORDER BY w.slug, w.display_name
260
+ """,
261
+ (user_id,),
262
+ ).fetchall()
263
+ return {
264
+ "ok": True,
265
+ "user": user_payload(user),
266
+ "workspace_memberships": [workspace_membership_payload(row) for row in memberships],
267
+ }
268
+ except sqlite3.Error as exc:
269
+ return {"ok": False, "error": f"current_user_lookup_failed: {exc}"}
270
+ finally:
271
+ conn.close()
272
+
273
+
274
+ def onboarding_payload(user_id: str) -> dict[str, Any]:
275
+ user_id = str(user_id or "").strip()
276
+ workspaces = _srv()._get_user_workspaces(user_id)
277
+ ws = next((w for w in workspaces if w.get("slug") == "default"), None) or (workspaces[0] if workspaces else None)
278
+ if not ws:
279
+ return {"ok": True, "complete": False, "step": "workspace", "credential_mode": "byok"}
280
+ ws_id = str(ws.get("id") or "")
281
+ role = str(ws.get("role") or "")
282
+ settings: dict[str, Any] = {}
283
+ try:
284
+ conn = _srv()._workframe_db()
285
+ row = conn.execute(
286
+ "SELECT settings_json FROM workspaces WHERE id = ? AND deleted_at IS NULL",
287
+ (ws_id,),
288
+ ).fetchone()
289
+ conn.close()
290
+ if row:
291
+ settings = _srv()._parse_workspace_settings(row)
292
+ except sqlite3.Error:
293
+ pass
294
+ credential_mode = str(settings.get("credential_mode") or "byok").strip() or "byok"
295
+ is_admin = role in OWNER_ADMIN_ROLES
296
+ install_done = _srv()._install_complete()
297
+ integrations_done = bool(settings.get("admin_integrations_done")) or not is_admin or install_done
298
+ admin_done = bool(settings.get("admin_onboarding_done")) or not is_admin or install_done
299
+ user_has_llm = _srv()._user_has_llm_provider(user_id)
300
+ if is_admin and not integrations_done:
301
+ return {
302
+ "ok": True,
303
+ "complete": False,
304
+ "step": "admin_integrations",
305
+ "credential_mode": credential_mode,
306
+ "role": role,
307
+ "workspace_id": ws_id,
308
+ }
309
+ if is_admin and not admin_done:
310
+ return {
311
+ "ok": True,
312
+ "complete": False,
313
+ "step": "admin",
314
+ "credential_mode": credential_mode,
315
+ "role": role,
316
+ "workspace_id": ws_id,
317
+ }
318
+ if credential_mode == "workspace" and is_admin and not _srv()._workspace_has_llm_provider(ws_id):
319
+ return {
320
+ "ok": True,
321
+ "complete": False,
322
+ "step": "workspace_provider",
323
+ "credential_mode": credential_mode,
324
+ "role": role,
325
+ "workspace_id": ws_id,
326
+ }
327
+ if credential_mode == "byok" and not user_has_llm:
328
+ return {
329
+ "ok": True,
330
+ "complete": False,
331
+ "step": "user_provider",
332
+ "credential_mode": credential_mode,
333
+ "role": role,
334
+ "workspace_id": ws_id,
335
+ }
336
+ return {
337
+ "ok": True,
338
+ "complete": True,
339
+ "step": "done",
340
+ "credential_mode": credential_mode,
341
+ "role": role,
342
+ "workspace_id": ws_id,
343
+ "has_llm_provider": user_has_llm,
344
+ }
345
+
346
+
347
+ def session_profile_payload(user_id: str) -> dict[str, Any] | None:
348
+ user_id = str(user_id or "").strip()
349
+ if not user_id:
350
+ return None
351
+ profile = _zk.get_profile(user_id)
352
+ user = get_workframe_user(user_id)
353
+ if not profile and not user:
354
+ return None
355
+ workspaces = _srv()._get_user_workspaces(user_id)
356
+ email = str((user or {}).get("email", "") or "")
357
+ display_name = (
358
+ str((profile or {}).get("display_name", "") or "").strip()
359
+ or str((user or {}).get("display_name", "") or "").strip()
360
+ or display_name_from_email(email)
361
+ )
362
+ default_workspace = next((ws for ws in workspaces if ws.get("slug") == "default"), None)
363
+ current_workspace = _srv()._resolve_current_workspace(user_id, workspaces)
364
+ wf_user = get_workframe_user(user_id) or {}
365
+ platform_ids = wf_user.get("platform_ids") if isinstance(wf_user.get("platform_ids"), dict) else {}
366
+ zk_avatar = str((profile or {}).get("avatar_url") or "").strip()
367
+ wf_avatar = str((user or {}).get("avatar_url") or wf_user.get("avatar_url") or "").strip()
368
+ avatar_url = (
369
+ _srv()._normalize_user_avatar_url(zk_avatar or wf_avatar) if (zk_avatar or wf_avatar) else None
370
+ )
371
+ return {
372
+ "ok": True,
373
+ "user": {
374
+ "user_id": user_id,
375
+ "id": user_id,
376
+ "email": email,
377
+ "display_name": display_name,
378
+ "avatar_url": avatar_url,
379
+ "tagline": (profile or {}).get("tagline"),
380
+ "bio": (profile or {}).get("bio"),
381
+ "platform_ids": platform_ids,
382
+ },
383
+ "workspaces": workspaces,
384
+ "current_workspace": current_workspace,
385
+ "default_workspace": default_workspace,
386
+ "hermes_dashboard_access": _srv()._user_can_access_hermes_dashboard(user_id),
387
+ }