create-workframe 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +85 -0
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +9 -0
  8. package/scripts/verify-public-deploy.sh +121 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/openrouter_catalog.py +4 -4
  51. package/workframe-api/package.json +2 -2
  52. package/workframe-api/profile_api_lifecycle.py +66 -0
  53. package/workframe-api/profile_config_yaml.py +126 -0
  54. package/workframe-api/profile_gateway.py +436 -0
  55. package/workframe-api/provider_bindings.py +673 -0
  56. package/workframe-api/provider_bootstrap.py +347 -0
  57. package/workframe-api/provider_catalog.py +180 -0
  58. package/workframe-api/rooms.py +1613 -0
  59. package/workframe-api/route_registry.py +470 -0
  60. package/workframe-api/run-typecheck.mjs +26 -0
  61. package/workframe-api/run_authority.py +287 -0
  62. package/workframe-api/run_ledger.py +470 -0
  63. package/workframe-api/run_surface_wiring.py +344 -0
  64. package/workframe-api/runtime_cohort.py +752 -0
  65. package/workframe-api/runtime_tokens.py +127 -0
  66. package/workframe-api/server.py +1376 -19305
  67. package/workframe-api/snapshot_feed.py +49 -0
  68. package/workframe-api/stack_config.py +33 -1
  69. package/workframe-api/supervisor_client.py +154 -0
  70. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  71. package/workframe-api/test_cell_authority.py +79 -0
  72. package/workframe-api/test_chat_bind.py +48 -0
  73. package/workframe-api/test_credential_lease_matrix.py +171 -0
  74. package/workframe-api/test_credential_resolve.py +51 -0
  75. package/workframe-api/test_credential_store.py +27 -0
  76. package/workframe-api/test_dogfood_flows.py +346 -0
  77. package/workframe-api/test_domain_entities.py +152 -0
  78. package/workframe-api/test_exception_hygiene.py +12 -0
  79. package/workframe-api/test_hermes_admin.py +72 -0
  80. package/workframe-api/test_install_wizard_resume.py +78 -0
  81. package/workframe-api/test_local_bootstrap.py +67 -0
  82. package/workframe-api/test_mention_helpers.py +35 -0
  83. package/workframe-api/test_mention_invoke.py +26 -0
  84. package/workframe-api/test_model_surface_consistency.py +117 -0
  85. package/workframe-api/test_oauth_pending.py +41 -0
  86. package/workframe-api/test_profile_model_yaml.py +99 -0
  87. package/workframe-api/test_provider_bindings.py +52 -0
  88. package/workframe-api/test_provider_catalog.py +28 -0
  89. package/workframe-api/test_public_routes.py +41 -0
  90. package/workframe-api/test_route_registry.py +245 -0
  91. package/workframe-api/test_run_authority.py +112 -0
  92. package/workframe-api/test_run_ledger.py +157 -0
  93. package/workframe-api/test_run_surface_wiring.py +130 -0
  94. package/workframe-api/test_runtime_cohort.py +85 -0
  95. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  96. package/workframe-api/test_server_reexports.py +99 -0
  97. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  98. package/workframe-api/time-bind-steps.py +75 -0
  99. package/workframe-api/time-perf.py +81 -0
  100. package/workframe-api/time-warm.py +107 -0
  101. package/workframe-api/turn_credentials.py +28 -13
  102. package/workframe-api/turn_overlay.py +715 -0
  103. package/workframe-api/updates.py +16 -1
  104. package/workframe-api/user_prefs.py +387 -0
  105. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  106. package/workframe-api/workspace_bootstrap.py +536 -0
  107. package/workframe-api/workspace_files.py +344 -0
  108. package/workframe-api/workspace_messaging.py +206 -0
  109. package/workframe-api/zk_auth.py +3 -2
  110. package/workframe-supervisor/server.py +10 -1
  111. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  112. package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
  113. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  114. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  115. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  116. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  117. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  118. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  123. package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  124. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  127. package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  128. package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  129. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  130. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  131. package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  132. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  133. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  134. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  135. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  136. package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  137. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  138. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  139. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  140. package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  141. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  142. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  143. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  144. package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  145. package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  146. package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
  147. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  148. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  149. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  150. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  151. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  152. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  153. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  154. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  155. package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  156. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  157. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  158. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  159. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  160. package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
  161. package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
  162. package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  163. package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
  164. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  165. package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  166. package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  167. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  168. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  169. package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  170. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  171. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  172. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  173. package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
  174. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  175. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  176. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  177. package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  178. package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  179. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  180. package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  181. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  182. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  183. package/workframe-ui/public/index.html +7 -6
  184. package/workframe-ui/public/workframe-build.json +5 -0
  185. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
  186. package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
  187. package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
  188. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
  189. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
  190. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
  191. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
  192. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
  193. package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
  194. package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
  195. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
