create-workframe 0.1.0 → 0.1.1

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 (206) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +40 -40
  5. package/bin/workframe.js +329 -329
  6. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
  7. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
  8. package/package.json +3 -6
  9. package/profiles/architect/AGENTS.md +29 -29
  10. package/profiles/architect/SOUL.md +2 -2
  11. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  12. package/profiles/designer/AGENTS.md +26 -26
  13. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  14. package/profiles/dev/AGENTS.md +28 -28
  15. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  16. package/profiles/docs/AGENTS.md +27 -27
  17. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  18. package/profiles/research/AGENTS.md +26 -26
  19. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/visionary/AGENTS.md +25 -25
  21. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  22. package/profiles/workframe-agent/AGENTS.md +37 -37
  23. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  24. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  25. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  26. package/rules/workspace-README.md +5 -5
  27. package/scripts/apply-update-hermes.sh +17 -17
  28. package/scripts/apply-update-workframe.sh +77 -77
  29. package/scripts/bootstrap-workspace-link.sh +8 -8
  30. package/scripts/bundle-workframe-ui.mjs +3 -3
  31. package/scripts/compose-docker-host.sh +37 -37
  32. package/scripts/ensure-compose-host-paths.mjs +51 -51
  33. package/scripts/fix-zk-encryption-key.sh +35 -35
  34. package/scripts/lib/install-identity.mjs +212 -212
  35. package/scripts/restart-gateway-hermes.sh +12 -12
  36. package/scripts/set-compose-public-url.mjs +92 -92
  37. package/scripts/setup-stack-secrets.sh +50 -50
  38. package/scripts/sync-canonical-to-package.mjs +8 -7
  39. package/scripts/verify-public-deploy.sh +105 -105
  40. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  41. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  42. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  43. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  44. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  45. package/workframe-api/README.md +28 -28
  46. package/workframe-api/action_proxy.py +131 -131
  47. package/workframe-api/auth_rate_limit.py +49 -49
  48. package/workframe-api/credential_vault.py +445 -445
  49. package/workframe-api/data/avatar-catalog.json +41 -41
  50. package/workframe-api/email_sender.py +220 -220
  51. package/workframe-api/google_auth.py +90 -90
  52. package/workframe-api/install_api.py +359 -359
  53. package/workframe-api/internal_proxy_auth.py +150 -150
  54. package/workframe-api/llm_proxy.py +277 -277
  55. package/workframe-api/oidc_jwt.py +108 -108
  56. package/workframe-api/package.json +12 -13
  57. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  58. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  59. package/workframe-api/requirements.txt +2 -2
  60. package/workframe-api/site_meta.py +271 -271
  61. package/workframe-api/stack_config.py +427 -427
  62. package/workframe-api/time-bind-chat.py +99 -99
  63. package/workframe-api/turn_credentials.py +226 -226
  64. package/workframe-api/updates.py +417 -417
  65. package/workframe-api/vault_kek.py +159 -159
  66. package/workframe-api/zk_auth.py +633 -633
  67. package/workframe-supervisor/Dockerfile +11 -11
  68. package/workframe-supervisor/server.py +787 -787
  69. package/workframe-ui/docker/nginx.conf +85 -85
  70. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  71. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  72. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  73. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  74. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  75. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  76. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  81. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  84. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  85. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  86. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  87. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  88. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  89. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  90. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  91. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  92. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  93. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  94. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  95. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  96. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  97. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  98. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  99. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  100. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  101. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  102. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  103. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  104. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  105. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  106. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  107. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  108. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  109. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  110. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  111. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  112. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  113. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  114. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  115. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  116. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  117. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  118. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  119. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  120. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  121. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  122. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  123. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  124. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  125. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  126. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  127. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  128. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  129. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  130. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  131. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  132. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  133. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  134. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  135. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  136. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  137. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  138. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  139. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  140. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  141. package/workframe-ui/public/favicon.svg +7 -7
  142. package/workframe-ui/public/index.html +50 -50
  143. package/workframe-ui/public/workframe-config.json +3 -3
  144. package/scripts/security_audit.py +0 -156
  145. package/scripts/test-scaffold.mjs +0 -390
  146. package/workframe-api/tests/__init__.py +0 -0
  147. package/workframe-api/tests/db_setup.py +0 -13
  148. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  149. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  150. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  151. package/workframe-api/tests/test_auth_email.py +0 -222
  152. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  153. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  154. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  155. package/workframe-api/tests/test_child_soul_template.py +0 -71
  156. package/workframe-api/tests/test_credential_canary.py +0 -135
  157. package/workframe-api/tests/test_credential_isolation.py +0 -448
  158. package/workframe-api/tests/test_credential_resolution.py +0 -206
  159. package/workframe-api/tests/test_device_oauth.py +0 -108
  160. package/workframe-api/tests/test_doctor_repair.py +0 -103
  161. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  162. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  163. package/workframe-api/tests/test_install_secure_host.py +0 -39
  164. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  165. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  166. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  167. package/workframe-api/tests/test_llm_proxy.py +0 -155
  168. package/workframe-api/tests/test_login_access_policy.py +0 -183
  169. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  170. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  171. package/workframe-api/tests/test_platform_auth.py +0 -47
  172. package/workframe-api/tests/test_profile_config_path.py +0 -56
  173. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  174. package/workframe-api/tests/test_profile_create.py +0 -72
  175. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  176. package/workframe-api/tests/test_profile_install_health.py +0 -45
  177. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  178. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  179. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  180. package/workframe-api/tests/test_provider_connect.py +0 -54
  181. package/workframe-api/tests/test_room_crud.py +0 -192
  182. package/workframe-api/tests/test_room_tenancy.py +0 -701
  183. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  184. package/workframe-api/tests/test_site_meta.py +0 -81
  185. package/workframe-api/tests/test_soul_stub.py +0 -42
  186. package/workframe-api/tests/test_space_member_sync.py +0 -99
  187. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  188. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  189. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  190. package/workframe-api/tests/test_updates.py +0 -176
  191. package/workframe-api/tests/test_user_cohort.py +0 -113
  192. package/workframe-api/tests/test_vault_envelope.py +0 -110
  193. package/workframe-api/tests/test_workspace_members.py +0 -183
  194. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  195. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  196. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  197. package/workframe-supervisor/tests/test_server_import.py +0 -21
  198. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  199. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  200. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  201. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  202. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  203. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  204. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  205. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  206. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
