create-workframe 0.1.0 → 0.1.2

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 (198) hide show
  1. package/LICENSE +201 -201
  2. package/NOTICE +12 -12
  3. package/README.md +8 -92
  4. package/SECURITY.md +38 -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/bundle-workframe-ui.mjs +3 -3
  28. package/scripts/ensure-compose-host-paths.mjs +51 -51
  29. package/scripts/lib/install-identity.mjs +212 -212
  30. package/scripts/set-compose-public-url.mjs +92 -92
  31. package/scripts/sync-canonical-to-package.mjs +27 -9
  32. package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
  33. package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
  34. package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
  35. package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
  36. package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
  37. package/workframe-api/README.md +26 -28
  38. package/workframe-api/action_proxy.py +131 -131
  39. package/workframe-api/auth_rate_limit.py +49 -49
  40. package/workframe-api/credential_vault.py +445 -445
  41. package/workframe-api/data/avatar-catalog.json +41 -41
  42. package/workframe-api/email_sender.py +220 -220
  43. package/workframe-api/google_auth.py +90 -90
  44. package/workframe-api/install_api.py +359 -359
  45. package/workframe-api/internal_proxy_auth.py +150 -150
  46. package/workframe-api/llm_proxy.py +277 -277
  47. package/workframe-api/oidc_jwt.py +108 -108
  48. package/workframe-api/package.json +12 -13
  49. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  50. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  51. package/workframe-api/requirements.txt +2 -2
  52. package/workframe-api/site_meta.py +271 -271
  53. package/workframe-api/stack_config.py +427 -427
  54. package/workframe-api/time-bind-chat.py +99 -99
  55. package/workframe-api/turn_credentials.py +226 -226
  56. package/workframe-api/updates.py +417 -417
  57. package/workframe-api/vault_kek.py +159 -159
  58. package/workframe-api/zk_auth.py +633 -633
  59. package/workframe-supervisor/Dockerfile +11 -11
  60. package/workframe-supervisor/server.py +787 -787
  61. package/workframe-ui/docker/nginx.conf +85 -85
  62. package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
  63. package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
  64. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
  65. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
  66. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
  67. package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
  68. package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
  75. package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
  76. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
  77. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
  78. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
  79. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
  80. package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
  81. package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
  82. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
  83. package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
  84. package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
  85. package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
  86. package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
  87. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
  88. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
  89. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
  90. package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
  91. package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
  92. package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
  93. package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
  94. package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
  95. package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
  96. package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
  97. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
  98. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
  99. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
  100. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
  101. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
  102. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
  103. package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
  104. package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
  105. package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
  106. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
  107. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
  108. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
  109. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
  110. package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
  111. package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
  112. package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
  113. package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
  114. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
  115. package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
  116. package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
  117. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
  118. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
  119. package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
  120. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
  121. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
  122. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
  123. package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
  124. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
  125. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
  126. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
  127. package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
  128. package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
  129. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
  130. package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
  131. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
  132. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
  133. package/workframe-ui/public/favicon.svg +7 -7
  134. package/workframe-ui/public/index.html +50 -50
  135. package/workframe-ui/public/workframe-config.json +3 -3
  136. package/scripts/security_audit.py +0 -156
  137. package/scripts/test-scaffold.mjs +0 -390
  138. package/workframe-api/tests/__init__.py +0 -0
  139. package/workframe-api/tests/db_setup.py +0 -13
  140. package/workframe-api/tests/test_admin_updates_gated.py +0 -30
  141. package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
  142. package/workframe-api/tests/test_agent_profile_sync.py +0 -76
  143. package/workframe-api/tests/test_auth_email.py +0 -222
  144. package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
  145. package/workframe-api/tests/test_auth_rate_limit.py +0 -19
  146. package/workframe-api/tests/test_avatar_resolve.py +0 -77
  147. package/workframe-api/tests/test_child_soul_template.py +0 -71
  148. package/workframe-api/tests/test_credential_canary.py +0 -135
  149. package/workframe-api/tests/test_credential_isolation.py +0 -448
  150. package/workframe-api/tests/test_credential_resolution.py +0 -206
  151. package/workframe-api/tests/test_device_oauth.py +0 -108
  152. package/workframe-api/tests/test_doctor_repair.py +0 -103
  153. package/workframe-api/tests/test_ensure_profile_api.py +0 -77
  154. package/workframe-api/tests/test_gateway_compose_security.py +0 -136
  155. package/workframe-api/tests/test_install_secure_host.py +0 -39
  156. package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
  157. package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
  158. package/workframe-api/tests/test_kanban_delegation.py +0 -185
  159. package/workframe-api/tests/test_llm_proxy.py +0 -155
  160. package/workframe-api/tests/test_login_access_policy.py +0 -183
  161. package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
  162. package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
  163. package/workframe-api/tests/test_platform_auth.py +0 -47
  164. package/workframe-api/tests/test_profile_config_path.py +0 -56
  165. package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
  166. package/workframe-api/tests/test_profile_create.py +0 -72
  167. package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
  168. package/workframe-api/tests/test_profile_install_health.py +0 -45
  169. package/workframe-api/tests/test_profile_secret_policy.py +0 -57
  170. package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
  171. package/workframe-api/tests/test_provider_bootstrap.py +0 -75
  172. package/workframe-api/tests/test_provider_connect.py +0 -54
  173. package/workframe-api/tests/test_room_crud.py +0 -192
  174. package/workframe-api/tests/test_room_tenancy.py +0 -701
  175. package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
  176. package/workframe-api/tests/test_site_meta.py +0 -81
  177. package/workframe-api/tests/test_soul_stub.py +0 -42
  178. package/workframe-api/tests/test_space_member_sync.py +0 -99
  179. package/workframe-api/tests/test_stripe_stack_config.py +0 -37
  180. package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
  181. package/workframe-api/tests/test_turn_credential_vault.py +0 -125
  182. package/workframe-api/tests/test_updates.py +0 -176
  183. package/workframe-api/tests/test_user_cohort.py +0 -113
  184. package/workframe-api/tests/test_vault_envelope.py +0 -110
  185. package/workframe-api/tests/test_workspace_members.py +0 -183
  186. package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
  187. package/workframe-api/tests/test_workspace_provider_list.py +0 -57
  188. package/workframe-supervisor/tests/test_exec_guard.py +0 -42
  189. package/workframe-supervisor/tests/test_server_import.py +0 -21
  190. package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
  191. package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
  192. package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
  193. package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
  194. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
  195. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
  196. package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
  197. package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
  198. 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
+ }