@@ -0,0 +1,85 @@
1
+ """WF-032 extract: OAuth PKCE pending state (short-lived callback handshake)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import secrets
8
+ import sqlite3
9
+ from datetime import datetime, timedelta, timezone
10
+ from typing import Any
11
+
12
+ import db_schema
13
+
14
+
15
+ def _srv():
16
+ import server as srv
17
+
18
+ return srv
19
+
20
+
21
+ def ensure_schema() -> None:
22
+ db_schema.ensure_oauth_pending_migrations(_srv()._workframe_db)
23
+
24
+
25
+ def pkce_pair() -> tuple[str, str]:
26
+ verifier = secrets.token_urlsafe(48)
27
+ digest = hashlib.sha256(verifier.encode("utf-8")).digest()
28
+ challenge = base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
29
+ return verifier, challenge
30
+
31
+
32
+ def store_pending(
33
+ state: str,
34
+ user_id: str,
35
+ provider: str,
36
+ code_verifier: str,
37
+ workspace_id: str = "",
38
+ ) -> None:
39
+ ensure_schema()
40
+ now = _srv()._utc_now()
41
+ expires = (datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat()
42
+ conn = _srv()._workframe_db()
43
+ try:
44
+ conn.execute(
45
+ """
46
+ INSERT INTO oauth_pending_states (state, user_id, provider, code_verifier, workspace_id, expires_at, created_at)
47
+ VALUES (?, ?, ?, ?, ?, ?, ?)
48
+ ON CONFLICT(state) DO UPDATE SET
49
+ user_id = excluded.user_id,
50
+ provider = excluded.provider,
51
+ code_verifier = excluded.code_verifier,
52
+ workspace_id = excluded.workspace_id,
53
+ expires_at = excluded.expires_at,
54
+ created_at = excluded.created_at
55
+ """,
56
+ (state, user_id, provider, code_verifier, workspace_id or None, expires, now),
57
+ )
58
+ conn.commit()
59
+ finally:
60
+ conn.close()
61
+
62
+
63
+ def take_pending(state: str) -> dict[str, Any] | None:
64
+ ensure_schema()
65
+ conn = _srv()._workframe_db()
66
+ conn.row_factory = sqlite3.Row
67
+ try:
68
+ row = conn.execute(
69
+ "SELECT * FROM oauth_pending_states WHERE state = ?",
70
+ (str(state or "").strip(),),
71
+ ).fetchone()
72
+ if not row:
73
+ return None
74
+ conn.execute("DELETE FROM oauth_pending_states WHERE state = ?", (row["state"],))
75
+ conn.commit()
76
+ expires_at = str(row["expires_at"] or "")
77
+ if expires_at:
78
+ try:
79
+ if datetime.fromisoformat(expires_at.replace("Z", "+00:00")) < datetime.now(timezone.utc):
80
+ return None
81
+ except ValueError:
82
+ pass
83
+ return dict(row)
84
+ finally:
85
+ conn.close()
@@ -0,0 +1,381 @@
1
+ """WF-032 extract: GitHub, Stripe, and Discord OAuth redirect flows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import secrets
8
+ import sqlite3
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ import uuid
13
+ from typing import Any
14
+
15
+ import oauth_pending
16
+ import platform_auth
17
+ import provider_catalog
18
+ import stack_config
19
+ from email_sender import APP_BASE_URL
20
+
21
+ GITHUB_OAUTH_SCOPES = "repo read:user"
22
+ STRIPE_CONNECT_SCOPES = "read_write"
23
+
24
+ _store_oauth_pending = oauth_pending.store_pending
25
+ _take_oauth_pending = oauth_pending.take_pending
26
+ _pkce_pair = oauth_pending.pkce_pair
27
+
28
+
29
+ def _srv():
30
+ import server as srv
31
+
32
+ return srv
33
+
34
+
35
+ def _stripe_connect_app_config() -> dict[str, str]:
36
+ """Stack Stripe Connect app — platform secret never leaves the BFF."""
37
+ stack_st = stack_config.resolved_stripe_connect()
38
+ if stack_st.get("client_id") and stack_st.get("client_secret"):
39
+ return stack_st
40
+ client_id = (
41
+ os.environ.get("WORKFRAME_STRIPE_CONNECT_CLIENT_ID")
42
+ or os.environ.get("STRIPE_CONNECT_CLIENT_ID")
43
+ or ""
44
+ ).strip()
45
+ client_secret = (
46
+ os.environ.get("WORKFRAME_STRIPE_SECRET_KEY")
47
+ or os.environ.get("STRIPE_SECRET_KEY")
48
+ or ""
49
+ ).strip()
50
+ return {"client_id": client_id, "client_secret": client_secret}
51
+
52
+
53
+ def _stripe_connect_configured() -> bool:
54
+ cfg = _stripe_connect_app_config()
55
+ return bool(cfg.get("client_id") and cfg.get("client_secret"))
56
+
57
+
58
+ def _stripe_oauth_redirect_uri() -> str:
59
+ base = APP_BASE_URL.rstrip("/")
60
+ return f"{base}/api/oauth/stripe/callback"
61
+
62
+
63
+ def _parse_workspace_settings(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]:
64
+ keys = row.keys() if hasattr(row, "keys") else row
65
+ raw = ""
66
+ if isinstance(keys, (list, set)) or hasattr(keys, "__contains__"):
67
+ if "settings_json" in keys:
68
+ raw = row["settings_json"] if not isinstance(row, dict) else row.get("settings_json", "")
69
+ if not raw:
70
+ return {}
71
+ try:
72
+ parsed = json.loads(str(raw or "{}"))
73
+ except (TypeError, json.JSONDecodeError):
74
+ return {}
75
+ return parsed if isinstance(parsed, dict) else {}
76
+
77
+
78
+ def _github_oauth_app_config(workspace_id: str = "") -> dict[str, str]:
79
+ """Stack or workspace GitHub OAuth app — client secret never leaves the BFF."""
80
+ workspace = str(workspace_id or "").strip()
81
+ if workspace:
82
+ try:
83
+ conn = _srv()._workframe_db()
84
+ row = conn.execute(
85
+ "SELECT settings_json FROM workspaces WHERE id = ? AND deleted_at IS NULL",
86
+ (workspace,),
87
+ ).fetchone()
88
+ conn.close()
89
+ if row:
90
+ gh = _parse_workspace_settings(row).get("github_oauth")
91
+ if isinstance(gh, dict):
92
+ client_id = str(gh.get("client_id") or "").strip()
93
+ client_secret = str(gh.get("client_secret") or "").strip()
94
+ if client_id and client_secret:
95
+ return {"client_id": client_id, "client_secret": client_secret}
96
+ except sqlite3.Error:
97
+ pass
98
+ stack_gh = stack_config.resolved_github_oauth()
99
+ if stack_gh.get("client_id") and stack_gh.get("client_secret"):
100
+ return stack_gh
101
+ client_id = (
102
+ os.environ.get("WORKFRAME_GITHUB_OAUTH_CLIENT_ID")
103
+ or os.environ.get("GITHUB_CLIENT_ID")
104
+ or ""
105
+ ).strip()
106
+ client_secret = (
107
+ os.environ.get("WORKFRAME_GITHUB_OAUTH_CLIENT_SECRET")
108
+ or os.environ.get("GITHUB_CLIENT_SECRET")
109
+ or ""
110
+ ).strip()
111
+ return {"client_id": client_id, "client_secret": client_secret}
112
+
113
+
114
+ def _github_oauth_configured(workspace_id: str = "") -> bool:
115
+ cfg = _github_oauth_app_config(workspace_id)
116
+ return bool(cfg.get("client_id") and cfg.get("client_secret"))
117
+
118
+
119
+ def _github_oauth_redirect_uri() -> str:
120
+ base = APP_BASE_URL.rstrip("/")
121
+ return f"{base}/api/oauth/github/callback"
122
+
123
+
124
+
125
+ def _github_exchange_code(code: str, client_id: str, client_secret: str, redirect_uri: str, code_verifier: str) -> dict[str, Any]:
126
+ body = urllib.parse.urlencode(
127
+ {
128
+ "client_id": client_id,
129
+ "client_secret": client_secret,
130
+ "code": code,
131
+ "redirect_uri": redirect_uri,
132
+ "code_verifier": code_verifier,
133
+ },
134
+ ).encode("utf-8")
135
+ req = urllib.request.Request(
136
+ "https://github.com/login/oauth/access_token",
137
+ data=body,
138
+ headers={
139
+ "Accept": "application/json",
140
+ "Content-Type": "application/x-www-form-urlencoded",
141
+ },
142
+ method="POST",
143
+ )
144
+ try:
145
+ with urllib.request.urlopen(req, timeout=30) as resp:
146
+ raw = resp.read().decode("utf-8", errors="replace")
147
+ except urllib.error.HTTPError as exc:
148
+ raw = exc.read().decode("utf-8", errors="replace")
149
+ except OSError as exc:
150
+ return {"error": str(exc)}
151
+ try:
152
+ data = json.loads(raw)
153
+ except json.JSONDecodeError:
154
+ return {"error": raw or "github_token_exchange_failed"}
155
+ return data if isinstance(data, dict) else {"error": "invalid_github_response"}
156
+
157
+
158
+ def _start_github_oauth(user_id: str, workspace_id: str, spec: dict[str, Any]) -> dict[str, Any]:
159
+ cfg = _github_oauth_app_config(workspace_id)
160
+ client_id = str(cfg.get("client_id") or "").strip()
161
+ if not client_id or not str(cfg.get("client_secret") or "").strip():
162
+ return {
163
+ "ok": False,
164
+ "error": "github_oauth_not_configured",
165
+ "message": "Workframe admin must register a GitHub OAuth app under Workframe → Integrations.",
166
+ }
167
+ state = secrets.token_urlsafe(24)
168
+ verifier, challenge = _pkce_pair()
169
+ _store_oauth_pending(state, user_id, "github", verifier, workspace_id)
170
+ params = urllib.parse.urlencode(
171
+ {
172
+ "client_id": client_id,
173
+ "redirect_uri": _github_oauth_redirect_uri(),
174
+ "scope": GITHUB_OAUTH_SCOPES,
175
+ "state": state,
176
+ "code_challenge": challenge,
177
+ "code_challenge_method": "S256",
178
+ },
179
+ )
180
+ return {
181
+ "ok": True,
182
+ "provider": str(spec.get("id") or "github"),
183
+ "redirect_url": f"https://github.com/login/oauth/authorize?{params}",
184
+ "output": "",
185
+ "error": None,
186
+ }
187
+
188
+
189
+ def _complete_github_oauth(user_id: str, code: str, state: str) -> dict[str, Any]:
190
+ pending = _take_oauth_pending(state)
191
+ if not pending or str(pending.get("user_id") or "") != user_id:
192
+ return {"ok": False, "error": "invalid_oauth_state"}
193
+ workspace_id = str(pending.get("workspace_id") or "")
194
+ cfg = _github_oauth_app_config(workspace_id)
195
+ client_id = str(cfg.get("client_id") or "").strip()
196
+ client_secret = str(cfg.get("client_secret") or "").strip()
197
+ if not client_id or not client_secret:
198
+ return {"ok": False, "error": "github_oauth_not_configured"}
199
+ token_data = _github_exchange_code(
200
+ code,
201
+ client_id,
202
+ client_secret,
203
+ _github_oauth_redirect_uri(),
204
+ str(pending.get("code_verifier") or ""),
205
+ )
206
+ if token_data.get("error"):
207
+ return {"ok": False, "error": str(token_data.get("error"))}
208
+ access_token = str(token_data.get("access_token") or "").strip()
209
+ if not access_token:
210
+ return {"ok": False, "error": "github_missing_access_token"}
211
+ spec = provider_catalog.catalog_provider("github") or {}
212
+ env_var = str(spec.get("env_var") or "GITHUB_TOKEN")
213
+ payload = _srv()._store_user_credential(user_id, "github", "oauth", access_token, env_var, "GitHub OAuth")
214
+ cred_ref = str(payload["credential_ref"])
215
+ now = _srv()._utc_now()
216
+ cred_id = str(uuid.uuid4())
217
+ try:
218
+ conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=3.0)
219
+ conn.execute(
220
+ """
221
+ INSERT INTO credential_bindings
222
+ (id, workspace_id, user_id, agent_profile_id, provider, credential_type,
223
+ credential_ref, label, is_active, created_by, created_at, updated_at)
224
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
225
+ """,
226
+ (
227
+ cred_id, None, user_id, None, "github", "oauth", cred_ref,
228
+ "GitHub OAuth", 1, user_id, now, now,
229
+ ),
230
+ )
231
+ conn.commit()
232
+ conn.close()
233
+ except sqlite3.Error as exc:
234
+ return {"ok": False, "error": f"db_error: {exc}"}
235
+ return {"ok": True, "provider": "github", "credential_id": cred_id}
236
+
237
+
238
+ def _stripe_exchange_code(code: str, client_secret: str) -> dict[str, Any]:
239
+ body = urllib.parse.urlencode(
240
+ {
241
+ "client_secret": client_secret,
242
+ "code": code,
243
+ "grant_type": "authorization_code",
244
+ },
245
+ ).encode("utf-8")
246
+ req = urllib.request.Request(
247
+ "https://connect.stripe.com/oauth/token",
248
+ data=body,
249
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
250
+ method="POST",
251
+ )
252
+ try:
253
+ with urllib.request.urlopen(req, timeout=30) as resp:
254
+ raw = resp.read().decode("utf-8", errors="replace")
255
+ except urllib.error.HTTPError as exc:
256
+ raw = exc.read().decode("utf-8", errors="replace")
257
+ except OSError as exc:
258
+ return {"error": str(exc)}
259
+ try:
260
+ data = json.loads(raw)
261
+ except json.JSONDecodeError:
262
+ return {"error": raw or "stripe_token_exchange_failed"}
263
+ return data if isinstance(data, dict) else {"error": "invalid_stripe_response"}
264
+
265
+
266
+ def _start_stripe_oauth(user_id: str, workspace_id: str, spec: dict[str, Any]) -> dict[str, Any]:
267
+ cfg = _stripe_connect_app_config()
268
+ client_id = str(cfg.get("client_id") or "").strip()
269
+ client_secret = str(cfg.get("client_secret") or "").strip()
270
+ if not client_id or not client_secret:
271
+ return {
272
+ "ok": False,
273
+ "error": "stripe_connect_not_configured",
274
+ "message": "Workframe admin must register Stripe Connect under Workframe → Integrations.",
275
+ }
276
+ state = secrets.token_urlsafe(24)
277
+ _store_oauth_pending(state, user_id, "stripe", "", workspace_id)
278
+ params = urllib.parse.urlencode(
279
+ {
280
+ "response_type": "code",
281
+ "client_id": client_id,
282
+ "scope": STRIPE_CONNECT_SCOPES,
283
+ "redirect_uri": _stripe_oauth_redirect_uri(),
284
+ "state": state,
285
+ },
286
+ )
287
+ return {
288
+ "ok": True,
289
+ "provider": str(spec.get("id") or "stripe"),
290
+ "redirect_url": f"https://connect.stripe.com/oauth/authorize?{params}",
291
+ "output": "",
292
+ "error": None,
293
+ }
294
+
295
+
296
+ def _complete_stripe_oauth(user_id: str, code: str, state: str) -> dict[str, Any]:
297
+ pending = _take_oauth_pending(state)
298
+ if not pending or str(pending.get("user_id") or "") != user_id:
299
+ return {"ok": False, "error": "invalid_oauth_state"}
300
+ cfg = _stripe_connect_app_config()
301
+ client_secret = str(cfg.get("client_secret") or "").strip()
302
+ if not client_secret:
303
+ return {"ok": False, "error": "stripe_connect_not_configured"}
304
+ token_data = _stripe_exchange_code(code, client_secret)
305
+ if token_data.get("error"):
306
+ err = token_data.get("error")
307
+ if isinstance(err, dict):
308
+ return {"ok": False, "error": str(err.get("message") or err.get("description") or "stripe_oauth_failed")}
309
+ return {"ok": False, "error": str(err)}
310
+ access_token = str(token_data.get("access_token") or "").strip()
311
+ if not access_token:
312
+ return {"ok": False, "error": "stripe_missing_access_token"}
313
+ stripe_user_id = str(token_data.get("stripe_user_id") or "").strip()
314
+ spec = provider_catalog.catalog_provider("stripe") or {}
315
+ env_var = str(spec.get("env_var") or "STRIPE_SECRET_KEY")
316
+ label = f"Stripe Connect ({stripe_user_id})" if stripe_user_id else "Stripe Connect"
317
+ payload = _srv()._store_user_credential(user_id, "stripe", "oauth", access_token, env_var, label)
318
+ cred_ref = str(payload["credential_ref"])
319
+ now = _srv()._utc_now()
320
+ cred_id = str(uuid.uuid4())
321
+ try:
322
+ conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=3.0)
323
+ conn.execute(
324
+ """
325
+ INSERT INTO credential_bindings
326
+ (id, workspace_id, user_id, agent_profile_id, provider, credential_type,
327
+ credential_ref, label, is_active, created_by, created_at, updated_at)
328
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
329
+ """,
330
+ (
331
+ cred_id, None, user_id, None, "stripe", "oauth", cred_ref,
332
+ label, 1, user_id, now, now,
333
+ ),
334
+ )
335
+ conn.commit()
336
+ conn.close()
337
+ except sqlite3.Error as exc:
338
+ return {"ok": False, "error": f"db_error: {exc}"}
339
+ return {"ok": True, "provider": "stripe", "credential_id": cred_id, "stripe_user_id": stripe_user_id}
340
+ def _start_discord_oauth(user_id: str, workspace_id: str = "") -> dict[str, Any]:
341
+ cfg = platform_auth.resolved_discord_oauth()
342
+ if not cfg.get("client_id") or not cfg.get("client_secret"):
343
+ return {
344
+ "ok": False,
345
+ "error": "discord_oauth_not_configured",
346
+ "message": "Workframe admin must register a Discord OAuth app under Integrations.",
347
+ }
348
+ state = secrets.token_urlsafe(24)
349
+ _store_oauth_pending(state, user_id, "discord", "", workspace_id)
350
+ params = urllib.parse.urlencode(
351
+ {
352
+ "client_id": cfg["client_id"],
353
+ "redirect_uri": platform_auth.discord_redirect_uri(),
354
+ "response_type": "code",
355
+ "scope": "identify",
356
+ "state": state,
357
+ "prompt": "consent",
358
+ },
359
+ )
360
+ return {
361
+ "ok": True,
362
+ "provider": "discord",
363
+ "redirect_url": f"https://discord.com/api/oauth2/authorize?{params}",
364
+ "output": "",
365
+ "error": None,
366
+ }
367
+
368
+
369
+ def _complete_discord_oauth(user_id: str, code: str, state: str) -> dict[str, Any]:
370
+ pending = _take_oauth_pending(state)
371
+ if not pending or str(pending.get("user_id") or "") != user_id:
372
+ return {"ok": False, "error": "invalid_oauth_state"}
373
+ if str(pending.get("provider") or "") != "discord":
374
+ return {"ok": False, "error": "invalid_oauth_provider"}
375
+ result = platform_auth.complete_discord_link(str(code or ""))
376
+ if not result.get("ok"):
377
+ return result
378
+ patch = result.get("platform_ids") if isinstance(result.get("platform_ids"), dict) else {}
379
+ if patch:
380
+ _srv()._merge_user_platform_ids(user_id, {str(k): str(v) for k, v in patch.items()})
381
+ return {"ok": True, "provider": "discord", "platform_ids": patch}
@@ -12,7 +12,7 @@ _CACHE: dict[str, Any] = {"at": 0.0, "rows": []}
12
12
  _TTL_SEC = int(__import__("os").environ.get("WORKFRAME_OPENROUTER_CATALOG_TTL", "3600"))
