create-workframe 0.1.5 → 0.1.7

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 (185) hide show
  1. package/.dockerignore +22 -22
  2. package/.gitignore +73 -73
  3. package/LICENSE +201 -201
  4. package/NOTICE +12 -12
  5. package/README.md +40 -40
  6. package/SECURITY.md +38 -38
  7. package/bin/create-workframe.js +2816 -2814
  8. package/bin/workframe.js +329 -329
  9. package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -20
  10. package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -20
  11. package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -20
  12. package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -21
  13. package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -29
  14. package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -19
  15. package/package.json +64 -64
  16. package/profiles/README.md +15 -15
  17. package/profiles/architect/AGENTS.md +29 -29
  18. package/profiles/architect/SOUL.md +44 -44
  19. package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
  20. package/profiles/designer/AGENTS.md +26 -26
  21. package/profiles/designer/SOUL.md +31 -31
  22. package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
  23. package/profiles/dev/AGENTS.md +28 -28
  24. package/profiles/dev/SOUL.md +31 -31
  25. package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
  26. package/profiles/docs/AGENTS.md +27 -27
  27. package/profiles/docs/SOUL.md +30 -30
  28. package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
  29. package/profiles/research/AGENTS.md +26 -26
  30. package/profiles/research/SOUL.md +31 -31
  31. package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
  32. package/profiles/visionary/AGENTS.md +25 -25
  33. package/profiles/visionary/SOUL.md +31 -31
  34. package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
  35. package/profiles/workframe-agent/AGENTS.md +37 -37
  36. package/profiles/workframe-agent/SETUP.md +185 -185
  37. package/profiles/workframe-agent/SOUL.md +61 -61
  38. package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
  39. package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
  40. package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
  41. package/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -16
  42. package/rules/.hermes.md +11 -11
  43. package/rules/AGENTS.md +22 -22
  44. package/rules/workspace-README.md +5 -5
  45. package/scripts/apply-update-workframe.sh +91 -21
  46. package/scripts/bundle-workframe-ui.mjs +83 -83
  47. package/scripts/create_workframe_scaffold.py +648 -648
  48. package/scripts/ensure-compose-host-paths.mjs +51 -51
  49. package/scripts/lib/install-identity.mjs +212 -212
  50. package/scripts/lib/workframe-registry.mjs +290 -290
  51. package/scripts/new-project.mjs +68 -68
  52. package/scripts/select_agent_pack.py +31 -31
  53. package/scripts/set-compose-public-url.mjs +92 -92
  54. package/scripts/sync-canonical-to-package.mjs +164 -164
  55. package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -31
  56. package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -29
  57. package/shared/WORKFRAME_AGENT_PACKS.json +64 -64
  58. package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -20
  59. package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -20
  60. package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -25
  61. package/shared/WORKFRAME_SKILL_CURATION.md +27 -27
  62. package/workframe-api/Dockerfile +14 -14
  63. package/workframe-api/README.md +24 -24
  64. package/workframe-api/action_proxy.py +131 -131
  65. package/workframe-api/auth_rate_limit.py +49 -49
  66. package/workframe-api/credential_vault.py +445 -445
  67. package/workframe-api/data/avatar-catalog.json +41 -41
  68. package/workframe-api/data/logo-catalog.json +14 -14
  69. package/workframe-api/data/user-avatar-catalog.json +18 -18
  70. package/workframe-api/email_sender.py +220 -220
  71. package/workframe-api/google_auth.py +90 -90
  72. package/workframe-api/install_api.py +359 -359
  73. package/workframe-api/internal_proxy_auth.py +150 -150
  74. package/workframe-api/llm_proxy.py +280 -280
  75. package/workframe-api/oidc_jwt.py +108 -108
  76. package/workframe-api/package.json +12 -12
  77. package/workframe-api/platform_auth.py +194 -194
  78. package/workframe-api/profile_secret_policy.py +86 -86
  79. package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
  80. package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
  81. package/workframe-api/public/assets/index-DglUqFB_.js +8 -8
  82. package/workframe-api/public/index.html +12 -12
  83. package/workframe-api/requirements.txt +2 -2
  84. package/workframe-api/server.py +20295 -19866
  85. package/workframe-api/site_meta.py +271 -271
  86. package/workframe-api/stack_config.py +435 -427
  87. package/workframe-api/test_ensure_native_profile.py +29 -0
  88. package/workframe-api/test_oauth_llm.py +58 -0
  89. package/workframe-api/test_provider_model_surface.py +31 -0
  90. package/workframe-api/test_stack_config_install_smtp.py +51 -0
  91. package/workframe-api/time-bind-chat.py +99 -99
  92. package/workframe-api/turn_credentials.py +226 -226
  93. package/workframe-api/updates.py +506 -506
  94. package/workframe-api/vault_kek.py +159 -159
  95. package/workframe-api/zk_auth.py +633 -633
  96. package/workframe-supervisor/Dockerfile +11 -11
  97. package/workframe-supervisor/profile_secret_policy.py +76 -76
  98. package/workframe-supervisor/server.py +860 -860
  99. package/workframe-ui/docker/nginx.conf +85 -85
  100. package/workframe-ui/public/assets/{arc-CnriPN3G.js → arc-Cn-lt9fT.js} +1 -1
  101. package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
  102. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DYYB1Ic2.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
  103. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bn-fFOyj.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
  104. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BPPDtAnD.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
  105. package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
  106. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bz2CJiC3.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
  107. package/workframe-ui/public/assets/{chunk-3OPIFGDE-B8aMeE1B.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
  108. package/workframe-ui/public/assets/{chunk-4BX2VUAB-bLFHQTuT.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
  109. package/workframe-ui/public/assets/{chunk-55IACEB6-BRxhx-lG.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
  110. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BLyOgEJ8.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
  111. package/workframe-ui/public/assets/{chunk-727SXJPM-DiuvBl7G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-BE1_aBdC.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-BSJP7CBP-BicGsmBs.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-C3VTns3d.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-FMBD7UC4-DSiXY3bY.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-KSCS5N6A-Cvw5s3fR.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-jJifGdIQ.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Maee1aqo.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
  119. package/workframe-ui/public/assets/{chunk-ND2GUHAM-C77N3VAM.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-NZK2D7GU-BY68lrPB.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-O5CBEL6O-D_c4kWzh.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
  122. package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
  123. package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
  124. package/workframe-ui/public/assets/{chunk-XPW4576I-DlQgvqBp.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
  125. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
  126. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
  127. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CHCOHDSd.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
  128. package/workframe-ui/public/assets/{dagre-BM42HDAG-CKPcSYwU.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
  129. package/workframe-ui/public/assets/{diagram-2AECGRRQ-C6qe0jP-.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
  130. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DhQhrieU.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
  131. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Bqf6Iw1r.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
  132. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt1ms2D2.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
  133. package/workframe-ui/public/assets/{diagram-OG6HWLK6-C0Rrxe1i.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
  134. package/workframe-ui/public/assets/{dist-BLcLhTn6.js → dist-uZYeD3o1.js} +1 -1
  135. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Cy1GbZVu.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
  136. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
  137. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-DL3ALqDG.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
  138. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CMpwBhXQ.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
  139. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-C0mwHUXL.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
  140. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-6gzhWaF0.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
  141. package/workframe-ui/public/assets/index-DHLQNAXd.js +129 -0
  142. package/workframe-ui/public/assets/index-auJ0bLMU.css +1 -0
  143. package/workframe-ui/public/assets/{info-J43DQDTF-Bb_wZTxU.js → info-J43DQDTF-BNMADxE2.js} +1 -1
  144. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-601evYFV.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
  145. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BYtF-wHm.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
  146. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BMizyHzC.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
  147. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CLLJsdnh.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
  148. package/workframe-ui/public/assets/{line-BFiSvu-q.js → line-CNtIbnmt.js} +1 -1
  149. package/workframe-ui/public/assets/{linear-D6HYZ18_.js → linear-pmSg_4uV.js} +1 -1
  150. package/workframe-ui/public/assets/{mermaid-parser.core-Cid5yDBQ.js → mermaid-parser.core-BX4tnojE.js} +2 -2
  151. package/workframe-ui/public/assets/{mermaid.core-DqIiju5Z.js → mermaid.core-BtpBva5o.js} +3 -3
  152. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Bg7xJdsz.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
  153. package/workframe-ui/public/assets/{packet-YPE3B663-1ebGt86D.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
  154. package/workframe-ui/public/assets/{pie-LRSECV5Y-DJ9wOBC_.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
  155. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BmK96Aus.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
  156. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-ByUqtWPZ.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
  157. package/workframe-ui/public/assets/{radar-GUYGQ44K-DN-Hkt96.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
  158. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ByU2r8gb.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
  159. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-elyWa0GK.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
  160. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-CzZwsAiK.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
  161. package/workframe-ui/public/assets/{src-5vgsM_dl.js → src-B3Axsi1U.js} +1 -1
  162. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-B0O5w5lJ.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
  163. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
  164. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-oyqa5w5y.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
  165. package/workframe-ui/public/assets/{treeView-BLDUP644-MCV12-eP.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
  166. package/workframe-ui/public/assets/{treemap-LRROVOQU-DiA1P9n5.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
  167. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CO_4x7OR.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
  168. package/workframe-ui/public/assets/{wardley-L42UT6IY-CmQNv6Xv.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
  169. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CJxDkbIj.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
  170. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B02MxgzG.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
  171. package/workframe-ui/public/favicon.svg +7 -7
  172. package/workframe-ui/public/icons.svg +24 -24
  173. package/workframe-ui/public/index.html +47 -47
  174. package/workframe-ui/public/manifest.webmanifest +18 -18
  175. package/workframe-ui/public/workframe-config.json +3 -3
  176. package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +0 -1
  177. package/workframe-ui/public/assets/channel-Dok_-T0o.js +0 -1
  178. package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +0 -1
  179. package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +0 -1
  180. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +0 -1
  181. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +0 -1
  182. package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +0 -1
  183. package/workframe-ui/public/assets/index-DyO6K0TD.css +0 -1
  184. package/workframe-ui/public/assets/index-yvpSUs8T.js +0 -133
  185. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +0 -1
@@ -1,427 +1,435 @@
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).
2
+
3
+ During install window: stack file only (clean install — no compose env ghost creds).
4
+ After install_complete: env wins over file for VPS ops overrides.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
15
+ CONFIG_PATH = DATA_DIR / "stack_config.json"
16
+
17
+ DEPLOYMENT_MODES = frozenset({"single_user_local", "trusted_team", "public_multi_user"})
18
+
19
+
20
+ def normalize_smtp_secure(port: int, secure: str) -> str:
21
+ """Map provider port + secure hint to smtplib mode (ssl | starttls | none)."""
22
+ s = str(secure or "").strip().lower()
23
+ p = int(port or 587)
24
+ if s in {"ssl", "smtps", "1", "true", "yes"}:
25
+ return "ssl"
26
+ if s in {"none", "off", "false", "0", "plain"}:
27
+ return "none"
28
+ if p == 465:
29
+ return "ssl"
30
+ if s in {"starttls", "tls"}:
31
+ return "starttls"
32
+ if p in {587, 2525}:
33
+ return "starttls"
34
+ return "starttls"
35
+
36
+
37
+ def _read_raw() -> dict[str, Any]:
38
+ if not CONFIG_PATH.is_file():
39
+ return {}
40
+ try:
41
+ with open(CONFIG_PATH, encoding="utf-8") as f:
42
+ data = json.load(f)
43
+ return data if isinstance(data, dict) else {}
44
+ except (OSError, json.JSONDecodeError):
45
+ return {}
46
+
47
+
48
+ def _write_raw(data: dict[str, Any]) -> None:
49
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
50
+ tmp = CONFIG_PATH.with_suffix(".tmp")
51
+ with open(tmp, "w", encoding="utf-8") as f:
52
+ json.dump(data, f, indent=2, sort_keys=True)
53
+ os.replace(tmp, CONFIG_PATH)
54
+ try:
55
+ os.chmod(CONFIG_PATH, 0o600)
56
+ except OSError:
57
+ pass
58
+
59
+
60
+ def _stack_smtp_raw() -> dict[str, Any]:
61
+ raw = _read_raw()
62
+ smtp = raw.get("smtp")
63
+ return smtp if isinstance(smtp, dict) else {}
64
+
65
+
66
+ def _stack_oauth_raw(key: str) -> dict[str, Any]:
67
+ block = _read_raw().get(key)
68
+ return block if isinstance(block, dict) else {}
69
+
70
+
71
+ def _oauth_public(block: dict[str, Any]) -> dict[str, Any]:
72
+ client_id = str(block.get("client_id") or "").strip()
73
+ return {
74
+ "client_id": client_id,
75
+ "has_secret": bool(str(block.get("client_secret") or "").strip()),
76
+ "enabled": bool(client_id),
77
+ }
78
+
79
+
80
+ def resolved_google_oauth() -> dict[str, str]:
81
+ go = _stack_oauth_raw("google_oauth")
82
+ return {
83
+ "client_id": str(go.get("client_id") or "").strip(),
84
+ "client_secret": str(go.get("client_secret") or "").strip(),
85
+ }
86
+
87
+
88
+ def resolved_github_oauth() -> dict[str, str]:
89
+ gh = _stack_oauth_raw("github_oauth")
90
+ return {
91
+ "client_id": str(gh.get("client_id") or "").strip(),
92
+ "client_secret": str(gh.get("client_secret") or "").strip(),
93
+ }
94
+
95
+
96
+ def resolved_stripe_connect() -> dict[str, str]:
97
+ st = _stack_oauth_raw("stripe_connect")
98
+ return {
99
+ "client_id": str(st.get("client_id") or "").strip(),
100
+ "client_secret": str(st.get("client_secret") or "").strip(),
101
+ }
102
+
103
+
104
+ def github_oauth_for_workspace_settings(settings: dict[str, Any]) -> dict[str, Any]:
105
+ """Copy install-time GitHub OAuth app creds into workspace settings when present."""
106
+ gh = resolved_github_oauth()
107
+ if gh.get("client_id") and gh.get("client_secret"):
108
+ settings = dict(settings or {})
109
+ settings["github_oauth"] = dict(gh)
110
+ return settings
111
+
112
+
113
+ def get_stack_config() -> dict[str, Any]:
114
+ raw = _read_raw()
115
+ smtp = raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {}
116
+ return {
117
+ "deployment_mode": str(raw.get("deployment_mode") or "").strip(),
118
+ "app_base_url": str(raw.get("app_base_url") or "").strip(),
119
+ "install_complete": bool(raw.get("install_complete")),
120
+ "smtp": {
121
+ "provider": str(smtp.get("provider") or "").strip(),
122
+ "host": str(smtp.get("host") or "").strip(),
123
+ "port": int(smtp.get("port") or 587),
124
+ "user": str(smtp.get("user") or "").strip(),
125
+ "from": str(smtp.get("from") or smtp.get("from_address") or "").strip(),
126
+ "secure": str(smtp.get("secure") or "starttls").strip(),
127
+ "has_password": bool(str(smtp.get("password") or "").strip()),
128
+ },
129
+ "google_oauth": _oauth_public(_stack_oauth_raw("google_oauth")),
130
+ "github_oauth": _oauth_public(_stack_oauth_raw("github_oauth")),
131
+ "discord_oauth": _oauth_public(_stack_oauth_raw("discord_oauth")),
132
+ "telegram_login": _telegram_login_public(),
133
+ "stripe_connect": _oauth_public(_stack_oauth_raw("stripe_connect")),
134
+ }
135
+
136
+
137
+ def _telegram_login_public() -> dict[str, Any]:
138
+ block = _stack_oauth_raw("telegram_login")
139
+ bot_username = str(block.get("bot_username") or "").strip().lstrip("@")
140
+ return {
141
+ "bot_username": bot_username,
142
+ "has_token": bool(str(block.get("bot_token") or "").strip()),
143
+ "enabled": bool(bot_username and str(block.get("bot_token") or "").strip()),
144
+ }
145
+
146
+
147
+ def patch_stack_config(body: dict[str, Any]) -> dict[str, Any]:
148
+ raw = _read_raw()
149
+ if "deployment_mode" in body:
150
+ mode = str(body.get("deployment_mode") or "").strip().lower()
151
+ if mode not in DEPLOYMENT_MODES:
152
+ raise ValueError(f"invalid deployment_mode: {mode}")
153
+ raw["deployment_mode"] = mode
154
+ os.environ["WORKFRAME_DEPLOYMENT_MODE"] = mode
155
+ if "app_base_url" in body:
156
+ raw["app_base_url"] = str(body.get("app_base_url") or "").strip().rstrip("/")
157
+ if body.get("install_complete") is True:
158
+ raw["install_complete"] = True
159
+ if "smtp" in body and isinstance(body["smtp"], dict):
160
+ smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
161
+ creds_changed = False
162
+ for key in ("provider", "host", "user", "secure"):
163
+ if key in body["smtp"]:
164
+ val = str(body["smtp"][key] or "").strip()
165
+ if str(smtp.get(key) or "").strip() != val:
166
+ creds_changed = True
167
+ smtp[key] = val
168
+ if "port" in body["smtp"]:
169
+ port_val = int(body["smtp"].get("port") or 587)
170
+ if int(smtp.get("port") or 587) != port_val:
171
+ creds_changed = True
172
+ smtp["port"] = port_val
173
+ if "password" in body["smtp"]:
174
+ pw = str(body["smtp"].get("password") or "")
175
+ if pw:
176
+ creds_changed = True
177
+ smtp["password"] = pw
178
+ elif "pass" in body["smtp"]:
179
+ pw = str(body["smtp"].get("pass") or "")
180
+ if pw:
181
+ creds_changed = True
182
+ smtp["password"] = pw
183
+ if "from" in body["smtp"] or "from_address" in body["smtp"]:
184
+ from_val = str(
185
+ body["smtp"].get("from") if "from" in body["smtp"] else body["smtp"].get("from_address") or ""
186
+ ).strip()
187
+ if from_val:
188
+ smtp["from"] = from_val
189
+ elif "from" in smtp:
190
+ del smtp["from"]
191
+ if "admin_email" in body["smtp"]:
192
+ admin_email = str(body["smtp"].get("admin_email") or "").strip().lower()
193
+ if admin_email:
194
+ smtp["admin_email"] = admin_email
195
+ elif "admin_email" in smtp:
196
+ del smtp["admin_email"]
197
+ if creds_changed:
198
+ smtp.pop("tested", None)
199
+ port = int(smtp.get("port") or 587)
200
+ smtp["secure"] = normalize_smtp_secure(port, str(smtp.get("secure") or ""))
201
+ raw["smtp"] = smtp
202
+ if "google_oauth" in body and isinstance(body["google_oauth"], dict):
203
+ go = raw.get("google_oauth") if isinstance(raw.get("google_oauth"), dict) else {}
204
+ for key in ("client_id", "client_secret"):
205
+ if key in body["google_oauth"]:
206
+ val = str(body["google_oauth"].get(key) or "").strip()
207
+ if val or key == "client_id":
208
+ go[key] = val
209
+ raw["google_oauth"] = go
210
+ if "github_oauth" in body and isinstance(body["github_oauth"], dict):
211
+ gh = raw.get("github_oauth") if isinstance(raw.get("github_oauth"), dict) else {}
212
+ for key in ("client_id", "client_secret"):
213
+ if key in body["github_oauth"]:
214
+ val = str(body["github_oauth"].get(key) or "").strip()
215
+ if val or key == "client_id":
216
+ gh[key] = val
217
+ raw["github_oauth"] = gh
218
+ if "discord_oauth" in body and isinstance(body["discord_oauth"], dict):
219
+ dc = raw.get("discord_oauth") if isinstance(raw.get("discord_oauth"), dict) else {}
220
+ for key in ("client_id", "client_secret"):
221
+ if key in body["discord_oauth"]:
222
+ val = str(body["discord_oauth"].get(key) or "").strip()
223
+ if val or key == "client_id":
224
+ dc[key] = val
225
+ raw["discord_oauth"] = dc
226
+ if "telegram_login" in body and isinstance(body["telegram_login"], dict):
227
+ tg = raw.get("telegram_login") if isinstance(raw.get("telegram_login"), dict) else {}
228
+ if "bot_username" in body["telegram_login"]:
229
+ tg["bot_username"] = str(body["telegram_login"].get("bot_username") or "").strip().lstrip("@")
230
+ if "bot_token" in body["telegram_login"]:
231
+ token = str(body["telegram_login"].get("bot_token") or "").strip()
232
+ if token:
233
+ tg["bot_token"] = token
234
+ raw["telegram_login"] = tg
235
+ if "stripe_connect" in body and isinstance(body["stripe_connect"], dict):
236
+ st = raw.get("stripe_connect") if isinstance(raw.get("stripe_connect"), dict) else {}
237
+ for key in ("client_id", "client_secret"):
238
+ if key in body["stripe_connect"]:
239
+ val = str(body["stripe_connect"].get(key) or "").strip()
240
+ if val or key == "client_id":
241
+ st[key] = val
242
+ raw["stripe_connect"] = st
243
+ if "site_branding" in body and isinstance(body["site_branding"], dict):
244
+ block = raw.get("site_branding") if isinstance(raw.get("site_branding"), dict) else {}
245
+ for key in ("title", "description", "theme_color"):
246
+ if key in body["site_branding"]:
247
+ block[key] = str(body["site_branding"].get(key) or "").strip()
248
+ raw["site_branding"] = block
249
+ _write_raw(raw)
250
+ return get_stack_config()
251
+
252
+
253
+ def resolve_deployment_mode(env_default: str = "trusted_team") -> str:
254
+ """Explicit WORKFRAME_DEPLOYMENT_MODE env wins; stack_config applies only when env is unset."""
255
+ env_raw = (os.environ.get("WORKFRAME_DEPLOYMENT_MODE") or "").strip().lower()
256
+ if env_raw in DEPLOYMENT_MODES:
257
+ return env_raw
258
+ if env_raw:
259
+ return env_raw
260
+ try:
261
+ sc_mode = str(get_stack_config().get("deployment_mode") or "").strip().lower()
262
+ if sc_mode in DEPLOYMENT_MODES:
263
+ return sc_mode
264
+ except Exception:
265
+ pass
266
+ raw = (env_default or "trusted_team").strip().lower()
267
+ return raw if raw in DEPLOYMENT_MODES else "trusted_team"
268
+
269
+
270
+ def effective_deployment_mode(env_default: str) -> str:
271
+ return resolve_deployment_mode(env_default)
272
+
273
+
274
+ def _install_window_open() -> bool:
275
+ return not bool(_read_raw().get("install_complete"))
276
+
277
+
278
+ def resolved_smtp() -> dict[str, Any]:
279
+ """Stack file during install; env overrides only after install_complete."""
280
+ stack = _stack_smtp_raw()
281
+ stack_host = str(stack.get("host") or "").strip()
282
+ stack_user = str(stack.get("user") or "").strip()
283
+ stack_pw = str(stack.get("password") or "").strip().replace(" ", "")
284
+ stack_from = str(stack.get("from") or "").strip()
285
+
286
+ env_host = "" if _install_window_open() else os.environ.get("SMTP_HOST", "").strip()
287
+ if env_host:
288
+ port = int(os.environ.get("SMTP_PORT", "587"))
289
+ secure = normalize_smtp_secure(
290
+ port,
291
+ os.environ.get("SMTP_SECURE", "").strip().lower(),
292
+ )
293
+ user = os.environ.get("SMTP_USER", "").strip() or stack_user
294
+ password = (
295
+ os.environ.get("SMTP_PASSWORD") or os.environ.get("SMTP_PASS") or stack_pw or ""
296
+ ).strip().replace(" ", "")
297
+ from_addr = (
298
+ os.environ.get("SMTP_FROM") or os.environ.get("EMAIL_FROM") or stack_from or user or ""
299
+ ).strip()
300
+ return {
301
+ "host": env_host,
302
+ "port": port,
303
+ "user": user,
304
+ "password": password,
305
+ "from": from_addr or user,
306
+ "secure": secure,
307
+ "source": "env",
308
+ }
309
+
310
+ if not stack_host:
311
+ return {"source": "none"}
312
+ port = int(stack.get("port") or 587)
313
+ user = stack_user
314
+ return {
315
+ "host": stack_host,
316
+ "port": port,
317
+ "user": user,
318
+ "password": stack_pw,
319
+ "from": stack_from or user,
320
+ "secure": normalize_smtp_secure(port, str(stack.get("secure") or "starttls")),
321
+ "source": "stack_config",
322
+ }
323
+
324
+
325
+ def smtp_configured() -> bool:
326
+ s = resolved_smtp()
327
+ return bool(s.get("host") and s.get("source") != "none")
328
+
329
+
330
+ def smtp_tested() -> bool:
331
+ stack = _stack_smtp_raw()
332
+ return bool(stack.get("tested"))
333
+
334
+
335
+ def smtp_has_password() -> bool:
336
+ stack = _stack_smtp_raw()
337
+ if str(stack.get("password") or "").strip():
338
+ return True
339
+ resolved = resolved_smtp()
340
+ return bool(str(resolved.get("password") or "").strip())
341
+
342
+
343
+ def smtp_setup_complete() -> bool:
344
+ """SMTP tested and admin email saved — wizard may skip re-test."""
345
+ if not smtp_tested():
346
+ return False
347
+ stack = _stack_smtp_raw()
348
+ return bool(str(stack.get("admin_email") or "").strip()) and smtp_has_password() and smtp_configured()
349
+
350
+
351
+ def mark_smtp_tested() -> None:
352
+ raw = _read_raw()
353
+ smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
354
+ smtp["tested"] = True
355
+ raw["smtp"] = smtp
356
+ _write_raw(raw)
357
+
358
+
359
+ def _site_branding_public_payload() -> dict[str, Any]:
360
+ try:
361
+ import site_meta
362
+
363
+ return site_meta.site_branding_public()
364
+ except Exception:
365
+ return {
366
+ "title": "",
367
+ "description": "",
368
+ "theme_color": "",
369
+ "has_og_image": False,
370
+ "has_favicon": False,
371
+ }
372
+
373
+
374
+ def public_stack_payload() -> dict[str, Any]:
375
+ cfg = get_stack_config()
376
+ smtp = cfg.get("smtp") or {}
377
+ go = cfg.get("google_oauth") or {}
378
+ gh = cfg.get("github_oauth") or {}
379
+ dc = cfg.get("discord_oauth") or {}
380
+ tg = cfg.get("telegram_login") or {}
381
+ st = cfg.get("stripe_connect") or {}
382
+ app_base = str(cfg.get("app_base_url") or "").strip().rstrip("/")
383
+ domain = ""
384
+ if app_base:
385
+ try:
386
+ from urllib.parse import urlparse
387
+
388
+ domain = (urlparse(app_base).hostname or "").strip()
389
+ except Exception:
390
+ domain = ""
391
+ return {
392
+ "deployment_mode": cfg.get("deployment_mode") or "",
393
+ "app_base_url": cfg.get("app_base_url") or "",
394
+ "install_complete": bool(cfg.get("install_complete")),
395
+ "smtp": {
396
+ "provider": smtp.get("provider") or "",
397
+ "host": smtp.get("host") or "",
398
+ "port": smtp.get("port") or 587,
399
+ "user": smtp.get("user") or "",
400
+ "from": smtp.get("from") or "",
401
+ "admin_email": smtp.get("admin_email") or "",
402
+ "secure": smtp.get("secure") or "starttls",
403
+ "configured": smtp_configured(),
404
+ "tested": smtp_tested(),
405
+ "setup_complete": smtp_setup_complete(),
406
+ "has_password": smtp_has_password(),
407
+ },
408
+ "google_oauth": {
409
+ "client_id": go.get("client_id") or "",
410
+ "has_secret": bool(go.get("has_secret")),
411
+ "enabled": bool(go.get("enabled")),
412
+ },
413
+ "github_oauth": {
414
+ "client_id": gh.get("client_id") or "",
415
+ "has_secret": bool(gh.get("has_secret")),
416
+ "enabled": bool(gh.get("enabled")),
417
+ },
418
+ "discord_oauth": {
419
+ "client_id": dc.get("client_id") or "",
420
+ "has_secret": bool(dc.get("has_secret")),
421
+ "enabled": bool(dc.get("enabled")),
422
+ },
423
+ "telegram_login": {
424
+ "bot_username": tg.get("bot_username") or "",
425
+ "has_token": bool(tg.get("has_token")),
426
+ "enabled": bool(tg.get("enabled")),
427
+ "domain": domain,
428
+ },
429
+ "stripe_connect": {
430
+ "client_id": st.get("client_id") or "",
431
+ "has_secret": bool(st.get("has_secret")),
432
+ "enabled": bool(st.get("enabled")),
433
+ },
434
+ "site_branding": _site_branding_public_payload(),
435
+ }