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,245 @@
1
+ #!/usr/bin/env python3
2
+ """Route registry audit (WF-037).
3
+
4
+ Run: python services/workframe-api/test_route_registry.py
5
+ """
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ ROOT = Path(__file__).resolve().parent
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ import route_registry # noqa: E402
14
+
15
+
16
+ def _handler_source_text() -> str:
17
+ """server.Handler bodies plus extracted handler_modules mixins (WF-032)."""
18
+ parts = [(ROOT / "server.py").read_text(encoding="utf-8")]
19
+ mods = ROOT / "handler_modules"
20
+ if mods.is_dir():
21
+ for path in sorted(mods.glob("*.py")):
22
+ if path.name == "__init__.py":
23
+ continue
24
+ parts.append(path.read_text(encoding="utf-8"))
25
+ return "\n".join(parts)
26
+
27
+
28
+ def _sample_path_from_label(label: str, pattern: re.Pattern[str] | None = None) -> str:
29
+ sample = (
30
+ label.replace("{id}", "sample-id")
31
+ .replace("{slug}", "sample-slug")
32
+ .replace("{token}", "sample-token")
33
+ .replace("{provider}", "github")
34
+ .replace("{cid}", "cred-1")
35
+ .replace("{path}", "docs/readme.md")
36
+ .replace("{kind}", "og")
37
+ )
38
+ if pattern is not None and not pattern.fullmatch(sample):
39
+ raise AssertionError(f"sample path does not match pattern: {sample} {label}")
40
+ return sample
41
+
42
+
43
+ # Auth level coverage (4-value enum)
44
+ assert route_registry.resolve_auth_level("GET", "/api/health") == route_registry.AuthLevel.PUBLIC
45
+ assert route_registry.resolve_auth_level("GET", "/api/meta") == route_registry.AuthLevel.PUBLIC
46
+ assert route_registry.resolve_auth_level("GET", "/api/snapshot") == route_registry.AuthLevel.SESSION
47
+ assert route_registry.resolve_auth_level("POST", "/api/auth/start") == route_registry.AuthLevel.PUBLIC
48
+ assert route_registry.resolve_auth_level("GET", "/api/me") == route_registry.AuthLevel.SESSION
49
+ assert route_registry.resolve_auth_level("GET", "/api/admin/vault/status") == route_registry.AuthLevel.ROLE_OWNER_ADMIN
50
+ assert route_registry.resolve_auth_level("GET", "/api/workspace/default/rooms") == route_registry.AuthLevel.SESSION
51
+
52
+ # Unknown /api paths are unregistered → None (404 at dispatch, not auth-gated)
53
+ assert route_registry.resolve_auth_level("GET", "/api/not-a-real-handler") is None
54
+ assert route_registry.authorize_request(
55
+ "GET",
56
+ "/api/not-a-real-handler",
57
+ "",
58
+ deployment_mode="trusted_team",
59
+ dev_local_unsafe=False,
60
+ install_window_open=False,
61
+ validate_session=lambda _s: None,
62
+ attach_user=lambda _u: None,
63
+ )
64
+
65
+ # Registered session route without session → 401
66
+ assert not route_registry.authorize_request(
67
+ "GET",
68
+ "/api/me",
69
+ "",
70
+ deployment_mode="trusted_team",
71
+ dev_local_unsafe=False,
72
+ install_window_open=False,
73
+ validate_session=lambda _s: None,
74
+ attach_user=lambda _u: None,
75
+ )
76
+
77
+ # ROUTES table: dispatched routes have handlers on server.Handler (+ handler_modules)
78
+ handler_src = _handler_source_text()
79
+ missing_handlers: list[str] = []
80
+ for route in route_registry.ROUTES:
81
+ if not route.handler:
82
+ continue
83
+ if f"def {route.handler}(" not in handler_src:
84
+ missing_handlers.append(f"{route.method} {route.path} -> {route.handler}")
85
+ assert not missing_handlers, f"missing handler methods: {missing_handlers}"
86
+
87
+ # Every pattern-backed route has a handler and auth via label-derived sample path
88
+ missing_pattern_handlers: list[str] = []
89
+ missing_pattern_auth: list[str] = []
90
+ for rp in route_registry.ROUTE_PATTERNS:
91
+ if not rp.handler:
92
+ missing_pattern_handlers.append(f"{rp.method} {rp.label}")
93
+ continue
94
+ if f"def {rp.handler}(" not in handler_src:
95
+ missing_pattern_handlers.append(f"{rp.method} {rp.label} -> {rp.handler}")
96
+ sample = _sample_path_from_label(rp.label, rp.pattern)
97
+ if route_registry.lookup_auth(rp.method, sample) is None:
98
+ missing_pattern_auth.append(f"{rp.method} {sample}")
99
+ assert not missing_pattern_handlers, f"missing pattern handlers: {missing_pattern_handlers}"
100
+ assert not missing_pattern_auth, f"missing pattern auth: {missing_pattern_auth}"
101
+
102
+ # Pattern handlers must not re-dispatch with legacy startswith guards
103
+ pattern_method_blocks = re.findall(
104
+ r"(def (_route_pattern_\w+)\([^)]*\)[^:]*:.*?)(?=\n def |\Z)",
105
+ handler_src,
106
+ flags=re.DOTALL,
107
+ )
108
+ legacy_guards: list[str] = []
109
+ for block, name in pattern_method_blocks:
110
+ if 'path.startswith("/api/' in block or "path.startswith('/api/" in block:
111
+ legacy_guards.append(name)
112
+ assert not legacy_guards, f"legacy startswith guards in pattern handlers: {legacy_guards}"
113
+
114
+ # Every if-chain literal /api path must be registered with explicit auth
115
+ server_src = (ROOT / "server.py").read_text(encoding="utf-8")
116
+ handler_paths = set(re.findall(r'if path == "(/api/[^"]+)"', server_src))
117
+ handler_paths |= set(re.findall(r"if path == \'(/api/[^\']+)\'", server_src))
118
+ unregistered: list[str] = []
119
+ for p in sorted(handler_paths):
120
+ if route_registry.lookup_auth("GET", p) is None and route_registry.lookup_auth("POST", p) is None:
121
+ unregistered.append(p)
122
+ assert not unregistered, f"unregistered auth for if-chain paths: {unregistered}"
123
+
124
+ # Pattern-backed routes from startswith / fullmatch in do_GET/do_POST
125
+ _pattern_probes = [
126
+ ("GET", "/api/public/branding/og"),
127
+ ("GET", "/api/workspace/ws-1/rooms"),
128
+ ("GET", "/api/rooms/room-1/messages"),
129
+ ("POST", "/api/rooms/room-1/messages/send"),
130
+ ("PATCH", "/api/workspace/ws-1"),
131
+ ("DELETE", "/api/memory/mem-1"),
132
+ ]
133
+ for method, path in _pattern_probes:
134
+ assert route_registry.lookup_auth(method, path) is not None, f"missing pattern auth: {method} {path}"
135
+
136
+ # sessionless_ok data GETs
137
+ snap = route_registry.lookup_auth("GET", "/api/snapshot")
138
+ assert snap is not None and snap.sessionless_ok
139
+ assert not route_registry.authorize_request(
140
+ "GET",
141
+ "/api/agents",
142
+ "",
143
+ deployment_mode="trusted_team",
144
+ dev_local_unsafe=False,
145
+ install_window_open=False,
146
+ validate_session=lambda _s: None,
147
+ attach_user=lambda _u: None,
148
+ )
149
+ assert route_registry.authorize_request(
150
+ "GET",
151
+ "/api/agents",
152
+ "sid-ok",
153
+ deployment_mode="trusted_team",
154
+ dev_local_unsafe=False,
155
+ install_window_open=False,
156
+ validate_session=lambda s: {"user_id": "u1"} if s == "sid-ok" else None,
157
+ attach_user=lambda _u: None,
158
+ )
159
+
160
+ # owner/admin gate
161
+ assert not route_registry.authorize_request(
162
+ "GET",
163
+ "/api/admin/vault/status",
164
+ "sid-ok",
165
+ deployment_mode="trusted_team",
166
+ dev_local_unsafe=False,
167
+ install_window_open=False,
168
+ validate_session=lambda s: {"user_id": "u1"} if s == "sid-ok" else None,
169
+ attach_user=lambda _u: None,
170
+ get_workspace_role=lambda: "member",
171
+ )
172
+ assert route_registry.authorize_request(
173
+ "GET",
174
+ "/api/admin/vault/status",
175
+ "sid-ok",
176
+ deployment_mode="trusted_team",
177
+ dev_local_unsafe=False,
178
+ install_window_open=False,
179
+ validate_session=lambda s: {"user_id": "u1"} if s == "sid-ok" else None,
180
+ attach_user=lambda _u: None,
181
+ get_workspace_role=lambda: "owner",
182
+ )
183
+
184
+ dispatched_get = [r for r in route_registry.ROUTES if r.method == "GET" and r.handler]
185
+ dispatched_post = [r for r in route_registry.ROUTES if r.method == "POST" and r.handler]
186
+ dispatched_patch = [r for r in route_registry.ROUTES if r.method == "PATCH" and r.handler]
187
+ pattern_dispatched = [rp for rp in route_registry.ROUTE_PATTERNS if rp.handler]
188
+ assert len(dispatched_get) >= 50, f"unexpected GET dispatch count: {len(dispatched_get)}"
189
+ assert len(dispatched_post) >= 40, f"unexpected POST dispatch count: {len(dispatched_post)}"
190
+ assert len(dispatched_patch) >= 4, f"unexpected PATCH dispatch count: {len(dispatched_patch)}"
191
+ assert len(pattern_dispatched) >= 60, f"unexpected pattern dispatch count: {len(pattern_dispatched)}"
192
+ assert len(dispatched_get) + len(dispatched_post) + len(dispatched_patch) + len(pattern_dispatched) >= 160
193
+
194
+ class _PostStub:
195
+ def __init__(self) -> None:
196
+ self.called = False
197
+ self.body: dict | None = None
198
+
199
+ def _route_post_auth_start(self, body: dict) -> None:
200
+ self.called = True
201
+ self.body = body
202
+
203
+ _stub = _PostStub()
204
+ assert route_registry.dispatch_post(_stub, "/api/auth/start", {"email": "a@b.c"})
205
+ assert _stub.called and _stub.body == {"email": "a@b.c"}
206
+ assert not route_registry.dispatch_post(_stub, "/api/not-registered", {})
207
+
208
+ class _GetStub:
209
+ def __init__(self) -> None:
210
+ self.called = False
211
+ self.pattern_called = False
212
+
213
+ def _route_get_admin_vault_status(self, qs: dict) -> None:
214
+ self.called = True
215
+
216
+ def _route_pattern_get_workspace_events(self, path: str, qs: dict) -> None:
217
+ self.pattern_called = True
218
+
219
+ _get_stub = _GetStub()
220
+ assert route_registry.dispatch_get(_get_stub, "/api/admin/vault/status", {})
221
+ assert _get_stub.called
222
+ assert route_registry.dispatch_get(_get_stub, "/api/admin/vault/status", {}) # idempotent call ok
223
+
224
+ assert route_registry.dispatch_pattern(
225
+ "GET", _get_stub, "/api/workspace/ws-1/events", qs={},
226
+ ) is True
227
+ assert _get_stub.pattern_called
228
+
229
+ class _PatchStub:
230
+ def __init__(self) -> None:
231
+ self.called = False
232
+
233
+ def _route_patch_me(self, body: dict) -> None:
234
+ self.called = True
235
+
236
+ _patch_stub = _PatchStub()
237
+ assert route_registry.dispatch_patch(_patch_stub, "/api/me", {"display_name": "x"})
238
+ assert _patch_stub.called
239
+
240
+ # No legacy frozenset allowlists
241
+ assert not hasattr(route_registry, "GET_ALWAYS_PUBLIC_ROUTES")
242
+ assert not hasattr(route_registry, "GET_SINGLE_USER_PUBLIC_ROUTES")
243
+ assert not hasattr(route_registry, "GET_PUBLIC_ROUTES")
244
+
245
+ print("route registry self-check ok")
@@ -0,0 +1,112 @@
1
+ """WF-009 RunAuthorityGate self-check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
9
+
10
+ from domain.entities import ActorType, FundingSource, RunSurface # noqa: E402
11
+ from run_authority import ( # noqa: E402
12
+ DENY_DELEGATION,
13
+ DENY_NO_CREDENTIAL_BYOK,
14
+ DENY_NO_CREDENTIAL_COMPANY,
15
+ DENY_PROVIDER_USER_ONLY,
16
+ RunAuthorityContext,
17
+ RunAuthorityRequest,
18
+ evaluate_run_authority,
19
+ )
20
+
21
+
22
+ def _req() -> RunAuthorityRequest:
23
+ return RunAuthorityRequest(
24
+ surface=RunSurface.CHAT,
25
+ actor_type=ActorType.USER,
26
+ actor_id="user-1",
27
+ triggering_user_id="user-1",
28
+ profile_slug="u-user-1-native",
29
+ workspace_id="ws-1",
30
+ provider="openrouter",
31
+ )
32
+
33
+
34
+ def test_byok_user_cred() -> None:
35
+ decision = evaluate_run_authority(
36
+ _req(),
37
+ RunAuthorityContext(user_has_credential=True),
38
+ run_id="run-1",
39
+ )
40
+ assert decision.allowed
41
+ assert decision.payer_user_id == "user-1"
42
+ assert decision.funding_source == FundingSource.BYOK
43
+
44
+
45
+ def test_byok_no_cred_denied() -> None:
46
+ decision = evaluate_run_authority(_req(), RunAuthorityContext())
47
+ assert not decision.allowed
48
+ assert decision.deny_reason == DENY_NO_CREDENTIAL_BYOK
49
+
50
+
51
+ def test_company_workspace_cred() -> None:
52
+ decision = evaluate_run_authority(
53
+ _req(),
54
+ RunAuthorityContext(workspace_credential_mode="workspace", workspace_has_credential=True),
55
+ )
56
+ assert decision.allowed
57
+ assert decision.funding_source == FundingSource.COMPANY
58
+ assert decision.credential_scope == "workspace"
59
+
60
+
61
+ def test_company_user_cred_wins() -> None:
62
+ decision = evaluate_run_authority(
63
+ _req(),
64
+ RunAuthorityContext(
65
+ workspace_credential_mode="workspace",
66
+ user_has_credential=True,
67
+ workspace_has_credential=True,
68
+ ),
69
+ )
70
+ assert decision.allowed
71
+ assert decision.funding_source == FundingSource.BYOK
72
+
73
+
74
+ def test_user_only_no_fallback() -> None:
75
+ decision = evaluate_run_authority(
76
+ _req(),
77
+ RunAuthorityContext(provider_user_only=True, workspace_has_credential=True),
78
+ )
79
+ assert not decision.allowed
80
+ assert decision.deny_reason == DENY_PROVIDER_USER_ONLY
81
+
82
+
83
+ def test_delegation_grantor() -> None:
84
+ decision = evaluate_run_authority(
85
+ _req(),
86
+ RunAuthorityContext(grantor_has_credential={"owner-1": True}),
87
+ )
88
+ assert decision.allowed
89
+ assert decision.payer_user_id == "owner-1"
90
+
91
+
92
+ def test_delegation_no_grantor_cred() -> None:
93
+ decision = evaluate_run_authority(
94
+ _req(),
95
+ RunAuthorityContext(
96
+ workspace_credential_mode="workspace",
97
+ grantor_has_credential={"owner-1": False},
98
+ ),
99
+ )
100
+ assert not decision.allowed
101
+ assert decision.deny_reason == DENY_NO_CREDENTIAL_COMPANY
102
+
103
+
104
+ if __name__ == "__main__":
105
+ test_byok_user_cred()
106
+ test_byok_no_cred_denied()
107
+ test_company_workspace_cred()
108
+ test_company_user_cred_wins()
109
+ test_user_only_no_fallback()
110
+ test_delegation_grantor()
111
+ test_delegation_no_grantor_cred()
112
+ print("test_run_authority: ok")
@@ -0,0 +1,157 @@
1
+ """WF-NS-P2 run ledger self-check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sqlite3
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+
14
+ import run_ledger # noqa: E402
15
+ from domain.entities import ( # noqa: E402
16
+ ActorType,
17
+ FundingSource,
18
+ RunStatus,
19
+ RunSurface,
20
+ )
21
+ from run_authority import RunAuthorityDecision, chat_run_request # noqa: E402
22
+
23
+
24
+ def test_schema_and_round_trip() -> None:
25
+ with tempfile.TemporaryDirectory() as tmp:
26
+ db_path = Path(tmp) / "workframe.db"
27
+ os.environ["WORKFRAME_API_DATA_DIR"] = tmp
28
+ run_ledger.WORKFRAME_DB = db_path
29
+ run_ledger._SCHEMA_READY.clear()
30
+ run_ledger.ensure_schema()
31
+
32
+ conn = sqlite3.connect(str(db_path))
33
+ conn.row_factory = sqlite3.Row
34
+ try:
35
+ decision = RunAuthorityDecision(
36
+ allowed=True,
37
+ deny_reason=None,
38
+ payer_user_id="user-1",
39
+ funding_source=FundingSource.BYOK,
40
+ credential_ref_id=None,
41
+ credential_scope="user",
42
+ grants=(),
43
+ )
44
+ run_ledger.record_authority_decision(
45
+ conn,
46
+ run_id="run-test-1",
47
+ request_surface=RunSurface.CHAT,
48
+ actor_type=ActorType.USER,
49
+ actor_id="user-1",
50
+ triggering_user_id="user-1",
51
+ workspace_id="ws-1",
52
+ agent_id="agent-1",
53
+ runtime_binding_id="bind-1",
54
+ profile_slug="u-user-1-native",
55
+ provider="openrouter",
56
+ room_id="room-1",
57
+ session_id="sess-1",
58
+ decision=decision,
59
+ )
60
+ conn.commit()
61
+
62
+ run = run_ledger.get_run(conn, "run-test-1")
63
+ assert run is not None
64
+ assert run.status == RunStatus.RUNNING
65
+ assert run.funding_source == FundingSource.BYOK
66
+
67
+ run_ledger.complete_run(
68
+ conn,
69
+ "run-test-1",
70
+ model="test/model",
71
+ provider="openrouter",
72
+ funding_source=FundingSource.BYOK,
73
+ payer_user_id="user-1",
74
+ )
75
+ conn.commit()
76
+
77
+ items = conn.execute(
78
+ "SELECT kind, funding_source, amount_usd, receipt_json FROM run_line_items WHERE run_id = ?",
79
+ ("run-test-1",),
80
+ ).fetchall()
81
+ assert len(items) == 1
82
+ assert str(items[0]["kind"]) == "llm_turn"
83
+ assert items[0]["amount_usd"] is None
84
+ receipt = json.loads(str(items[0]["receipt_json"]))
85
+ assert receipt["run_id"] == "run-test-1"
86
+ assert receipt["payer_user_id"] == "user-1"
87
+ finally:
88
+ conn.close()
89
+
90
+ events = run_ledger.list_run_events_for_room("room-1")
91
+ assert len(events) >= 2
92
+ assert events[0]["source"] == "run_ledger"
93
+
94
+
95
+ def test_dm_room_chat_run_recorded() -> None:
96
+ """WF-NS-P2: DM rooms (room_type=direct) share chat_stream run path."""
97
+ dm_room_id = "550e8400-e29b-41d4-a716-446655440099"
98
+ req = chat_run_request(
99
+ triggering_user_id="user-a",
100
+ profile_slug="u-user-a-native",
101
+ workspace_id="ws-1",
102
+ provider="openrouter",
103
+ room_id=dm_room_id,
104
+ )
105
+ assert req.surface == RunSurface.CHAT
106
+
107
+ with tempfile.TemporaryDirectory() as tmp:
108
+ db_path = Path(tmp) / "workframe.db"
109
+ os.environ["WORKFRAME_API_DATA_DIR"] = tmp
110
+ run_ledger.WORKFRAME_DB = db_path
111
+ run_ledger._SCHEMA_READY.clear()
112
+ run_ledger.ensure_schema()
113
+
114
+ conn = sqlite3.connect(str(db_path))
115
+ conn.row_factory = sqlite3.Row
116
+ try:
117
+ decision = RunAuthorityDecision(
118
+ allowed=True,
119
+ deny_reason=None,
120
+ payer_user_id="user-a",
121
+ funding_source=FundingSource.BYOK,
122
+ credential_ref_id=None,
123
+ credential_scope="user",
124
+ grants=(),
125
+ )
126
+ run_ledger.record_authority_decision(
127
+ conn,
128
+ run_id="run-dm-1",
129
+ request_surface=req.surface,
130
+ actor_type=req.actor_type,
131
+ actor_id=req.actor_id,
132
+ triggering_user_id="user-a",
133
+ workspace_id="ws-1",
134
+ agent_id=req.agent_id,
135
+ runtime_binding_id=req.runtime_binding_id,
136
+ profile_slug=req.profile_slug,
137
+ provider="openrouter",
138
+ room_id=dm_room_id,
139
+ session_id="sess-dm-1",
140
+ decision=decision,
141
+ )
142
+ conn.commit()
143
+ row = conn.execute(
144
+ "SELECT surface, room_id FROM runs WHERE run_id = ?",
145
+ ("run-dm-1",),
146
+ ).fetchone()
147
+ assert row is not None
148
+ assert row["surface"] == RunSurface.CHAT.value
149
+ assert row["room_id"] == dm_room_id
150
+ finally:
151
+ conn.close()
152
+
153
+
154
+ if __name__ == "__main__":
155
+ test_schema_and_round_trip()
156
+ test_dm_room_chat_run_recorded()
157
+ print("test_run_ledger: ok")
@@ -0,0 +1,130 @@
1
+ """WF-NS-P2 run surface wiring self-check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sqlite3
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+
13
+ import run_ledger # noqa: E402
14
+ import run_surface_wiring # noqa: E402
15
+ import server # noqa: E402
16
+ from domain.entities import ActorType, RunSurface # noqa: E402
17
+
18
+
19
+ def _use_tmp_db(tmp: str) -> Path:
20
+ db_path = Path(tmp) / "workframe.db"
21
+ data = Path(tmp)
22
+ os.environ["WORKFRAME_API_DATA_DIR"] = tmp
23
+ server.DATA_DIR = data
24
+ server.AUTH_DB_PATH = data / "auth.db"
25
+ run_ledger.DATA_DIR = data
26
+ run_ledger.WORKFRAME_DB = db_path
27
+ run_ledger._SCHEMA_READY.clear()
28
+ return db_path
29
+
30
+
31
+ def _line_items_for_run(db_path: Path, run_id: str) -> list[sqlite3.Row]:
32
+ conn = sqlite3.connect(str(db_path))
33
+ conn.row_factory = sqlite3.Row
34
+ try:
35
+ return conn.execute(
36
+ "SELECT kind, amount_usd, receipt_json FROM run_line_items WHERE run_id = ?",
37
+ (run_id,),
38
+ ).fetchall()
39
+ finally:
40
+ conn.close()
41
+
42
+
43
+ def test_slash_audit_run_no_gate() -> None:
44
+ with tempfile.TemporaryDirectory() as tmp:
45
+ _use_tmp_db(tmp)
46
+ assert run_surface_wiring.slash_requires_authority("help") is False
47
+ assert run_surface_wiring.slash_requires_authority("chat") is True
48
+
49
+
50
+ def test_record_automated_cron() -> None:
51
+ with tempfile.TemporaryDirectory() as tmp:
52
+ db_path = _use_tmp_db(tmp)
53
+ run_ledger.ensure_schema()
54
+ result = run_surface_wiring.record_automated_surface_run(
55
+ {
56
+ "surface": "cron",
57
+ "profile_slug": "u-test-dev",
58
+ "triggering_user_id": "user-1",
59
+ "workspace_id": "ws-1",
60
+ "actor_id": "cron:daily",
61
+ "provider": "openrouter",
62
+ "event_type": "cron.triggered",
63
+ "payload": {"job": "daily"},
64
+ }
65
+ )
66
+ assert result["ok"] is True
67
+ rid = str(result["run_id"])
68
+ conn = sqlite3.connect(str(db_path))
69
+ conn.row_factory = sqlite3.Row
70
+ row = conn.execute("SELECT surface, status FROM runs WHERE run_id = ?", (rid,)).fetchone()
71
+ conn.close()
72
+ assert row is not None
73
+ assert row["surface"] == RunSurface.CRON.value
74
+ assert row["status"] == "completed"
75
+ items = _line_items_for_run(db_path, rid)
76
+ assert len(items) == 1
77
+ assert items[0]["amount_usd"] is None
78
+
79
+
80
+ def test_finish_surface_run_emits_receipt() -> None:
81
+ with tempfile.TemporaryDirectory() as tmp:
82
+ db_path = _use_tmp_db(tmp)
83
+ run_ledger.ensure_schema()
84
+ rid = run_surface_wiring.record_audit_surface_run(
85
+ surface=RunSurface.SLASH,
86
+ actor_type=ActorType.USER,
87
+ actor_id="user-1",
88
+ triggering_user_id="user-1",
89
+ profile_slug="u-test-dev",
90
+ workspace_id="ws-1",
91
+ event_type="slash.command",
92
+ payload={"command": "help"},
93
+ provider="openrouter",
94
+ )
95
+ run_surface_wiring.finish_surface_run(rid, ok=True, detail="ok")
96
+ items = _line_items_for_run(db_path, rid)
97
+ assert len(items) == 1
98
+ assert items[0]["amount_usd"] is None
99
+
100
+
101
+ def test_record_automated_webhook() -> None:
102
+ with tempfile.TemporaryDirectory() as tmp:
103
+ _use_tmp_db(tmp)
104
+ run_ledger.ensure_schema()
105
+ result = run_surface_wiring.record_automated_surface_run(
106
+ {
107
+ "surface": "webhook",
108
+ "profile_slug": "u-test-dev",
109
+ "triggering_user_id": "user-1",
110
+ "workspace_id": "ws-1",
111
+ "actor_id": "webhook:github",
112
+ "event_type": "webhook.received",
113
+ "provider": "openrouter",
114
+ "payload": {"source": "github"},
115
+ }
116
+ )
117
+ assert result["ok"] is True
118
+ assert result["surface"] == RunSurface.WEBHOOK.value
119
+ rid = str(result["run_id"])
120
+ items = _line_items_for_run(Path(tmp) / "workframe.db", rid)
121
+ assert len(items) == 1
122
+ assert items[0]["amount_usd"] is None
123
+
124
+
125
+ if __name__ == "__main__":
126
+ test_slash_audit_run_no_gate()
127
+ test_record_automated_cron()
128
+ test_record_automated_webhook()
129
+ test_finish_surface_run_emits_receipt()
130
+ print("test_run_surface_wiring ok")