13
13
 
14
14
 
15
- def _fetch_models(api_key: str) -> list[dict[str, str]]:
15
+ def _fetch_models(api_key: str, *, timeout: float = 45) -> list[dict[str, str]]:
16
16
  req = urllib.request.Request(
17
17
  "https://openrouter.ai/api/v1/models",
18
18
  headers={
@@ -22,7 +22,7 @@ def _fetch_models(api_key: str) -> list[dict[str, str]]:
22
22
  },
23
23
  method="GET",
24
24
  )
25
- with urllib.request.urlopen(req, timeout=45) as resp:
25
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
26
26
  data = json.loads(resp.read())
27
27
  rows: list[dict[str, str]] = []
28
28
  for item in data.get("data") or []:
@@ -48,7 +48,7 @@ def _fetch_models(api_key: str) -> list[dict[str, str]]:
48
48
  return rows[:120]
49
49
 
50
50
 
51
- def live_suggestions(api_key: str = "", *, limit: int = 40) -> list[dict[str, str]]:
51
+ def live_suggestions(api_key: str = "", *, limit: int = 40, timeout: float = 45) -> list[dict[str, str]]:
52
52
  """Return cached OpenRouter suggestions; empty without a key."""
53
53
  key = str(api_key or "").strip()
54
54
  if not key:
