create-workframe 0.1.12 → 0.1.14

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-BT9iZUSd.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-CaEDd3np.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-DHJhMrIS.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-Cdyz9hIV.js} +1 -1
  110. package/workframe-ui/public/assets/channel-3M69ekE5.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-BbZx76LX.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DcSxjTIP.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-B7F-kTY1.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-BwwEiRTK.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-DAi_NksX.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-BIaF4XCN.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-DjYSk1dG.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-_twnyfgV.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CkNmdy5s.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CjLqd-gl.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-_6oNqFjj.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-CeO9Am_D.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-sRM4YeIe.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-CH8tMb21.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-DAI1bsII.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-B2e-5a2G.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-DM2-0q37.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-BnbbewLv.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-C4zmB1Vt.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-n-BJmsiC.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-BzQGQnZi.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-Cx_De-wS.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-CIOI3Lok.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-DkRuPLWY.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-XhCT1Q-V.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-C3GwhDU1.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-CLWH8z2e.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D4h66Vy3.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-IW5F-Q6c.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-BjNIbmRM.js} +1 -1
  146. package/workframe-ui/public/assets/index-DL2vJP4L.css +1 -0
  147. package/workframe-ui/public/assets/index-ElS_D59P.js +130 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CcJspM79.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-DP4xR9jt.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-D4cZb21V.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-DsLuHkIJ.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Wu65nOsR.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-ByBDzRpy.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-DzXi2vnq.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-CaB-1Ckn.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-D7HTHC17.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BfKsC9aj.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-ClpU98TO.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-KeOyY_-u.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-CUpjnzbs.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-CJdPTszF.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-C9BpandZ.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-CHDZPwry.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-CMJJh91s.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-BsV4GppP.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-WHks82Up.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-eSX9P8z0.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Dg1UkhGo.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-B7yGsBuj.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-j_G2oyQ_.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-BRrT0jqC.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-DEdUqez2.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-C-bBK9Bb.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-DhH5xuR2.js} +1 -1
  176. package/workframe-ui/public/index.html +11 -7
  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,566 @@
