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,343 @@
1
+ """WF-032 extract: avatar catalog, preset picks, and agent avatar registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import secrets
8
+ import urllib.parse
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ def _srv():
15
+ import server as srv
16
+
17
+ return srv
18
+
19
+
20
+ _avatar_catalog_cache: dict[str, Any] | None = None
21
+ _preset_catalog_cache: dict[str, dict[str, Any]] = {}
22
+
23
+
24
+ def _load_avatar_catalog() -> dict[str, Any]:
25
+ global _avatar_catalog_cache
26
+ if _avatar_catalog_cache is not None:
27
+ return _avatar_catalog_cache
28
+ fallback: dict[str, Any] = {"public_base": "/assets/avatars", "avatars": []}
29
+ catalog_path = _srv().AVATAR_CATALOG_JSON
30
+ if not catalog_path.is_file():
31
+ _avatar_catalog_cache = fallback
32
+ return fallback
33
+ try:
34
+ data = json.loads(catalog_path.read_text(encoding="utf-8"))
35
+ _avatar_catalog_cache = data if isinstance(data, dict) else fallback
36
+ except Exception: # noqa: BLE001
37
+ _avatar_catalog_cache = fallback
38
+ return _avatar_catalog_cache
39
+
40
+
41
+ def _avatar_path_from_url(avatar_url: str) -> str:
42
+ raw = str(avatar_url or "").strip()
43
+ if "://" in raw:
44
+ try:
45
+ parsed = urllib.parse.urlparse(raw)
46
+ raw = parsed.path or raw
47
+ except Exception: # noqa: BLE001
48
+ pass
49
+ return raw.split("?", 1)[0].split("#", 1)[0]
50
+
51
+
52
+ def _catalog_url_for_id(
53
+ catalog: dict[str, Any],
54
+ avatar_id: str,
55
+ *,
56
+ items_key: str = "avatars",
57
+ default_base: str = "/assets",
58
+ ) -> str:
59
+ avatar_id = str(avatar_id or "").strip()
60
+ base = str(catalog.get("public_base") or default_base).rstrip("/")
61
+ for row in catalog.get(items_key) or []:
62
+ if isinstance(row, dict) and str(row.get("id")) == avatar_id:
63
+ file_name = str(row.get("file") or f"{avatar_id}.png")
64
+ return f"{base}/{file_name}"
65
+ return f"{base}/{avatar_id}.png"
66
+
67
+
68
+ def _id_from_catalog_url(
69
+ catalog: dict[str, Any],
70
+ avatar_url: str,
71
+ *,
72
+ items_key: str = "avatars",
73
+ ) -> str:
74
+ path = _avatar_path_from_url(avatar_url)
75
+ basename = path.rsplit("/", 1)[-1]
76
+ if not basename:
77
+ return ""
78
+ rows = [row for row in catalog.get(items_key) or [] if isinstance(row, dict)]
79
+ base = str(catalog.get("public_base") or "").rstrip("/")
80
+ for row in rows:
81
+ if str(row.get("file") or f"{row.get('id')}.png") == basename:
82
+ return str(row.get("id") or "")
83
+ if base:
84
+ stable = re.match(rf"^{re.escape(base)}/([^/]+)\.png$", path, re.I)
85
+ if stable:
86
+ stem = stable.group(1)
87
+ for row in rows:
88
+ if str(row.get("id") or "") == stem:
89
+ return str(row.get("id") or "")
90
+ file_stem = str(row.get("file") or f"{row.get('id')}.png").replace(".png", "")
91
+ if file_stem == stem:
92
+ return str(row.get("id") or "")
93
+ hashed = re.match(r"/assets/([a-z0-9]+)-[A-Za-z0-9_]+\.png$", path, re.I)
94
+ if hashed:
95
+ prefix = hashed.group(1)
96
+ for row in rows:
97
+ if str(row.get("id") or "") == prefix:
98
+ return str(row.get("id") or "")
99
+ file_stem = str(row.get("file") or f"{row.get('id')}.png").replace(".png", "")
100
+ if file_stem == prefix:
101
+ return str(row.get("id") or "")
102
+ return ""
103
+
104
+
105
+ def _normalize_catalog_avatar_patch(
106
+ catalog: dict[str, Any],
107
+ avatar_url: str = "",
108
+ avatar_id: str = "",
109
+ *,
110
+ items_key: str = "avatars",
111
+ default_base: str = "/assets",
112
+ include_id: bool = True,
113
+ ) -> dict[str, str]:
114
+ aid = str(avatar_id or "").strip()
115
+ raw = str(avatar_url or "").strip()
116
+ if raw.startswith("data:"):
117
+ out = {"avatar_url": raw}
118
+ if include_id:
119
+ out["avatar_id"] = ""
120
+ return out
121
+ path = _avatar_path_from_url(raw)
122
+ if not path and not aid:
123
+ out = {"avatar_url": ""}
124
+ if include_id:
125
+ out["avatar_id"] = ""
126
+ return out
127
+ if not aid and path:
128
+ aid = _id_from_catalog_url(catalog, path, items_key=items_key)
129
+ if aid:
130
+ stable = _catalog_url_for_id(catalog, aid, items_key=items_key, default_base=default_base)
131
+ out = {"avatar_url": stable}
132
+ if include_id:
133
+ out["avatar_id"] = aid
134
+ return out
135
+ out = {"avatar_url": path or raw}
136
+ if include_id:
137
+ out["avatar_id"] = ""
138
+ return out
139
+
140
+
141
+ def _avatar_url_for_id(avatar_id: str) -> str:
142
+ return _catalog_url_for_id(
143
+ _load_avatar_catalog(),
144
+ avatar_id,
145
+ items_key="avatars",
146
+ default_base="/assets/avatars",
147
+ )
148
+
149
+
150
+ def _avatar_id_from_url(avatar_url: str) -> str:
151
+ return _id_from_catalog_url(_load_avatar_catalog(), avatar_url, items_key="avatars")
152
+
153
+
154
+ def _load_preset_catalog(path: Path) -> dict[str, Any]:
155
+ key = str(path)
156
+ if key in _preset_catalog_cache:
157
+ return _preset_catalog_cache[key]
158
+ fallback: dict[str, Any] = {"public_base": "", "avatars": [], "logos": []}
159
+ if not path.is_file():
160
+ _preset_catalog_cache[key] = fallback
161
+ return fallback
162
+ try:
163
+ data = json.loads(path.read_text(encoding="utf-8"))
164
+ _preset_catalog_cache[key] = data if isinstance(data, dict) else fallback
165
+ except Exception: # noqa: BLE001
166
+ _preset_catalog_cache[key] = fallback
167
+ return _preset_catalog_cache[key]
168
+
169
+
170
+ def _normalize_user_avatar_url(avatar_url: str) -> str:
171
+ catalog = _load_preset_catalog(_srv().USER_AVATAR_CATALOG_JSON)
172
+ return _normalize_catalog_avatar_patch(
173
+ catalog,
174
+ avatar_url,
175
+ items_key="avatars",
176
+ default_base="/assets/avatars",
177
+ include_id=False,
178
+ )["avatar_url"]
179
+
180
+
181
+ def _normalize_logo_url(avatar_url: str) -> str:
182
+ catalog = _load_preset_catalog(_srv().LOGO_CATALOG_JSON)
183
+ return _normalize_catalog_avatar_patch(
184
+ catalog,
185
+ avatar_url,
186
+ items_key="logos",
187
+ default_base="/assets/project-logos",
188
+ include_id=False,
189
+ )["avatar_url"]
190
+
191
+
192
+ def _normalize_agent_avatar_patch(
193
+ avatar_url: str = "",
194
+ avatar_id: str = "",
195
+ ) -> dict[str, str]:
196
+ """Persist stable catalog id + nginx path — not vite hash or absolute origin."""
197
+ return _normalize_catalog_avatar_patch(
198
+ _load_avatar_catalog(),
199
+ avatar_url,
200
+ avatar_id,
201
+ items_key="avatars",
202
+ default_base="/assets/avatars",
203
+ include_id=True,
204
+ )
205
+
206
+
207
+ def _pick_preset_url(catalog_path: Path, *, items_key: str = "avatars") -> str:
208
+ catalog = _load_preset_catalog(catalog_path)
209
+ base = str(catalog.get("public_base") or "").rstrip("/")
210
+ items = [row for row in catalog.get(items_key) or [] if isinstance(row, dict) and row.get("id")]
211
+ if not items or not base:
212
+ return ""
213
+ pick = secrets.choice(items)
214
+ file_name = str(pick.get("file") or f"{pick['id']}.png")
215
+ return f"{base}/{file_name}"
216
+
217
+
218
+ def _pick_logo_url() -> str:
219
+ return _pick_preset_url(_srv().LOGO_CATALOG_JSON, items_key="logos")
220
+
221
+
222
+ def _pick_user_avatar_url() -> str:
223
+ return _pick_preset_url(_srv().USER_AVATAR_CATALOG_JSON, items_key="avatars")
224
+
225
+
226
+ def _resolve_avatar_fields(row: dict[str, Any]) -> None:
227
+ avatar_id = str(row.get("avatar_id") or "").strip()
228
+ if avatar_id:
229
+ row["avatar_url"] = _avatar_url_for_id(avatar_id)
230
+ return
231
+ explicit = str(row.get("avatar_url") or "").strip()
232
+ if explicit:
233
+ row["avatar_url"] = explicit
234
+
235
+
236
+ def _load_avatar_registry() -> dict[str, Any]:
237
+ registry_path = _srv().AVATAR_REGISTRY_JSON
238
+ if not registry_path.is_file():
239
+ return {"version": 1, "weights": {}, "assignments": {}}
240
+ try:
241
+ data = json.loads(registry_path.read_text(encoding="utf-8"))
242
+ if isinstance(data, dict):
243
+ data.setdefault("weights", {})
244
+ data.setdefault("assignments", {})
245
+ return data
246
+ except Exception: # noqa: BLE001
247
+ pass
248
+ return {"version": 1, "weights": {}, "assignments": {}}
249
+
250
+
251
+ def _save_avatar_registry(data: dict[str, Any]) -> None:
252
+ registry_path = _srv().AVATAR_REGISTRY_JSON
253
+ registry_path.parent.mkdir(parents=True, exist_ok=True)
254
+ registry_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
255
+
256
+
257
+ def _pick_avatar_id() -> str:
258
+ catalog = _load_avatar_catalog()
259
+ avatars = [row for row in catalog.get("avatars") or [] if isinstance(row, dict) and row.get("id")]
260
+ if not avatars:
261
+ return "steve"
262
+ registry = _load_avatar_registry()
263
+ agents = _srv().load_agent_registry()
264
+ assigned = {str(v) for v in registry.get("assignments", {}).values()}
265
+ for row in agents.values():
266
+ aid = str(row.get("avatar_id") or "").strip()
267
+ if aid:
268
+ assigned.add(aid)
269
+ pool = [row for row in avatars if str(row["id"]) not in assigned] or avatars
270
+ weights = registry.get("weights") or {}
271
+ min_weight = min(int(weights.get(str(row["id"]), 0)) for row in pool)
272
+ candidates = [row for row in pool if int(weights.get(str(row["id"]), 0)) <= min_weight]
273
+ pick = secrets.choice(candidates)
274
+ return str(pick["id"])
275
+
276
+
277
+ def _upsert_agent_registry_row(profile: str, patch: dict[str, Any]) -> None:
278
+ prof_key = _srv().safe_profile_slug(profile)
279
+ if "avatar_url" in patch or "avatar_id" in patch:
280
+ norm = _normalize_agent_avatar_patch(
281
+ str(patch.get("avatar_url") or ""),
282
+ str(patch.get("avatar_id") or ""),
283
+ )
284
+ patch = {**patch, **norm}
285
+ agents_json = _srv().AGENTS_JSON
286
+ agents_json.parent.mkdir(parents=True, exist_ok=True)
287
+ data: dict[str, Any] = {"version": 1, "owner_profile": _srv()._primary_profile(), "agents": {}}
288
+ if agents_json.is_file():
289
+ try:
290
+ loaded = json.loads(agents_json.read_text(encoding="utf-8"))
291
+ if isinstance(loaded, dict):
292
+ data = loaded
293
+ except Exception: # noqa: BLE001
294
+ pass
295
+ agents = data.setdefault("agents", {})
296
+ current = agents.get(prof_key) if isinstance(agents.get(prof_key), dict) else {}
297
+ agents[prof_key] = {**current, **patch, "profile": prof_key}
298
+ data["agents"] = agents
299
+ agents_json.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
300
+
301
+
302
+ def _avatar_id_for_display_name(display_name: str) -> str:
303
+ """Map a chosen agent name to catalog id when labels match (e.g. Ada → ada)."""
304
+ needle = str(display_name or "").strip().lower()
305
+ if not needle:
306
+ return ""
307
+ for row in _load_avatar_catalog().get("avatars") or []:
308
+ if not isinstance(row, dict):
309
+ continue
310
+ label = str(row.get("label") or "").strip().lower()
311
+ aid = str(row.get("id") or "").strip().lower()
312
+ if needle in (label, aid):
313
+ return str(row["id"])
314
+ return ""
315
+
316
+
317
+ def _assign_agent_avatar(profile: str, *, display_name: str = "") -> dict[str, str]:
318
+ """Pick catalog avatar and persist to agents.json + avatar-registry.json."""
319
+ prof = _srv().safe_profile_slug(profile)
320
+ native_slug = _srv().safe_profile_slug(str(_srv().NATIVE_PROFILE or "workframe-agent"))
321
+ name = str(display_name or "").strip() or str(_srv()._agent_registry_row(prof).get("display_name") or "").strip()
322
+ by_name = _avatar_id_for_display_name(name)
323
+ if by_name:
324
+ avatar_id = by_name
325
+ elif prof == native_slug:
326
+ avatar_id = "steve"
327
+ else:
328
+ avatar_id = _pick_avatar_id()
329
+ avatar_url = _avatar_url_for_id(avatar_id)
330
+ _upsert_agent_registry_row(
331
+ prof,
332
+ {
333
+ "avatar_id": avatar_id,
334
+ "avatar_url": avatar_url,
335
+ "updated_at": datetime.now(timezone.utc).isoformat(),
336
+ },
337
+ )
338
+ registry = _load_avatar_registry()
339
+ registry.setdefault("assignments", {})[prof] = avatar_id
340
+ weights = registry.setdefault("weights", {})
341
+ weights[avatar_id] = int(weights.get(avatar_id, 0)) + 1
342
+ _save_avatar_registry(registry)
343
+ return {"avatar_id": avatar_id, "avatar_url": avatar_url}
@@ -0,0 +1,164 @@
1
+ """Structured audit events for credential broker requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
14
+ WORKFRAME_DB = DATA_DIR / "workframe.db"
15
+
16
+ _SCHEMA_READY: set[str] = set()
17
+
18
+
19
+ def _connect() -> sqlite3.Connection:
20
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
21
+ conn = sqlite3.connect(str(WORKFRAME_DB), timeout=5.0)
22
+ conn.row_factory = sqlite3.Row
23
+ return conn
24
+
25
+
26
+ def ensure_schema() -> None:
27
+ key = str(WORKFRAME_DB)
28
+ if key in _SCHEMA_READY:
29
+ return
30
+ conn = _connect()
31
+ try:
32
+ conn.execute(
33
+ """
34
+ CREATE TABLE IF NOT EXISTS broker_audit_events (
35
+ id TEXT PRIMARY KEY,
36
+ created_at TEXT NOT NULL,
37
+ broker_kind TEXT NOT NULL,
38
+ user_id TEXT DEFAULT '',
39
+ workspace_id TEXT DEFAULT '',
40
+ profile_slug TEXT DEFAULT '',
41
+ run_id TEXT DEFAULT '',
42
+ provider TEXT NOT NULL,
43
+ upstream_host TEXT DEFAULT '',
44
+ status INTEGER NOT NULL,
45
+ deny_reason TEXT DEFAULT ''
46
+ )
47
+ """
48
+ )
49
+ conn.execute(
50
+ "CREATE INDEX IF NOT EXISTS idx_broker_audit_created "
51
+ "ON broker_audit_events(created_at DESC)"
52
+ )
53
+ conn.execute(
54
+ "CREATE INDEX IF NOT EXISTS idx_broker_audit_provider "
55
+ "ON broker_audit_events(provider, created_at DESC)"
56
+ )
57
+ conn.commit()
58
+ finally:
59
+ conn.close()
60
+ _SCHEMA_READY.add(key)
61
+
62
+
63
+ def record_broker_event(
64
+ *,
65
+ broker_kind: str,
66
+ provider: str,
67
+ upstream_host: str,
68
+ status: int,
69
+ deny_reason: str = "",
70
+ lease: dict[str, Any] | None = None,
71
+ ) -> None:
72
+ """Persist one broker hop — never raises."""
73
+ try:
74
+ ensure_schema()
75
+ meta = lease or {}
76
+ now = datetime.now(timezone.utc).isoformat()
77
+ conn = _connect()
78
+ try:
79
+ conn.execute(
80
+ """
81
+ INSERT INTO broker_audit_events (
82
+ id, created_at, broker_kind, user_id, workspace_id,
83
+ profile_slug, run_id, provider, upstream_host, status, deny_reason
84
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?)
85
+ """,
86
+ (
87
+ str(uuid.uuid4()),
88
+ now,
89
+ str(broker_kind or "").strip().lower(),
90
+ str(meta.get("payer_user_id") or ""),
91
+ str(meta.get("workspace_id") or ""),
92
+ str(meta.get("profile_slug") or ""),
93
+ str(meta.get("run_id") or ""),
94
+ str(provider or "").strip().lower(),
95
+ str(upstream_host or "").strip().lower(),
96
+ int(status),
97
+ str(deny_reason or "").strip(),
98
+ ),
99
+ )
100
+ conn.commit()
101
+ finally:
102
+ conn.close()
103
+ except Exception:
104
+ pass
105
+
106
+
107
+ def list_broker_events(*, limit: int = 100, provider: str = "") -> list[dict[str, Any]]:
108
+ """Recent broker events for deploy audit / diagnostics."""
109
+ ensure_schema()
110
+ limit = max(1, min(int(limit or 100), 500))
111
+ conn = _connect()
112
+ try:
113
+ if provider:
114
+ rows = conn.execute(
115
+ """
116
+ SELECT created_at, broker_kind, user_id, workspace_id, profile_slug,
117
+ run_id, provider, upstream_host, status, deny_reason
118
+ FROM broker_audit_events
119
+ WHERE provider = ?
120
+ ORDER BY created_at DESC
121
+ LIMIT ?
122
+ """,
123
+ (str(provider).strip().lower(), limit),
124
+ ).fetchall()
125
+ else:
126
+ rows = conn.execute(
127
+ """
128
+ SELECT created_at, broker_kind, user_id, workspace_id, profile_slug,
129
+ run_id, provider, upstream_host, status, deny_reason
130
+ FROM broker_audit_events
131
+ ORDER BY created_at DESC
132
+ LIMIT ?
133
+ """,
134
+ (limit,),
135
+ ).fetchall()
136
+ finally:
137
+ conn.close()
138
+ return [dict(row) for row in rows]
139
+
140
+
141
+ if __name__ == "__main__":
142
+ import tempfile
143
+
144
+ td = tempfile.mkdtemp()
145
+ os.environ["WORKFRAME_API_DATA_DIR"] = td
146
+ globals()["DATA_DIR"] = Path(td)
147
+ globals()["WORKFRAME_DB"] = Path(td) / "workframe.db"
148
+ _SCHEMA_READY.clear()
149
+ record_broker_event(
150
+ broker_kind="llm",
151
+ provider="openrouter",
152
+ upstream_host="openrouter.ai",
153
+ status=401,
154
+ deny_reason="expired",
155
+ lease={
156
+ "payer_user_id": "u1",
157
+ "workspace_id": "ws1",
158
+ "profile_slug": "u-u1-dev",
159
+ "run_id": "run-1",
160
+ },
161
+ )
162
+ events = list_broker_events(limit=5)
163
+ assert len(events) == 1 and events[0]["deny_reason"] == "expired"
164
+ print("broker_audit ok")