@@ -57,7 +57,7 @@ def live_suggestions(api_key: str = "", *, limit: int = 40) -> list[dict[str, st
57
57
  if _CACHE["rows"] and now - float(_CACHE["at"]) < _TTL_SEC:
58
58
  return list(_CACHE["rows"])[:limit]
59
59
  try:
60
- rows = _fetch_models(key)
60
+ rows = _fetch_models(key, timeout=timeout)
61
61
  except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError):
62
62
  return list(_CACHE["rows"])[:limit]
63
63
  _CACHE["at"] = now
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@workframe/workframe-api",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "python3 server.py",
8
8
  "start": "python3 server.py",
9
- "typecheck": "python3 -m py_compile server.py zk_auth.py email_sender.py",
9
+ "typecheck": "python -m py_compile server.py zk_auth.py email_sender.py profile_config_yaml.py route_registry.py domain/entities.py domain/__init__.py && python test_public_routes.py && python test_route_registry.py && python test_billing_provider.py && python test_model_surface_consistency.py && python test_profile_model_yaml.py && python test_domain_entities.py",
10
10
  "build": "python3 -m py_compile server.py zk_auth.py email_sender.py"
11
11
  }
12
12
  }
@@ -0,0 +1,66 @@
1
+ """WF-032 extract: ensure profile Hermes API is up (start/restart lifecycle)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import profile_gateway
8
+
9
+
10
+ def _srv():
11
+ import server as srv
12
+
13
+ return srv
14
+
15
+
16
+ def ensure_profile_api(
17
+ profile: str,
18
+ user_id: str = "",
19
+ workspace_id: str = "",
20
+ *,
21
+ bootstrap_providers: bool = True,
22
+ ) -> dict[str, Any]:
23
+ """Start profile gateway if down. ponytail: bind/chat must not mutate yaml or bootstrap creds."""
24
+ prof = _srv().resolve_hermes_profile(profile)
25
+ port = profile_gateway._profile_api_port(prof)
26
+ if profile_gateway._profile_api_healthy(prof):
27
+ return {"ok": True, "profile": prof, "api_port": port, "started": False}
28
+ # ponytail: gateway restart (~7s) before supervisor cold start (~37s).
29
+ if (
30
+ _srv()._is_runtime_profile_slug(prof)
31
+ and prof != _srv()._primary_profile()
32
+ and _srv()._runtime_profile_on_disk(prof)
33
+ ):
34
+ with _srv()._gateway_lifecycle_lock:
35
+ if profile_gateway._profile_api_healthy(prof, use_cache=False):
36
+ return {"ok": True, "profile": prof, "api_port": port, "started": False}
37
+ code, _out = _srv()._gateway_exec(prof, ["gateway", "restart"])
38
+ if code == 0:
39
+ _srv()._invalidate_profile_health_cache(prof)
40
+ if profile_gateway._wait_profile_api_healthy(prof, attempts=48, delay=0.25):
41
+ return {"ok": True, "profile": prof, "api_port": port, "started": False}
42
+ if prof == _srv()._primary_profile():
43
+ ok, out, _port = profile_gateway._configure_profile_api(prof)
44
+ if not ok:
45
+ raise ValueError(f"profile api config failed: {out}")
46
+ _srv()._restart_stack_gateway()
47
+ if not profile_gateway._wait_profile_api_healthy(prof):
48
+ raise ValueError(f"profile api did not become healthy: {prof}")
49
+ return {"ok": True, "profile": prof, "api_port": port, "started": True}
50
+ if bootstrap_providers and user_id:
51
+ _srv()._bootstrap_profile_providers(prof, user_id, workspace_id)
52
+ with _srv()._gateway_lifecycle_lock:
53
+ if profile_gateway._profile_api_healthy(prof):
54
+ return {"ok": True, "profile": prof, "api_port": port, "started": False}
55
+ result = profile_gateway.profile_gateway_lifecycle(
56
+ prof, "start", bootstrap_providers=bootstrap_providers
57
+ )
58
+ if not profile_gateway._wait_profile_api_healthy(prof):
59
+ raise ValueError(f"profile api did not become healthy: {prof}")
60
+ return {
61
+ **result,
62
+ "ok": True,
63
+ "profile": prof,
64
+ "api_port": port,
65
+ "started": True,
66
+ }