1
+ """Auth gate: session, CORS/host validation, deployment mode (WF-032)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import re
9
+ import sqlite3
10
+ import time
11
+ import urllib.parse
12
+ import urllib.request
13
+ from http.server import BaseHTTPRequestHandler
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from email_sender import APP_BASE_URL
18
+ import internal_proxy_auth
19
+ import route_registry
20
+ import stack_config
21
+ import zk_auth as _zk
22
+
23
+ DASHBOARD_HTTP_TIMEOUT = float(os.environ.get("HERMES_DASHBOARD_HTTP_TIMEOUT", "15"))
24
+ WORKFRAME_AUTH_INSECURE = os.environ.get("WORKFRAME_AUTH_INSECURE", "0").strip() in {"1", "true", "yes"}
25
+ DOCKER_SOCK = os.environ.get("DOCKER_SOCK", "/var/run/docker.sock")
26
+
27
+ OWNER_ADMIN_ROLES = {"owner", "admin"}
28
+
29
+
30
+ def _srv():
31
+ import server as srv # ponytail: break circular import at module load
32
+
33
+ return srv
34
+
35
+
36
+ def _is_truthy(value: str | None) -> bool:
37
+ return (value or "").strip().lower() in {"1", "true", "yes", "on"}
38
+
39
+
40
+ def _dashboard_session_token(internal_url: str) -> str:
41
+ import http.cookiejar
42
+
43
+ jar = http.cookiejar.CookieJar()
44
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
45
+
46
+ def _fetch_html(url: str) -> str:
47
+ with opener.open(url, timeout=DASHBOARD_HTTP_TIMEOUT) as resp:
48
+ return resp.read().decode("utf-8", errors="replace")
49
+
50
+ def _token_from_html(html: str) -> str:
51
+ token_match = re.search(r"__HERMES_SESSION_TOKEN__=\"([^\"]+)\"", html)
52
+ return token_match.group(1) if token_match else ""
53
+
54
+ try:
55
+ html = _fetch_html(f"{internal_url}/")
56
+ except Exception as exc: # noqa: BLE001
57
+ raise ValueError(f"dashboard unavailable: {exc}") from exc
58
+
59
+ token = _token_from_html(html)
60
+ if token:
61
+ return token
62
+
63
+ user = os.environ.get("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "").strip()
64
+ password = os.environ.get("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "").strip()
65
+ if user and password:
66
+ body = json.dumps({"username": user, "password": password}).encode("utf-8")
67
+ req = urllib.request.Request(
68
+ f"{internal_url}/auth/password-login",
69
+ data=body,
70
+ headers={"Content-Type": "application/json"},
71
+ method="POST",
72
+ )
73
+ try:
74
+ opener.open(req, timeout=DASHBOARD_HTTP_TIMEOUT).read()
75
+ html = _fetch_html(f"{internal_url}/")
76
+ token = _token_from_html(html)
77
+ if token:
78
+ return token
79
+ except Exception as exc: # noqa: BLE001
80
+ raise ValueError(f"dashboard login failed: {exc}") from exc
81
+
82
+ raise ValueError("dashboard session token not found")
83
+
84
+
85
+ def _resolve_mode_flags() -> tuple[bool, bool]:
86
+ secure = _is_truthy(os.environ.get("SECURE_MODE"))
87
+ dev_unsafe = _is_truthy(os.environ.get("DEV_LOCAL_UNSAFE"))
88
+ if secure and dev_unsafe:
89
+ raise SystemExit(
90
+ "SECURE_MODE and DEV_LOCAL_UNSAFE are mutually exclusive. "
91
+ "Set one or neither (neither defaults to secure)."
92
+ )
93
+ if not secure and not dev_unsafe:
94
+ secure = True
95
+ return secure, dev_unsafe
96
+
97
+
98
+ def _require_auth_token() -> str:
99
+ token = os.environ.get("WORKFRAME_API_TOKEN", "").strip()
100
+ if token:
101
+ return token
102
+ dashboard_url = os.environ.get("HERMES_DASHBOARD_URL", "http://127.0.0.1:9119")
103
+ return _dashboard_session_token(dashboard_url)
104
+
105
+
106
+ SECURE_MODE, DEV_LOCAL_UNSAFE = _resolve_mode_flags()
107
+ DEPLOYMENT_MODES = frozenset({"single_user_local", "trusted_team", "public_multi_user"})
108
+
109
+
110
+ def _resolve_deployment_mode() -> str:
111
+ mode = stack_config.resolve_deployment_mode("trusted_team")
112
+ if mode not in DEPLOYMENT_MODES:
113
+ raise SystemExit(
114
+ f"Invalid WORKFRAME_DEPLOYMENT_MODE={mode!r}. "
115
+ f"Use one of: {', '.join(sorted(DEPLOYMENT_MODES))}"
116
+ )
117
+ return mode
118
+
119
+
120
+ DEPLOYMENT_MODE = _resolve_deployment_mode()
121
+
122
+
123
+ def _invite_only_login_enforced() -> bool:
124
+ if DEPLOYMENT_MODE == "single_user_local" or DEV_LOCAL_UNSAFE:
125
+ return False
126
+ return not _srv()._install_window_open()
127
+
128
+
129
+ def _primary_workspace_row(conn: sqlite3.Connection) -> sqlite3.Row | None:
130
+ return conn.execute(
131
+ """
132
+ SELECT id, display_name FROM workspaces
133
+ WHERE deleted_at IS NULL
134
+ ORDER BY CASE WHEN slug = 'default' THEN 0 ELSE 1 END, created_at ASC
135
+ LIMIT 1
136
+ """,
137
+ ).fetchone()
138
+
139
+
140
+ def _pending_invite_for_email(conn: sqlite3.Connection, workspace_id: str, email: str) -> sqlite3.Row | None:
141
+ now_ts = int(time.time())
142
+ return conn.execute(
143
+ """
144
+ SELECT id FROM workspace_invites
145
+ WHERE workspace_id = ? AND LOWER(email) = ? AND deleted_at IS NULL
146
+ AND accepted_at IS NULL AND CAST(expires_at AS INTEGER) > ?
147
+ LIMIT 1
148
+ """,
149
+ (workspace_id, str(email or "").strip().lower(), now_ts),
150
+ ).fetchone()
151
+
152
+
153
+ def email_allowed_to_authenticate(email: str) -> tuple[bool, dict[str, Any]]:
154
+ normalized = str(email or "").strip().lower()
155
+ if not normalized or "@" not in normalized:
156
+ return False, {"error": "email required"}
157
+ if not _invite_only_login_enforced():
158
+ return True, {}
159
+ conn = _srv()._workframe_db()
160
+ try:
161
+ ws = _primary_workspace_row(conn)
162
+ if not ws:
163
+ return True, {}
164
+ ws_id = str(ws["id"])
165
+ user_count = int(conn.execute("SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").fetchone()[0] or 0)
166
+ if user_count == 0:
167
+ return True, {}
168
+ member = conn.execute(
169
+ """
170
+ SELECT 1 FROM workspace_memberships wm
171
+ JOIN users u ON u.id = wm.user_id
172
+ WHERE wm.workspace_id = ? AND wm.deleted_at IS NULL AND wm.status = 'active'
173
+ AND LOWER(u.email) = ?
174
+ LIMIT 1
175
+ """,
176
+ (ws_id, normalized),
177
+ ).fetchone()
178
+ if member:
179
+ return True, {}
180
+ if _pending_invite_for_email(conn, ws_id, normalized):
181
+ return True, {}
182
+ display = str(ws["display_name"] or "this workspace")
183
+ return False, {
184
+ "error": "private_workspace",
185
+ "workspace_name": display,
186
+ "message": (
187
+ f'This Workframe is private. Contact the Admin of "{display}" to get an invite link.'
188
+ ),
189
+ }
190
+ finally:
191
+ conn.close()
192
+
193
+
194
+ def invite_token_allows_email(invite_token: str, email: str) -> bool:
195
+ token = str(invite_token or "").strip()
196
+ if not token:
197
+ return False
198
+ token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
199
+ normalized = str(email or "").strip().lower()
200
+ conn = _srv()._workframe_db()
201
+ try:
202
+ inv = conn.execute(
203
+ """
204
+ SELECT email, expires_at FROM workspace_invites
205
+ WHERE token_hash = ? AND deleted_at IS NULL AND accepted_at IS NULL
206
+ LIMIT 1
207
+ """,
208
+ (token_hash,),
209
+ ).fetchone()
210
+ if not inv or str(inv["email"]).strip().lower() != normalized:
211
+ return False
212
+ return int(inv["expires_at"]) > int(time.time())
213
+ finally:
214
+ conn.close()
215
+
216
+
217
+ def deployment_security_errors() -> list[str]:
218
+ errors: list[str] = []
219
+ if DEPLOYMENT_MODE != "public_multi_user":
220
+ return errors
221
+ if _srv()._install_window_open():
222
+ return errors
223
+ if DEV_LOCAL_UNSAFE:
224
+ errors.append("public_multi_user cannot run with DEV_LOCAL_UNSAFE")
225
+ if not SECURE_MODE:
226
+ errors.append("public_multi_user requires SECURE_MODE=true")
227
+ if not _srv()._supervisor_ready():
228
+ errors.append("public_multi_user requires WORKFRAME_SUPERVISOR_URL and WORKFRAME_SUPERVISOR_TOKEN")
229
+ for key in ("ZK_AUTH_HMAC_KEY", "ZK_AUTH_ENCRYPTION_KEY", "ZK_AUTH_SESSION_SECRET"):
230
+ if not os.environ.get(key, "").strip():
231
+ errors.append(f"public_multi_user requires {key}")
232
+ if not os.environ.get("WORKFRAME_API_TOKEN", "").strip():
233
+ errors.append("public_multi_user requires WORKFRAME_API_TOKEN")
234
+ if not stack_config.smtp_configured():
235
+ errors.append("public_multi_user requires SMTP_HOST")
236
+ base = str(stack_config.get_stack_config().get("app_base_url") or APP_BASE_URL).strip()
237
+ if not base.lower().startswith("https://"):
238
+ errors.append("public_multi_user requires HTTPS APP_BASE_URL")
239
+ if not os.environ.get("WORKFRAME_VAULT_KEK", "").strip():
240
+ errors.append("public_multi_user requires WORKFRAME_VAULT_KEK")
241
+ data_dir = _srv().DATA_DIR
242
+ if not os.environ.get("WORKFRAME_PROXY_TOKEN", "").strip() and not (
243
+ data_dir / internal_proxy_auth.PROXY_TOKEN_FILE
244
+ ).is_file():
245
+ errors.append("public_multi_user requires WORKFRAME_PROXY_TOKEN")
246
+ if Path(DOCKER_SOCK).exists():
247
+ errors.append("public_multi_user must not mount docker.sock on workframe-api")
248
+ return errors
249
+
250
+
251
+ def deployment_security_warnings() -> list[str]:
252
+ warnings: list[str] = []
253
+ if DEPLOYMENT_MODE == "trusted_team" and DEV_LOCAL_UNSAFE:
254
+ warnings.append("trusted_team + DEV_LOCAL_UNSAFE: session and dashboard gates are relaxed for local dev")
255
+ return warnings
256
+
257
+
258
+ CORS_ALLOW_ORIGINS = tuple(
259
+ origin.strip()
260
+ for origin in os.environ.get("CORS_ALLOW_ORIGIN", "").split(",")
261
+ if origin.strip() and origin.strip() != "*"
262
+ )
263
+ ALLOWED_HOSTS = tuple(
264
+ host.strip().lower()
265
+ for host in os.environ.get("ALLOWED_HOSTS", "").split(",")
266
+ if host.strip()
267
+ )
268
+ DEFAULT_ALLOWED_HOSTS = {"127.0.0.1", "localhost", "::1", "0.0.0.0"}
269
+
270
+
271
+ def session_cookie_secure() -> bool:
272
+ if DEV_LOCAL_UNSAFE or WORKFRAME_AUTH_INSECURE:
273
+ return False
274
+ if os.environ.get("WORKFRAME_E2E") == "1":
275
+ base = APP_BASE_URL.strip().lower()
276
+ if base.startswith("http://127.0.0.1") or base.startswith("http://localhost"):
277
+ return False
278
+ return APP_BASE_URL.strip().lower().startswith("https://")
279
+
280
+
281
+ AUTH_TOKEN: str = ""
282
+
283
+
284
+ def get_auth_token() -> str:
285
+ global AUTH_TOKEN
286
+ if not AUTH_TOKEN:
287
+ try:
288
+ AUTH_TOKEN = _require_auth_token()
289
+ except Exception as exc:
290
+ raise SystemExit(f"SECURE_MODE: failed to obtain auth token: {exc}") from exc
291
+ return AUTH_TOKEN
292
+
293
+
294
+ def check_auth(request_handler) -> bool:
295
+ if DEV_LOCAL_UNSAFE:
296
+ return True
297
+ if getattr(request_handler, "auth_user", None):
298
+ return True
299
+ auth_header = request_handler.headers.get("Authorization", "")
300
+ if auth_header.startswith("Bearer "):
301
+ token = get_auth_token()
302
+ if token and hmac.compare_digest(auth_header[7:].strip(), token):
303
+ request_handler.auth_user = "service"
304
+ request_handler.auth_role = "owner" # type: ignore[attr-defined]
305
+ return True
306
+ sid = session_id_from_request(request_handler)
307
+ if sid:
308
+ session_info = _zk.validate_session_token(sid)
309
+ if session_info:
310
+ apply_session_user(request_handler, session_info["user_id"])
311
+ return True
312
+ return False
313
+
314
+
315
+ def host_without_port(host: str) -> str:
316
+ host = (host or "").strip().lower()
317
+ if not host:
318
+ return ""
319
+ if host.startswith("["):
320
+ end = host.find("]")
321
+ return host[1:end] if end != -1 else host
322
+ if host.count(":") == 1:
323
+ return host.rsplit(":", 1)[0]
324
+ return host
325
+
326
+
327
+ def allowed_hosts() -> set[str]:
328
+ configured = {host.strip().lower() for host in ALLOWED_HOSTS if host.strip()}
329
+ if configured:
330
+ return configured | DEFAULT_ALLOWED_HOSTS
331
+ return set(DEFAULT_ALLOWED_HOSTS)
332
+
333
+
334
+ def loopback_ui_origins() -> set[str]:
335
+ port = str(os.environ.get("WORKFRAME_UI_PORT", "18644") or "18644").strip()
336
+ return {f"http://{host}:{port}" for host in ("127.0.0.1", "localhost", "[::1]")}
337
+
338
+
339
+ def install_setup_route(method: str, path: str) -> bool:
340
+ if path == "/api/install/stack" and method in ("GET", "PATCH"):
341
+ return True
342
+ if path == "/api/install/status" and method == "GET":
343
+ return True
344
+ if path == "/api/install/publish-hints" and method == "GET":
345
+ return True
346
+ if path == "/api/install/url/test" and method in ("GET", "POST"):
347
+ return True
348
+ if method == "POST" and path in (
349
+ "/api/install/email/test",
350
+ "/api/install/complete",
351
+ "/api/install/setup-https",
352
+ ):
353
+ return True
354
+ return False
355
+
356
+
357
+ def secure_host_origin_ok(method: str, path: str, headers) -> bool:
358
+ if DEV_LOCAL_UNSAFE:
359
+ return True
360
+ if _srv()._install_window_open() and install_setup_route(method, path):
361
+ return True
362
+ return validate_host(headers) and validate_origin(headers)
363
+
364
+
365
+ def validate_host(headers) -> bool:
366
+ if DEV_LOCAL_UNSAFE:
367
+ return True
368
+ return host_without_port(headers.get("Host", "")) in allowed_hosts()
369
+
370
+
371
+ def origin_host(origin: str) -> str:
372
+ parsed = urllib.parse.urlsplit(origin)
373
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
374
+ return ""
375
+ return parsed.hostname.lower()
376
+
377
+
378
+ def origin_allowed(origin: str) -> bool:
379
+ if not origin:
380
+ return True
381
+ origin = origin.strip()
382
+ if origin in CORS_ALLOW_ORIGINS:
383
+ return True
384
+ if origin.rstrip("/") in loopback_ui_origins():
385
+ return True
386
+ if not CORS_ALLOW_ORIGINS:
387
+ return origin_host(origin) in allowed_hosts()
388
+ return False
389
+
390
+
391
+ def validate_origin(headers) -> bool:
392
+ if DEV_LOCAL_UNSAFE:
393
+ return True
394
+ return origin_allowed(headers.get("Origin", ""))
395
+
396
+
397
+ def validate_host_origin(headers) -> bool:
398
+ return validate_host(headers) and validate_origin(headers)
399
+
400
+
401
+ def admin_write_allowed(handler: BaseHTTPRequestHandler) -> bool:
402
+ if SECURE_MODE or not DEV_LOCAL_UNSAFE:
403
+ return True
404
+ return handler.headers.get("X-Workframe-Local", "").strip() == "1"
405
+
406
+
407
+ def cors_origin_for(headers=None) -> str:
408
+ if DEV_LOCAL_UNSAFE:
409
+ return "*"
410
+ if not CORS_ALLOW_ORIGINS:
411
+ return ""
412
+ origin = ""
413
+ if headers is not None:
414
+ origin = headers.get("Origin", "").strip()
415
+ if origin:
416
+ return origin if origin in CORS_ALLOW_ORIGINS else ""
417
+ if len(CORS_ALLOW_ORIGINS) == 1:
418
+ return CORS_ALLOW_ORIGINS[0]
419
+ return ""
420
+
421
+
422
+ def deployment_allows_sessionless_data_get(
423
+ *,
424
+ deployment_mode: str | None = None,
425
+ dev_local_unsafe: bool | None = None,
426
+ ) -> bool:
427
+ mode = deployment_mode if deployment_mode is not None else _resolve_deployment_mode()
428
+ dev = DEV_LOCAL_UNSAFE if dev_local_unsafe is None else dev_local_unsafe
429
+ return route_registry.deployment_allows_sessionless_data_get(mode, dev_local_unsafe=dev)
430
+
431
+
432
+ def sessionless_get_allowed(
433
+ path: str,
434
+ *,
435
+ deployment_mode: str | None = None,
436
+ dev_local_unsafe: bool | None = None,
437
+ ) -> bool:
438
+ mode = deployment_mode if deployment_mode is not None else _resolve_deployment_mode()
439
+ dev = DEV_LOCAL_UNSAFE if dev_local_unsafe is None else dev_local_unsafe
440
+ return route_registry.sessionless_get_allowed(path, deployment_mode=mode, dev_local_unsafe=dev)
441
+
442
+
443
+ def is_public_get(path: str) -> bool:
444
+ if path in ("", "/"):
445
+ return True
446
+ if path.startswith("/static/") or path.startswith("/assets/"):
447
+ return True
448
+ return sessionless_get_allowed(path)
449
+
450
+
451
+ def cookie_session_id(handler: BaseHTTPRequestHandler) -> str:
452
+ cookie_header = handler.headers.get("Cookie", "")
453
+ prefix = f"{_zk.session_cookie_name()}="
454
+ legacy_prefix = "wf_session="
455
+ for part in cookie_header.split(";"):
456
+ part = part.strip()
457
+ if part.startswith(prefix):
458
+ return part[len(prefix) :]
459
+ if part.startswith(legacy_prefix):
460
+ return part[len(legacy_prefix) :]
461
+ return ""
462
+
463
+
464
+ def session_id_from_request(handler: BaseHTTPRequestHandler) -> str:
465
+ sid = cookie_session_id(handler)
466
+ if sid:
467
+ return sid
468
+ return handler.headers.get("X-Workframe-Session", "").strip()
469
+
470
+
471
+ def workspace_role_for_user(user_id: str) -> str:
472
+ rank = {"owner": 3, "admin": 2, "member": 1}
473
+ best = "member"
474
+ try:
475
+ conn = _srv()._workframe_db()
476
+ rows = conn.execute(
477
+ """
478
+ SELECT role FROM workspace_memberships
479
+ WHERE user_id = ? AND deleted_at IS NULL AND status = 'active'
480
+ """,
481
+ (user_id,),
482
+ ).fetchall()
483
+ conn.close()
484
+ except Exception:
485
+ return best
486
+ for row in rows:
487
+ role = str(row["role"] or "member")
488
+ if rank.get(role, 0) > rank.get(best, 0):
489
+ best = role
490
+ return best
491
+
492
+
493
+ def apply_session_user(handler: BaseHTTPRequestHandler, user_id: str) -> None:
494
+ handler.auth_user = user_id # type: ignore[attr-defined]
495
+ handler.auth_role = workspace_role_for_user(user_id) # type: ignore[attr-defined]
496
+
497
+
498
+ def user_is_stack_operator(user_id: str) -> bool:
499
+ return workspace_role_for_user(user_id) in OWNER_ADMIN_ROLES
500
+
501
+
502
+ def user_can_access_hermes_dashboard(user_id: str) -> bool:
503
+ if DEPLOYMENT_MODE == "single_user_local":
504
+ return True
505
+ if not user_id:
506
+ return False
507
+ return workspace_role_for_user(user_id) in OWNER_ADMIN_ROLES
508
+
509
+
510
+ def authorize_request(handler: BaseHTTPRequestHandler) -> bool:
511
+ path = urllib.parse.urlparse(handler.path).path
512
+ method = handler.command
513
+ sid = session_id_from_request(handler)
514
+
515
+ def _validate(sid_token: str):
516
+ return _zk.validate_session_token(sid_token)
517
+
518
+ def _attach(user_id: str) -> None:
519
+ apply_session_user(handler, user_id)
520
+
521
+ return route_registry.authorize_request(
522
+ method,
523
+ path,
524
+ sid,
525
+ deployment_mode=_resolve_deployment_mode(),
526
+ dev_local_unsafe=DEV_LOCAL_UNSAFE,
527
+ install_window_open=_srv()._install_window_open(),
528
+ validate_session=_validate,
529
+ attach_user=_attach,
530
+ get_workspace_role=lambda: str(getattr(handler, "auth_role", "") or ""),
531
+ )
532
+
533
+
534
+ def role_allows(handler: BaseHTTPRequestHandler, roles: set[str]) -> bool:
535
+ if not SECURE_MODE:
536
+ return True
537
+ return str(getattr(handler, "auth_role", "")) in roles
538
+
539
+
540
+ def handler_is_active_workspace_member(handler: BaseHTTPRequestHandler, workspace_id: str = "") -> bool:
541
+ if not SECURE_MODE:
542
+ return True
543
+ if role_allows(handler, OWNER_ADMIN_ROLES):
544
+ return True
545
+ user_id = str(getattr(handler, "auth_user", "") or "").strip()
546
+ if not user_id:
547
+ return False
548
+ try:
549
+ conn = _srv()._workframe_db()
550
+ try:
551
+ ws_id = str(workspace_id or "").strip()
552
+ if ws_id:
553
+ return _srv()._workspace_member_role(conn, ws_id, user_id) is not None
554
+ row = conn.execute(
555
+ """
556
+ SELECT 1 FROM workspace_memberships
557
+ WHERE user_id = ? AND deleted_at IS NULL AND status = 'active'
558
+ LIMIT 1
559
+ """,
560
+ (user_id,),
561
+ ).fetchone()
562
+ return row is not None
563
+ finally:
564
+ conn.close()
565
+ except sqlite3.Error:
566
+ return False