@@ -1,427 +1,427 @@
1
- """Stack operator config (SMTP, deployment mode, install state). Env wins over file for VPS."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- from pathlib import Path
8
- from typing import Any
9
-
10
- DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
11
- CONFIG_PATH = DATA_DIR / "stack_config.json"
12
-
13
- DEPLOYMENT_MODES = frozenset({"single_user_local", "trusted_team", "public_multi_user"})
14
-
15
-
16
- def normalize_smtp_secure(port: int, secure: str) -> str:
17
- """Map provider port + secure hint to smtplib mode (ssl | starttls | none)."""
18
- s = str(secure or "").strip().lower()
19
- p = int(port or 587)
20
- if s in {"ssl", "smtps", "1", "true", "yes"}:
21
- return "ssl"
22
- if s in {"none", "off", "false", "0", "plain"}:
23
- return "none"
24
- if p == 465:
25
- return "ssl"
26
- if s in {"starttls", "tls"}:
27
- return "starttls"
28
- if p in {587, 2525}:
29
- return "starttls"
30
- return "starttls"
31
-
32
-
33
- def _read_raw() -> dict[str, Any]:
34
- if not CONFIG_PATH.is_file():
35
- return {}
36
- try:
37
- with open(CONFIG_PATH, encoding="utf-8") as f:
38
- data = json.load(f)
39
- return data if isinstance(data, dict) else {}
40
- except (OSError, json.JSONDecodeError):
41
- return {}
42
-
43
-
44
- def _write_raw(data: dict[str, Any]) -> None:
45
- DATA_DIR.mkdir(parents=True, exist_ok=True)
46
- tmp = CONFIG_PATH.with_suffix(".tmp")
47
- with open(tmp, "w", encoding="utf-8") as f:
48
- json.dump(data, f, indent=2, sort_keys=True)
49
- os.replace(tmp, CONFIG_PATH)
50
- try:
51
- os.chmod(CONFIG_PATH, 0o600)
52
- except OSError:
53
- pass
54
-
55
-
56
- def _stack_smtp_raw() -> dict[str, Any]:
57
- raw = _read_raw()
58
- smtp = raw.get("smtp")
59
- return smtp if isinstance(smtp, dict) else {}
60
-
61
-
62
- def _stack_oauth_raw(key: str) -> dict[str, Any]:
63
- block = _read_raw().get(key)
64
- return block if isinstance(block, dict) else {}
65
-
66
-
67
- def _oauth_public(block: dict[str, Any]) -> dict[str, Any]:
68
- client_id = str(block.get("client_id") or "").strip()
69
- return {
70
- "client_id": client_id,
71
- "has_secret": bool(str(block.get("client_secret") or "").strip()),
72
- "enabled": bool(client_id),
73
- }
74
-
75
-
76
- def resolved_google_oauth() -> dict[str, str]:
77
- go = _stack_oauth_raw("google_oauth")
78
- return {
79
- "client_id": str(go.get("client_id") or "").strip(),
80
- "client_secret": str(go.get("client_secret") or "").strip(),
81
- }
82
-
83
-
84
- def resolved_github_oauth() -> dict[str, str]:
85
- gh = _stack_oauth_raw("github_oauth")
86
- return {
87
- "client_id": str(gh.get("client_id") or "").strip(),
88
- "client_secret": str(gh.get("client_secret") or "").strip(),
89
- }
90
-
91
-
92
- def resolved_stripe_connect() -> dict[str, str]:
93
- st = _stack_oauth_raw("stripe_connect")
94
- return {
95
- "client_id": str(st.get("client_id") or "").strip(),
96
- "client_secret": str(st.get("client_secret") or "").strip(),
97
- }
98
-
99
-
100
- def github_oauth_for_workspace_settings(settings: dict[str, Any]) -> dict[str, Any]:
101
- """Copy install-time GitHub OAuth app creds into workspace settings when present."""
102
- gh = resolved_github_oauth()
103
- if gh.get("client_id") and gh.get("client_secret"):
104
- settings = dict(settings or {})
105
- settings["github_oauth"] = dict(gh)
106
- return settings
107
-
108
-
109
- def get_stack_config() -> dict[str, Any]:
110
- raw = _read_raw()
111
- smtp = raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {}
112
- return {
113
- "deployment_mode": str(raw.get("deployment_mode") or "").strip(),
114
- "app_base_url": str(raw.get("app_base_url") or "").strip(),
115
- "install_complete": bool(raw.get("install_complete")),
116
- "smtp": {
117
- "provider": str(smtp.get("provider") or "").strip(),
118
- "host": str(smtp.get("host") or "").strip(),
119
- "port": int(smtp.get("port") or 587),
120
- "user": str(smtp.get("user") or "").strip(),
121
- "from": str(smtp.get("from") or smtp.get("from_address") or "").strip(),
122
- "secure": str(smtp.get("secure") or "starttls").strip(),
123
- "has_password": bool(str(smtp.get("password") or "").strip()),
124
- },
125
- "google_oauth": _oauth_public(_stack_oauth_raw("google_oauth")),
126
- "github_oauth": _oauth_public(_stack_oauth_raw("github_oauth")),
127
- "discord_oauth": _oauth_public(_stack_oauth_raw("discord_oauth")),
128
- "telegram_login": _telegram_login_public(),
129
- "stripe_connect": _oauth_public(_stack_oauth_raw("stripe_connect")),
130
- }
131
-
132
-
133
- def _telegram_login_public() -> dict[str, Any]:
134
- block = _stack_oauth_raw("telegram_login")
135
- bot_username = str(block.get("bot_username") or "").strip().lstrip("@")
136
- return {
137
- "bot_username": bot_username,
138
- "has_token": bool(str(block.get("bot_token") or "").strip()),
139
- "enabled": bool(bot_username and str(block.get("bot_token") or "").strip()),
140
- }
141
-
142
-
143
- def patch_stack_config(body: dict[str, Any]) -> dict[str, Any]:
144
- raw = _read_raw()
145
- if "deployment_mode" in body:
146
- mode = str(body.get("deployment_mode") or "").strip().lower()
147
- if mode not in DEPLOYMENT_MODES:
148
- raise ValueError(f"invalid deployment_mode: {mode}")
149
- raw["deployment_mode"] = mode
150
- os.environ["WORKFRAME_DEPLOYMENT_MODE"] = mode
151
- if "app_base_url" in body:
152
- raw["app_base_url"] = str(body.get("app_base_url") or "").strip().rstrip("/")
153
- if body.get("install_complete") is True:
154
- raw["install_complete"] = True
155
- if "smtp" in body and isinstance(body["smtp"], dict):
156
- smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
157
- creds_changed = False
158
- for key in ("provider", "host", "user", "secure"):
159
- if key in body["smtp"]:
160
- val = str(body["smtp"][key] or "").strip()
161
- if str(smtp.get(key) or "").strip() != val:
162
- creds_changed = True
163
- smtp[key] = val
164
- if "port" in body["smtp"]:
165
- port_val = int(body["smtp"].get("port") or 587)
166
- if int(smtp.get("port") or 587) != port_val:
167
- creds_changed = True
168
- smtp["port"] = port_val
169
- if "password" in body["smtp"]:
170
- pw = str(body["smtp"].get("password") or "")
171
- if pw:
172
- creds_changed = True
173
- smtp["password"] = pw
174
- elif "pass" in body["smtp"]:
175
- pw = str(body["smtp"].get("pass") or "")
176
- if pw:
177
- creds_changed = True
178
- smtp["password"] = pw
179
- if "from" in body["smtp"] or "from_address" in body["smtp"]:
180
- from_val = str(
181
- body["smtp"].get("from") if "from" in body["smtp"] else body["smtp"].get("from_address") or ""
182
- ).strip()
183
- if from_val:
184
- smtp["from"] = from_val
185
- elif "from" in smtp:
186
- del smtp["from"]
187
- if "admin_email" in body["smtp"]:
188
- admin_email = str(body["smtp"].get("admin_email") or "").strip().lower()
189
- if admin_email:
190
- smtp["admin_email"] = admin_email
191
- elif "admin_email" in smtp:
192
- del smtp["admin_email"]
193
- if creds_changed:
194
- smtp.pop("tested", None)
195
- port = int(smtp.get("port") or 587)
196
- smtp["secure"] = normalize_smtp_secure(port, str(smtp.get("secure") or ""))
197
- raw["smtp"] = smtp
198
- if "google_oauth" in body and isinstance(body["google_oauth"], dict):
199
- go = raw.get("google_oauth") if isinstance(raw.get("google_oauth"), dict) else {}
200
- for key in ("client_id", "client_secret"):
201
- if key in body["google_oauth"]:
202
- val = str(body["google_oauth"].get(key) or "").strip()
203
- if val or key == "client_id":
204
- go[key] = val
205
- raw["google_oauth"] = go
206
- if "github_oauth" in body and isinstance(body["github_oauth"], dict):
207
- gh = raw.get("github_oauth") if isinstance(raw.get("github_oauth"), dict) else {}
208
- for key in ("client_id", "client_secret"):
209
- if key in body["github_oauth"]:
210
- val = str(body["github_oauth"].get(key) or "").strip()
211
- if val or key == "client_id":
212
- gh[key] = val
213
- raw["github_oauth"] = gh
214
- if "discord_oauth" in body and isinstance(body["discord_oauth"], dict):
215
- dc = raw.get("discord_oauth") if isinstance(raw.get("discord_oauth"), dict) else {}
216
- for key in ("client_id", "client_secret"):
217
- if key in body["discord_oauth"]:
218
- val = str(body["discord_oauth"].get(key) or "").strip()
219
- if val or key == "client_id":
220
- dc[key] = val
221
- raw["discord_oauth"] = dc
222
- if "telegram_login" in body and isinstance(body["telegram_login"], dict):
223
- tg = raw.get("telegram_login") if isinstance(raw.get("telegram_login"), dict) else {}
224
- if "bot_username" in body["telegram_login"]:
225
- tg["bot_username"] = str(body["telegram_login"].get("bot_username") or "").strip().lstrip("@")
226
- if "bot_token" in body["telegram_login"]:
227
- token = str(body["telegram_login"].get("bot_token") or "").strip()
228
- if token:
229
- tg["bot_token"] = token
230
- raw["telegram_login"] = tg
231
- if "stripe_connect" in body and isinstance(body["stripe_connect"], dict):
232
- st = raw.get("stripe_connect") if isinstance(raw.get("stripe_connect"), dict) else {}
233
- for key in ("client_id", "client_secret"):
234
- if key in body["stripe_connect"]:
235
- val = str(body["stripe_connect"].get(key) or "").strip()
236
- if val or key == "client_id":
237
- st[key] = val
238
- raw["stripe_connect"] = st
239
- if "site_branding" in body and isinstance(body["site_branding"], dict):
240
- block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
241
- for key in ("title", "description", "theme_color"):
242
- if key in body["site_branding"]:
243
- block[key] = str(body["site_branding"].get(key) or "").strip()
244
- raw["site_branding"] = block
245
- _write_raw(raw)
246
- return get_stack_config()
247
-
248
-
249
- def resolve_deployment_mode(env_default: str = "trusted_team") -> str:
250
- """Explicit WORKFRAME_DEPLOYMENT_MODE env wins; stack_config applies only when env is unset."""
251
- env_raw = (os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "").strip().lower()
252
- if env_raw in DEPLOYMENT_MODES:
253
- return env_raw
254
- if env_raw:
255
- return env_raw
256
- try:
257
- sc_mode = str(get_stack_config().get("deployment_mode") or "").strip().lower()
258
- if sc_mode in DEPLOYMENT_MODES:
259
- return sc_mode
260
- except Exception:
261
- pass
262
- raw = (env_default or "trusted_team").strip().lower()
263
- return raw if raw in DEPLOYMENT_MODES else "trusted_team"
264
-
265
-
266
- def effective_deployment_mode(env_default: str) -> str:
267
- return resolve_deployment_mode(env_default)
268
-
269
-
270
- def resolved_smtp() -> dict[str, Any]:
271
- """Env wins for host; secrets/from fall back to stack file when env omits them."""
272
- stack = _stack_smtp_raw()
273
- stack_host = str(stack.get("host") or "").strip()
274
- stack_user = str(stack.get("user") or "").strip()
275
- stack_pw = str(stack.get("password") or "").strip().replace(" ", "")
276
- stack_from = str(stack.get("from") or "").strip()
277
-
278
- env_host = os.environ.get("SMTP_HOST", "").strip()
279
- if env_host:
280
- port = int(os.environ.get("SMTP_PORT", "587"))
281
- secure = normalize_smtp_secure(
282
- port,
283
- os.environ.get("SMTP_SECURE", "").strip().lower(),
284
- )
285
- user = os.environ.get("SMTP_USER", "").strip() or stack_user
286
- password = (
287
- os.environ.get("SMTP_PASSWORD") or os.environ.get("SMTP_PASS") or stack_pw or ""
288
- ).strip().replace(" ", "")
289
- from_addr = (
290
- os.environ.get("SMTP_FROM") or os.environ.get("EMAIL_FROM") or stack_from or user or ""
291
- ).strip()
292
- return {
293
- "host": env_host,
294
- "port": port,
295
- "user": user,
296
- "password": password,
297
- "from": from_addr or user,
298
- "secure": secure,
299
- "source": "env",
300
- }
301
-
302
- if not stack_host:
303
- return {"source": "none"}
304
- port = int(stack.get("port") or 587)
305
- user = stack_user
306
- return {
307
- "host": stack_host,
308
- "port": port,
309
- "user": user,
310
- "password": stack_pw,
311
- "from": stack_from or user,
312
- "secure": normalize_smtp_secure(port, str(stack.get("secure") or "starttls")),
313
- "source": "stack_config",
314
- }
315
-
316
-
317
- def smtp_configured() -> bool:
318
- s = resolved_smtp()
319
- return bool(s.get("host") and s.get("source") != "none")
320
-
321
-
322
- def smtp_tested() -> bool:
323
- stack = _stack_smtp_raw()
324
- return bool(stack.get("tested"))
325
-
326
-
327
- def smtp_has_password() -> bool:
328
- stack = _stack_smtp_raw()
329
- if str(stack.get("password") or "").strip():
330
- return True
331
- resolved = resolved_smtp()
332
- return bool(str(resolved.get("password") or "").strip())
333
-
334
-
335
- def smtp_setup_complete() -> bool:
336
- """SMTP tested and admin email saved — wizard may skip re-test."""
337
- if not smtp_tested():
338
- return False
339
- stack = _stack_smtp_raw()
340
- return bool(str(stack.get("admin_email") or "").strip()) and smtp_has_password() and smtp_configured()
341
-
342
-
343
- def mark_smtp_tested() -> None:
344
- raw = _read_raw()
345
- smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
346
- smtp["tested"] = True
347
- raw["smtp"] = smtp
348
- _write_raw(raw)
349
-
350
-
351
- def _site_branding_public_payload() -> dict[str, Any]:
352
- try:
353
- import site_meta
354
-
355
- return site_meta.site_branding_public()
356
- except Exception:
357
- return {
358
- "title": "",
359
- "description": "",
360
- "theme_color": "",
361
- "has_og_image": False,
362
- "has_favicon": False,
363
- }
364
-
365
-
366
- def public_stack_payload() -> dict[str, Any]:
367
- cfg = get_stack_config()
368
- smtp = cfg.get("smtp") or {}
369
- go = cfg.get("google_oauth") or {}
370
- gh = cfg.get("github_oauth") or {}
371
- dc = cfg.get("discord_oauth") or {}
372
- tg = cfg.get("telegram_login") or {}
373
- st = cfg.get("stripe_connect") or {}
374
- app_base = str(cfg.get("app_base_url") or "").strip().rstrip("/")
375
- domain = ""
376
- if app_base:
377
- try:
378
- from urllib.parse import urlparse
379
-
380
- domain = (urlparse(app_base).hostname or "").strip()
381
- except Exception:
382
- domain = ""
383
- return {
384
- "deployment_mode": cfg.get("deployment_mode") or "",
385
- "app_base_url": cfg.get("app_base_url") or "",
386
- "install_complete": bool(cfg.get("install_complete")),
387
- "smtp": {
388
- "provider": smtp.get("provider") or "",
389
- "host": smtp.get("host") or "",
390
- "port": smtp.get("port") or 587,
391
- "user": smtp.get("user") or "",
392
- "from": smtp.get("from") or "",
393
- "admin_email": smtp.get("admin_email") or "",
394
- "secure": smtp.get("secure") or "starttls",
395
- "configured": smtp_configured(),
396
- "tested": smtp_tested(),
397
- "setup_complete": smtp_setup_complete(),
398
- "has_password": smtp_has_password(),
399
- },
400
- "google_oauth": {
401
- "client_id": go.get("client_id") or "",
402
- "has_secret": bool(go.get("has_secret")),
403
- "enabled": bool(go.get("enabled")),
404
- },
405
- "github_oauth": {
406
- "client_id": gh.get("client_id") or "",
407
- "has_secret": bool(gh.get("has_secret")),
408
- "enabled": bool(gh.get("enabled")),
409
- },
410
- "discord_oauth": {
411
- "client_id": dc.get("client_id") or "",
412
- "has_secret": bool(dc.get("has_secret")),
413
- "enabled": bool(dc.get("enabled")),
414
- },
415
- "telegram_login": {
416
- "bot_username": tg.get("bot_username") or "",
417
- "has_token": bool(tg.get("has_token")),
418
- "enabled": bool(tg.get("enabled")),
419
- "domain": domain,
420
- },
421
- "stripe_connect": {
422
- "client_id": st.get("client_id") or "",
423
- "has_secret": bool(st.get("has_secret")),
424
- "enabled": bool(st.get("enabled")),
425
- },
426
- "site_branding": _site_branding_public_payload(),
427
- }
1
+ """Stack operator config (SMTP, deployment mode, install state). Env wins over file for VPS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
11
+ CONFIG_PATH = DATA_DIR / "stack_config.json"
12
+
13
+ DEPLOYMENT_MODES = frozenset({"single_user_local", "trusted_team", "public_multi_user"})
14
+
15
+
16
+ def normalize_smtp_secure(port: int, secure: str) -> str:
17
+ """Map provider port + secure hint to smtplib mode (ssl | starttls | none)."""
18
+ s = str(secure or "").strip().lower()
19
+ p = int(port or 587)
20
+ if s in {"ssl", "smtps", "1", "true", "yes"}:
21
+ return "ssl"
22
+ if s in {"none", "off", "false", "0", "plain"}:
23
+ return "none"
24
+ if p == 465:
25
+ return "ssl"
26
+ if s in {"starttls", "tls"}:
27
+ return "starttls"
28
+ if p in {587, 2525}:
29
+ return "starttls"
30
+ return "starttls"
31
+
32
+
33
+ def _read_raw() -> dict[str, Any]:
34
+ if not CONFIG_PATH.is_file():
35
+ return {}
36
+ try:
37
+ with open(CONFIG_PATH, encoding="utf-8") as f:
38
+ data = json.load(f)
39
+ return data if isinstance(data, dict) else {}
40
+ except (OSError, json.JSONDecodeError):
41
+ return {}
42
+
43
+
44
+ def _write_raw(data: dict[str, Any]) -> None:
45
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
46
+ tmp = CONFIG_PATH.with_suffix(".tmp")
47
+ with open(tmp, "w", encoding="utf-8") as f:
48
+ json.dump(data, f, indent=2, sort_keys=True)
49
+ os.replace(tmp, CONFIG_PATH)
50
+ try:
51
+ os.chmod(CONFIG_PATH, 0o600)
52
+ except OSError:
53
+ pass
54
+
55
+
56
+ def _stack_smtp_raw() -> dict[str, Any]:
57
+ raw = _read_raw()
58
+ smtp = raw.get("smtp")
59
+ return smtp if isinstance(smtp, dict) else {}
60
+
61
+
62
+ def _stack_oauth_raw(key: str) -> dict[str, Any]:
63
+ block = _read_raw().get(key)
64
+ return block if isinstance(block, dict) else {}
65
+
66
+
67
+ def _oauth_public(block: dict[str, Any]) -> dict[str, Any]:
68
+ client_id = str(block.get("client_id") or "").strip()
69
+ return {
70
+ "client_id": client_id,
71
+ "has_secret": bool(str(block.get("client_secret") or "").strip()),
72
+ "enabled": bool(client_id),
73
+ }
74
+
75
+
76
+ def resolved_google_oauth() -> dict[str, str]:
77
+ go = _stack_oauth_raw("google_oauth")
78
+ return {
79
+ "client_id": str(go.get("client_id") or "").strip(),
80
+ "client_secret": str(go.get("client_secret") or "").strip(),
81
+ }
82
+
83
+
84
+ def resolved_github_oauth() -> dict[str, str]:
85
+ gh = _stack_oauth_raw("github_oauth")
86
+ return {
87
+ "client_id": str(gh.get("client_id") or "").strip(),
88
+ "client_secret": str(gh.get("client_secret") or "").strip(),
89
+ }
90
+
91
+
92
+ def resolved_stripe_connect() -> dict[str, str]:
93
+ st = _stack_oauth_raw("stripe_connect")
94
+ return {
95
+ "client_id": str(st.get("client_id") or "").strip(),
96
+ "client_secret": str(st.get("client_secret") or "").strip(),
97
+ }
98
+
99
+
100
+ def github_oauth_for_workspace_settings(settings: dict[str, Any]) -> dict[str, Any]:
101
+ """Copy install-time GitHub OAuth app creds into workspace settings when present."""
102
+ gh = resolved_github_oauth()
103
+ if gh.get("client_id") and gh.get("client_secret"):
104
+ settings = dict(settings or {})
105
+ settings["github_oauth"] = dict(gh)
106
+ return settings
107
+
108
+
109
+ def get_stack_config() -> dict[str, Any]:
110
+ raw = _read_raw()
111
+ smtp = raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {}
112
+ return {
113
+ "deployment_mode": str(raw.get("deployment_mode") or "").strip(),
114
+ "app_base_url": str(raw.get("app_base_url") or "").strip(),
115
+ "install_complete": bool(raw.get("install_complete")),
116
+ "smtp": {
117
+ "provider": str(smtp.get("provider") or "").strip(),
118
+ "host": str(smtp.get("host") or "").strip(),
119
+ "port": int(smtp.get("port") or 587),
120
+ "user": str(smtp.get("user") or "").strip(),
121
+ "from": str(smtp.get("from") or smtp.get("from_address") or "").strip(),
122
+ "secure": str(smtp.get("secure") or "starttls").strip(),
123
+ "has_password": bool(str(smtp.get("password") or "").strip()),
124
+ },
125
+ "google_oauth": _oauth_public(_stack_oauth_raw("google_oauth")),
126
+ "github_oauth": _oauth_public(_stack_oauth_raw("github_oauth")),
127
+ "discord_oauth": _oauth_public(_stack_oauth_raw("discord_oauth")),
128
+ "telegram_login": _telegram_login_public(),
129
+ "stripe_connect": _oauth_public(_stack_oauth_raw("stripe_connect")),
130
+ }
131
+
132
+
133
+ def _telegram_login_public() -> dict[str, Any]:
134
+ block = _stack_oauth_raw("telegram_login")
135
+ bot_username = str(block.get("bot_username") or "").strip().lstrip("@")
136
+ return {
137
+ "bot_username": bot_username,
138
+ "has_token": bool(str(block.get("bot_token") or "").strip()),
139
+ "enabled": bool(bot_username and str(block.get("bot_token") or "").strip()),
140
+ }
141
+
142
+
143
+ def patch_stack_config(body: dict[str, Any]) -> dict[str, Any]:
144
+ raw = _read_raw()
145
+ if "deployment_mode" in body:
146
+ mode = str(body.get("deployment_mode") or "").strip().lower()
147
+ if mode not in DEPLOYMENT_MODES:
148
+ raise ValueError(f"invalid deployment_mode: {mode}")
149
+ raw["deployment_mode"] = mode
150
+ os.environ["WORKFRAME_DEPLOYMENT_MODE"] = mode
151
+ if "app_base_url" in body:
152
+ raw["app_base_url"] = str(body.get("app_base_url") or "").strip().rstrip("/")
153
+ if body.get("install_complete") is True:
154
+ raw["install_complete"] = True
155
+ if "smtp" in body and isinstance(body["smtp"], dict):
156
+ smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
157
+ creds_changed = False
158
+ for key in ("provider", "host", "user", "secure"):
159
+ if key in body["smtp"]:
160
+ val = str(body["smtp"][key] or "").strip()
161
+ if str(smtp.get(key) or "").strip() != val:
162
+ creds_changed = True
163
+ smtp[key] = val
164
+ if "port" in body["smtp"]:
165
+ port_val = int(body["smtp"].get("port") or 587)
166
+ if int(smtp.get("port") or 587) != port_val:
167
+ creds_changed = True
168
+ smtp["port"] = port_val
169
+ if "password" in body["smtp"]:
170
+ pw = str(body["smtp"].get("password") or "")
171
+ if pw:
172
+ creds_changed = True
173
+ smtp["password"] = pw
174
+ elif "pass" in body["smtp"]:
175
+ pw = str(body["smtp"].get("pass") or "")
176
+ if pw:
177
+ creds_changed = True
178
+ smtp["password"] = pw
179
+ if "from" in body["smtp"] or "from_address" in body["smtp"]:
180
+ from_val = str(
181
+ body["smtp"].get("from") if "from" in body["smtp"] else body["smtp"].get("from_address") or ""
182
+ ).strip()
183
+ if from_val:
184
+ smtp["from"] = from_val
185
+ elif "from" in smtp:
186
+ del smtp["from"]
187
+ if "admin_email" in body["smtp"]:
188
+ admin_email = str(body["smtp"].get("admin_email") or "").strip().lower()
189
+ if admin_email:
190
+ smtp["admin_email"] = admin_email
191
+ elif "admin_email" in smtp:
192
+ del smtp["admin_email"]
193
+ if creds_changed:
194
+ smtp.pop("tested", None)
195
+ port = int(smtp.get("port") or 587)
196
+ smtp["secure"] = normalize_smtp_secure(port, str(smtp.get("secure") or ""))
197
+ raw["smtp"] = smtp
198
+ if "google_oauth" in body and isinstance(body["google_oauth"], dict):
199
+ go = raw.get("google_oauth") if isinstance(raw.get("google_oauth"), dict) else {}
200
+ for key in ("client_id", "client_secret"):
201
+ if key in body["google_oauth"]:
202
+ val = str(body["google_oauth"].get(key) or "").strip()
203
+ if val or key == "client_id":
204
+ go[key] = val
205
+ raw["google_oauth"] = go
206
+ if "github_oauth" in body and isinstance(body["github_oauth"], dict):
207
+ gh = raw.get("github_oauth") if isinstance(raw.get("github_oauth"), dict) else {}
208
+ for key in ("client_id", "client_secret"):
209
+ if key in body["github_oauth"]:
210
+ val = str(body["github_oauth"].get(key) or "").strip()
211
+ if val or key == "client_id":
212
+ gh[key] = val
213
+ raw["github_oauth"] = gh
214
+ if "discord_oauth" in body and isinstance(body["discord_oauth"], dict):
215
+ dc = raw.get("discord_oauth") if isinstance(raw.get("discord_oauth"), dict) else {}
216
+ for key in ("client_id", "client_secret"):
217
+ if key in body["discord_oauth"]:
218
+ val = str(body["discord_oauth"].get(key) or "").strip()
219
+ if val or key == "client_id":
220
+ dc[key] = val
221
+ raw["discord_oauth"] = dc
222
+ if "telegram_login" in body and isinstance(body["telegram_login"], dict):
223
+ tg = raw.get("telegram_login") if isinstance(raw.get("telegram_login"), dict) else {}
224
+ if "bot_username" in body["telegram_login"]:
225
+ tg["bot_username"] = str(body["telegram_login"].get("bot_username") or "").strip().lstrip("@")
226
+ if "bot_token" in body["telegram_login"]:
227
+ token = str(body["telegram_login"].get("bot_token") or "").strip()
228
+ if token:
229
+ tg["bot_token"] = token
230
+ raw["telegram_login"] = tg
231
+ if "stripe_connect" in body and isinstance(body["stripe_connect"], dict):
232
+ st = raw.get("stripe_connect") if isinstance(raw.get("stripe_connect"), dict) else {}
233
+ for key in ("client_id", "client_secret"):
234
+ if key in body["stripe_connect"]:
235
+ val = str(body["stripe_connect"].get(key) or "").strip()
236
+ if val or key == "client_id":
237
+ st[key] = val
238
+ raw["stripe_connect"] = st
239
+ if "site_branding" in body and isinstance(body["site_branding"], dict):
240
+ block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
241
+ for key in ("title", "description", "theme_color"):
242
+ if key in body["site_branding"]:
243
+ block[key] = str(body["site_branding"].get(key) or "").strip()
244
+ raw["site_branding"] = block
245
+ _write_raw(raw)
246
+ return get_stack_config()
247
+
248
+
249
+ def resolve_deployment_mode(env_default: str = "trusted_team") -> str:
250
+ """Explicit WORKFRAME_DEPLOYMENT_MODE env wins; stack_config applies only when env is unset."""
251
+ env_raw = (os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "").strip().lower()
252
+ if env_raw in DEPLOYMENT_MODES:
253
+ return env_raw
254
+ if env_raw:
255
+ return env_raw
256
+ try:
257
+ sc_mode = str(get_stack_config().get("deployment_mode") or "").strip().lower()
258
+ if sc_mode in DEPLOYMENT_MODES:
259
+ return sc_mode
260
+ except Exception:
261
+ pass
262
+ raw = (env_default or "trusted_team").strip().lower()
263
+ return raw if raw in DEPLOYMENT_MODES else "trusted_team"
264
+
265
+
266
+ def effective_deployment_mode(env_default: str) -> str:
267
+ return resolve_deployment_mode(env_default)
268
+
269
+
270
+ def resolved_smtp() -> dict[str, Any]:
271
+ """Env wins for host; secrets/from fall back to stack file when env omits them."""
272
+ stack = _stack_smtp_raw()
273
+ stack_host = str(stack.get("host") or "").strip()
274
+ stack_user = str(stack.get("user") or "").strip()
275
+ stack_pw = str(stack.get("password") or "").strip().replace(" ", "")
276
+ stack_from = str(stack.get("from") or "").strip()
277
+
278
+ env_host = os.environ.get("SMTP_HOST", "").strip()
279
+ if env_host:
280
+ port = int(os.environ.get("SMTP_PORT", "587"))
281
+ secure = normalize_smtp_secure(
282
+ port,
283
+ os.environ.get("SMTP_SECURE", "").strip().lower(),
284
+ )
285
+ user = os.environ.get("SMTP_USER", "").strip() or stack_user
286
+ password = (
287
+ os.environ.get("SMTP_PASSWORD") or os.environ.get("SMTP_PASS") or stack_pw or ""
288
+ ).strip().replace(" ", "")
289
+ from_addr = (
290
+ os.environ.get("SMTP_FROM") or os.environ.get("EMAIL_FROM") or stack_from or user or ""
291
+ ).strip()
292
+ return {
293
+ "host": env_host,
294
+ "port": port,
295
+ "user": user,
296
+ "password": password,
297
+ "from": from_addr or user,
298
+ "secure": secure,
299
+ "source": "env",
300
+ }
301
+
302
+ if not stack_host:
303
+ return {"source": "none"}
304
+ port = int(stack.get("port") or 587)
305
+ user = stack_user
306
+ return {
307
+ "host": stack_host,
308
+ "port": port,
309
+ "user": user,
310
+ "password": stack_pw,
311
+ "from": stack_from or user,
312
+ "secure": normalize_smtp_secure(port, str(stack.get("secure") or "starttls")),
313
+ "source": "stack_config",
314
+ }
315
+
316
+
317
+ def smtp_configured() -> bool:
318
+ s = resolved_smtp()
319
+ return bool(s.get("host") and s.get("source") != "none")
320
+
321
+
322
+ def smtp_tested() -> bool:
323
+ stack = _stack_smtp_raw()
324
+ return bool(stack.get("tested"))
325
+
326
+
327
+ def smtp_has_password() -> bool:
328
+ stack = _stack_smtp_raw()
329
+ if str(stack.get("password") or "").strip():
330
+ return True
331
+ resolved = resolved_smtp()
332
+ return bool(str(resolved.get("password") or "").strip())
333
+
334
+
335
+ def smtp_setup_complete() -> bool:
336
+ """SMTP tested and admin email saved — wizard may skip re-test."""
337
+ if not smtp_tested():
338
+ return False
339
+ stack = _stack_smtp_raw()
340
+ return bool(str(stack.get("admin_email") or "").strip()) and smtp_has_password() and smtp_configured()
341
+
342
+
343
+ def mark_smtp_tested() -> None:
344
+ raw = _read_raw()
345
+ smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
346
+ smtp["tested"] = True
347
+ raw["smtp"] = smtp
348
+ _write_raw(raw)
349
+
350
+
351
+ def _site_branding_public_payload() -> dict[str, Any]:
352
+ try:
353
+ import site_meta
354
+
355
+ return site_meta.site_branding_public()
356
+ except Exception:
357
+ return {
358
+ "title": "",
359
+ "description": "",
360
+ "theme_color": "",
361
+ "has_og_image": False,
362
+ "has_favicon": False,
363
+ }
364
+
365
+
366
+ def public_stack_payload() -> dict[str, Any]:
367
+ cfg = get_stack_config()
368
+ smtp = cfg.get("smtp") or {}
369
+ go = cfg.get("google_oauth") or {}
370
+ gh = cfg.get("github_oauth") or {}
371
+ dc = cfg.get("discord_oauth") or {}
372
+ tg = cfg.get("telegram_login") or {}
373
+ st = cfg.get("stripe_connect") or {}
374
+ app_base = str(cfg.get("app_base_url") or "").strip().rstrip("/")
375
+ domain = ""
376
+ if app_base:
377
+ try:
378
+ from urllib.parse import urlparse
379
+
380
+ domain = (urlparse(app_base).hostname or "").strip()
381
+ except Exception:
382
+ domain = ""
383
+ return {
384
+ "deployment_mode": cfg.get("deployment_mode") or "",
385
+ "app_base_url": cfg.get("app_base_url") or "",
386
+ "install_complete": bool(cfg.get("install_complete")),
387
+ "smtp": {
388
+ "provider": smtp.get("provider") or "",
389
+ "host": smtp.get("host") or "",
390
+ "port": smtp.get("port") or 587,
391
+ "user": smtp.get("user") or "",
392
+ "from": smtp.get("from") or "",
393
+ "admin_email": smtp.get("admin_email") or "",
394
+ "secure": smtp.get("secure") or "starttls",
395
+ "configured": smtp_configured(),
396
+ "tested": smtp_tested(),
397
+ "setup_complete": smtp_setup_complete(),
398
+ "has_password": smtp_has_password(),
399
+ },
400
+ "google_oauth": {
401
+ "client_id": go.get("client_id") or "",
402
+ "has_secret": bool(go.get("has_secret")),
403
+ "enabled": bool(go.get("enabled")),
404
+ },
405
+ "github_oauth": {
406
+ "client_id": gh.get("client_id") or "",
407
+ "has_secret": bool(gh.get("has_secret")),
408
+ "enabled": bool(gh.get("enabled")),
409
+ },
410
+ "discord_oauth": {
411
+ "client_id": dc.get("client_id") or "",
412
+ "has_secret": bool(dc.get("has_secret")),
413
+ "enabled": bool(dc.get("enabled")),
414
+ },
415
+ "telegram_login": {
416
+ "bot_username": tg.get("bot_username") or "",
417
+ "has_token": bool(tg.get("has_token")),
418
+ "enabled": bool(tg.get("enabled")),
419
+ "domain": domain,
420
+ },
421
+ "stripe_connect": {
422
+ "client_id": st.get("client_id") or "",
423
+ "has_secret": bool(st.get("has_secret")),
424
+ "enabled": bool(st.get("enabled")),
425
+ },
426
+ "site_branding": _site_branding_public_payload(),
427
+ }