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.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +9 -0
- package/scripts/verify-public-deploy.sh +121 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +470 -0
- package/workframe-api/run-typecheck.mjs +26 -0
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1376 -19305
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +117 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +245 -0
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -0
- package/workframe-api/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -0
- package/workframe-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -6
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Local bootstrap must require a real owner email — no synthetic placeholders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
API_DIR = Path(__file__).resolve().parent
|
|
11
|
+
if str(API_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(API_DIR))
|
|
13
|
+
|
|
14
|
+
os.environ.setdefault("WORKFRAME_API_DATA_DIR", str(API_DIR / ".tmp-test-data"))
|
|
15
|
+
os.environ.setdefault("HERMES_DATA", str(API_DIR / ".tmp-test-hermes"))
|
|
16
|
+
os.environ.setdefault("DEV_LOCAL_UNSAFE", "true")
|
|
17
|
+
os.environ["DEPLOYMENT_MODE"] = "single_user_local"
|
|
18
|
+
|
|
19
|
+
import server # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _CaptureHandler(server.Handler):
|
|
23
|
+
captured: tuple[int, dict] | None = None
|
|
24
|
+
|
|
25
|
+
def _json(self, status: int, payload: dict, **kwargs) -> None: # type: ignore[override]
|
|
26
|
+
type(self).captured = (status, payload)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_local_bootstrap_rejects_missing_email() -> None:
|
|
30
|
+
handler = _CaptureHandler.__new__(_CaptureHandler)
|
|
31
|
+
with patch.object(server, "DEPLOYMENT_MODE", "single_user_local"), patch.object(
|
|
32
|
+
server, "_install_window_open", return_value=True,
|
|
33
|
+
):
|
|
34
|
+
handler._route_post_auth_local_bootstrap({})
|
|
35
|
+
assert _CaptureHandler.captured is not None
|
|
36
|
+
status, body = _CaptureHandler.captured
|
|
37
|
+
assert status == 400
|
|
38
|
+
assert body.get("error") == "email_required"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_local_bootstrap_uses_submitted_email() -> None:
|
|
42
|
+
handler = _CaptureHandler.__new__(_CaptureHandler)
|
|
43
|
+
with patch.object(server, "DEPLOYMENT_MODE", "single_user_local"), patch.object(
|
|
44
|
+
server, "_install_window_open", return_value=True,
|
|
45
|
+
), patch.object(
|
|
46
|
+
server._zk,
|
|
47
|
+
"create_session_for_email",
|
|
48
|
+
return_value={"user_id": "u-test", "session_id": "s-test", "refresh_token": "r-test"},
|
|
49
|
+
) as create_session, patch.object(handler, "_ensure_user"), patch.object(
|
|
50
|
+
handler, "_first_owner_bootstrap",
|
|
51
|
+
), patch.object(server, "_session_profile_payload", return_value={"ok": True}), patch.object(
|
|
52
|
+
server, "_session_cookie_secure", return_value=False,
|
|
53
|
+
), patch.object(
|
|
54
|
+
server._zk, "session_cookie_value", return_value="cookie=test",
|
|
55
|
+
):
|
|
56
|
+
handler._route_post_auth_local_bootstrap(
|
|
57
|
+
{"email": "Owner@Example.com", "display_name": "Owner"},
|
|
58
|
+
)
|
|
59
|
+
create_session.assert_called_once_with("owner@example.com")
|
|
60
|
+
assert _CaptureHandler.captured is not None
|
|
61
|
+
assert _CaptureHandler.captured[0] == 200
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
test_local_bootstrap_rejects_missing_email()
|
|
66
|
+
test_local_bootstrap_uses_submitted_email()
|
|
67
|
+
print("ok")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""WF-032 mention_helpers 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
|
+
import mention_helpers # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_mention_handle() -> None:
|
|
14
|
+
assert mention_helpers.mention_handle("Alice Smith") == "alicesmith"
|
|
15
|
+
assert mention_helpers.mention_handle("", "alice@example.com") == "alice"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_parse_room_mentions() -> None:
|
|
19
|
+
agents = [{"id": "a1", "slug": "dev", "display_name": "Dev Agent"}]
|
|
20
|
+
result = mention_helpers.parse_room_mentions("@dev please review", agents, [])
|
|
21
|
+
assert len(result["agents"]) == 1
|
|
22
|
+
assert result["agents"][0]["slug"] == "dev"
|
|
23
|
+
assert result["users"] == []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_parse_mentions() -> None:
|
|
27
|
+
rows = mention_helpers.parse_mentions("hello @architect and @dev")
|
|
28
|
+
assert [r["slug"] for r in rows] == ["architect", "dev"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
test_mention_handle()
|
|
33
|
+
test_parse_room_mentions()
|
|
34
|
+
test_parse_mentions()
|
|
35
|
+
print("test_mention_helpers: ok")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""WF-032 mention_invoke 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
|
+
import mention_invoke # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_invoke_mention_missing_agent() -> None:
|
|
14
|
+
"""No-op when agent row lacks slug/id."""
|
|
15
|
+
mention_invoke._invoke_room_agent_mention(
|
|
16
|
+
"room-1",
|
|
17
|
+
"ws-1",
|
|
18
|
+
{"slug": "", "id": ""},
|
|
19
|
+
"",
|
|
20
|
+
"msg-1",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
test_invoke_mention_missing_agent()
|
|
26
|
+
print("test_mention_invoke: ok")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for model save/provider surface (WF-034).
|
|
3
|
+
|
|
4
|
+
Run: python services/workframe-api/test_model_surface_consistency.py
|
|
5
|
+
"""
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parent
|
|
13
|
+
tmpdir = tempfile.mkdtemp(prefix="wf-model-surface-")
|
|
14
|
+
os.environ["WORKFRAME_AGENTS_DIR"] = tmpdir
|
|
15
|
+
os.environ["WORKFRAME_PROJECT"] = "ModelSurfaceTest"
|
|
16
|
+
os.environ["NATIVE_PROFILE"] = "surface-test-agent"
|
|
17
|
+
|
|
18
|
+
spec = importlib.util.spec_from_file_location("wf_server_model_surface", ROOT / "server.py")
|
|
19
|
+
server = importlib.util.module_from_spec(spec)
|
|
20
|
+
sys.modules["wf_server_model_surface"] = server
|
|
21
|
+
spec.loader.exec_module(server)
|
|
22
|
+
sys.modules["server"] = server # ponytail: extracted modules resolve _srv() to this instance
|
|
23
|
+
|
|
24
|
+
PROF = "surface-test-agent"
|
|
25
|
+
prof_dir = server._profile_dir(PROF)
|
|
26
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
(prof_dir / "config.yaml").write_text(
|
|
28
|
+
"model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
|
|
29
|
+
encoding="utf-8",
|
|
30
|
+
)
|
|
31
|
+
cfg_path = prof_dir / "config.yaml"
|
|
32
|
+
before = cfg_path.read_bytes()
|
|
33
|
+
|
|
34
|
+
# A: GET models must not mutate config.yaml
|
|
35
|
+
server.hermes_models(PROF, user_id="", workspace_id="")
|
|
36
|
+
assert cfg_path.read_bytes() == before, "hermes_models must not rewrite config on read"
|
|
37
|
+
|
|
38
|
+
# B: reconcile idempotent when routing already matches codex oauth
|
|
39
|
+
assert server._profile_routing_matches_billing(PROF, "codex")
|
|
40
|
+
assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is False
|
|
41
|
+
|
|
42
|
+
# C: billing resolution honors prefer= when model exists in that catalog
|
|
43
|
+
connected = {"codex", "openrouter"}
|
|
44
|
+
assert server._resolve_billing_provider_for_model(
|
|
45
|
+
"gpt-5.4-medium", connected, prefer="codex",
|
|
46
|
+
) == "codex"
|
|
47
|
+
assert server._resolve_billing_provider_for_model(
|
|
48
|
+
"openrouter/owl-alpha", connected, prefer="openrouter",
|
|
49
|
+
) == "openrouter"
|
|
50
|
+
assert server._resolve_billing_provider_for_model(
|
|
51
|
+
"openai-codex/gpt-5.4-mini", {"codex", "openrouter"},
|
|
52
|
+
) == "codex"
|
|
53
|
+
|
|
54
|
+
# D: set with explicit billing_provider returns billing id in response
|
|
55
|
+
applied = server.hermes_model_set(
|
|
56
|
+
PROF, "gpt-5.4-medium", user_id="", workspace_id="", billing_provider="codex",
|
|
57
|
+
)
|
|
58
|
+
assert applied.get("ok"), applied
|
|
59
|
+
assert applied.get("billing_provider") == "codex"
|
|
60
|
+
assert applied.get("provider") == "codex"
|
|
61
|
+
|
|
62
|
+
# E: reconcile picks model billing over stale openrouter proxy yaml
|
|
63
|
+
(prof_dir / "config.yaml").write_text(
|
|
64
|
+
"model:\n default: openai-codex/gpt-5.4-mini\n provider: custom\n"
|
|
65
|
+
" base_url: http://workframe-api:8080/internal/llm/openrouter/v1\n",
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
_orig_picker = server._user_llm_providers_for_picker
|
|
69
|
+
_orig_can = server._user_can_use_llm
|
|
70
|
+
_orig_sync = server._sync_oauth_llm_to_profile
|
|
71
|
+
server._user_llm_providers_for_picker = lambda uid: {"codex"} if uid == "user-1" else set()
|
|
72
|
+
server._user_can_use_llm = lambda uid, ws, prov: uid == "user-1" and prov == "codex"
|
|
73
|
+
server._sync_oauth_llm_to_profile = lambda *a, **k: None
|
|
74
|
+
try:
|
|
75
|
+
assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is True
|
|
76
|
+
block = server._read_model_block(PROF)
|
|
77
|
+
assert block.get("provider") == "openai-codex"
|
|
78
|
+
assert block.get("default") == "gpt-5.4-mini"
|
|
79
|
+
assert not block.get("base_url")
|
|
80
|
+
finally:
|
|
81
|
+
server._user_llm_providers_for_picker = _orig_picker
|
|
82
|
+
server._user_can_use_llm = _orig_can
|
|
83
|
+
server._sync_oauth_llm_to_profile = _orig_sync
|
|
84
|
+
|
|
85
|
+
# F: runtime disk wins over template in _read_model_block
|
|
86
|
+
runtime_prof = "u-test-user-surface-test-agent"
|
|
87
|
+
runtime_dir = server._profile_dir(runtime_prof)
|
|
88
|
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
(runtime_dir / "config.yaml").write_text(
|
|
90
|
+
"model:\n default: gpt-5.4-mini\n provider: openai-codex\n",
|
|
91
|
+
encoding="utf-8",
|
|
92
|
+
)
|
|
93
|
+
block = server._read_model_block(runtime_prof)
|
|
94
|
+
assert block.get("default") == "gpt-5.4-mini", block
|
|
95
|
+
assert block.get("provider") == "openai-codex", block
|
|
96
|
+
|
|
97
|
+
# G: template model save mirrors to runtime (same model id, not stale template re-read)
|
|
98
|
+
runtime_prof = "u-test-user2-surface-test-agent"
|
|
99
|
+
runtime_dir = server._profile_dir(runtime_prof)
|
|
100
|
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
(runtime_dir / "config.yaml").write_text(
|
|
102
|
+
"model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
)
|
|
105
|
+
_orig_restart = server._restart_runtime_profile_gateway
|
|
106
|
+
server._restart_runtime_profile_gateway = lambda *a, **k: None
|
|
107
|
+
try:
|
|
108
|
+
applied = server.hermes_model_set(
|
|
109
|
+
PROF, "gpt-5.4-mini", user_id="test-user2", workspace_id="", billing_provider="codex",
|
|
110
|
+
)
|
|
111
|
+
finally:
|
|
112
|
+
server._restart_runtime_profile_gateway = _orig_restart
|
|
113
|
+
assert applied.get("ok"), applied
|
|
114
|
+
runtime_block = server._parse_model_block_from_disk(runtime_prof)
|
|
115
|
+
assert runtime_block.get("default") == "gpt-5.4-mini", runtime_block
|
|
116
|
+
|
|
117
|
+
print("model surface consistency self-check ok")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""WF-032 oauth_pending store/take self-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
11
|
+
|
|
12
|
+
import oauth_pending # noqa: E402
|
|
13
|
+
import server # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_store_take_round_trip() -> None:
|
|
17
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
18
|
+
os.environ["WORKFRAME_API_DATA_DIR"] = tmp
|
|
19
|
+
server.DATA_DIR = Path(tmp)
|
|
20
|
+
server.AUTH_DB_PATH = Path(tmp) / "auth.db"
|
|
21
|
+
server._ensure_workframe_db_schema()
|
|
22
|
+
oauth_pending.store_pending("state-1", "user-1", "github", "verifier-abc", "ws-1")
|
|
23
|
+
row = oauth_pending.take_pending("state-1")
|
|
24
|
+
assert row is not None
|
|
25
|
+
assert row["user_id"] == "user-1"
|
|
26
|
+
assert row["provider"] == "github"
|
|
27
|
+
assert row["code_verifier"] == "verifier-abc"
|
|
28
|
+
assert oauth_pending.take_pending("state-1") is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_pkce_pair() -> None:
|
|
32
|
+
verifier, challenge = oauth_pending.pkce_pair()
|
|
33
|
+
assert len(verifier) > 20
|
|
34
|
+
assert len(challenge) > 20
|
|
35
|
+
assert verifier != challenge
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
test_pkce_pair()
|
|
40
|
+
test_store_take_round_trip()
|
|
41
|
+
print("test_oauth_pending: ok")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Coalesce corrupt duplicate model blocks + profile_config_yaml writer (WF-033)."""
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent
|
|
12
|
+
os.environ.setdefault("WORKFRAME_AGENTS_DIR", tempfile.mkdtemp(prefix="wf-yaml-test-"))
|
|
13
|
+
os.environ.setdefault("WORKFRAME_PROJECT", "YamlTest")
|
|
14
|
+
os.environ.setdefault("NATIVE_PROFILE", "yaml-test-agent")
|
|
15
|
+
|
|
16
|
+
spec = importlib.util.spec_from_file_location("wf_server_yaml_test", ROOT / "server.py")
|
|
17
|
+
server = importlib.util.module_from_spec(spec)
|
|
18
|
+
sys.modules["wf_server_yaml_test"] = server
|
|
19
|
+
spec.loader.exec_module(server)
|
|
20
|
+
|
|
21
|
+
import profile_config_yaml # noqa: E402
|
|
22
|
+
|
|
23
|
+
BROKEN = """model:
|
|
24
|
+
default: gpt-5.4-medium
|
|
25
|
+
provider: openai-codex
|
|
26
|
+
model:
|
|
27
|
+
default: gpt-5.4-medium
|
|
28
|
+
- provider: openai-codex
|
|
29
|
+
fallback_providers:
|
|
30
|
+
- provider: openai-codex
|
|
31
|
+
model: gpt-5.4-mini
|
|
32
|
+
toolsets:
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
prof = "yaml-test-agent"
|
|
36
|
+
prof_dir = server._profile_dir(prof)
|
|
37
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
cfg_path = prof_dir / "config.yaml"
|
|
39
|
+
cfg_path.write_text(BROKEN, encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
server._normalize_profile_config_yaml(prof)
|
|
42
|
+
fixed = cfg_path.read_text(encoding="utf-8")
|
|
43
|
+
assert fixed.count("\nmodel:\n") + fixed.count("model:\n") <= 2, fixed
|
|
44
|
+
assert " - provider:" not in fixed.split("fallback_providers:")[0], fixed
|
|
45
|
+
parsed = yaml.safe_load(fixed)
|
|
46
|
+
assert isinstance(parsed.get("model"), dict), parsed
|
|
47
|
+
assert parsed["model"]["default"] == "gpt-5.4-medium"
|
|
48
|
+
assert parsed["model"]["provider"] == "openai-codex"
|
|
49
|
+
assert len(parsed.get("fallback_providers") or []) == 1
|
|
50
|
+
assert "toolsets" in parsed
|
|
51
|
+
|
|
52
|
+
# Round-trip via update_model_surface preserves non-model keys
|
|
53
|
+
round_prof = "yaml-round-trip"
|
|
54
|
+
round_dir = server._profile_dir(round_prof)
|
|
55
|
+
round_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
round_path = round_dir / "config.yaml"
|
|
57
|
+
round_path.write_text(
|
|
58
|
+
"# picker note\nmodel:\n default: old-model\n provider: openrouter\ncron: []\n",
|
|
59
|
+
encoding="utf-8",
|
|
60
|
+
)
|
|
61
|
+
profile_config_yaml.update_model_surface(
|
|
62
|
+
round_path, default="new-model", provider="custom", base_url="http://proxy/v1",
|
|
63
|
+
)
|
|
64
|
+
rt = yaml.safe_load(round_path.read_text(encoding="utf-8"))
|
|
65
|
+
assert rt["model"]["default"] == "new-model"
|
|
66
|
+
assert rt["model"]["provider"] == "custom"
|
|
67
|
+
assert rt["model"]["base_url"] == "http://proxy/v1"
|
|
68
|
+
assert rt.get("cron") == []
|
|
69
|
+
assert "# picker note" not in round_path.read_text(encoding="utf-8") # comments not preserved
|
|
70
|
+
|
|
71
|
+
# Missing file: writer creates parent dirs + config
|
|
72
|
+
missing_prof = "yaml-missing"
|
|
73
|
+
missing_dir = server._profile_dir(missing_prof)
|
|
74
|
+
missing_path = missing_dir / "config.yaml"
|
|
75
|
+
if missing_path.is_file():
|
|
76
|
+
missing_path.unlink()
|
|
77
|
+
profile_config_yaml.update_model_surface(
|
|
78
|
+
missing_path, default="gpt-5.4-mini", provider="openai-codex",
|
|
79
|
+
)
|
|
80
|
+
assert missing_path.is_file()
|
|
81
|
+
loaded = yaml.safe_load(missing_path.read_text(encoding="utf-8"))
|
|
82
|
+
assert loaded["model"]["default"] == "gpt-5.4-mini"
|
|
83
|
+
|
|
84
|
+
# Coalesce flag drops duplicate blocks
|
|
85
|
+
dup_path = round_dir / "dup.yaml"
|
|
86
|
+
dup_path.write_text(BROKEN, encoding="utf-8")
|
|
87
|
+
profile_config_yaml.update_model_surface(
|
|
88
|
+
dup_path,
|
|
89
|
+
default="gpt-5.4-medium",
|
|
90
|
+
provider="openai-codex",
|
|
91
|
+
fallback_chain=[{"provider": "openai-codex", "model": "gpt-5.4-mini"}],
|
|
92
|
+
coalesce=True,
|
|
93
|
+
)
|
|
94
|
+
dup_text = dup_path.read_text(encoding="utf-8")
|
|
95
|
+
assert dup_text.count("\nmodel:\n") + dup_text.count("model:\n") <= 2, dup_text
|
|
96
|
+
dup_parsed = yaml.safe_load(dup_text)
|
|
97
|
+
assert dup_parsed["model"]["default"] == "gpt-5.4-medium"
|
|
98
|
+
|
|
99
|
+
print("profile model yaml coalesce self-check ok")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""WF-032 provider_bindings pure helpers 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
|
+
import provider_bindings # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_hermes_oauth_auth_keys() -> None:
|
|
14
|
+
keys = provider_bindings._hermes_oauth_auth_keys("openai-codex")
|
|
15
|
+
assert "openai-codex" in keys
|
|
16
|
+
assert "codex" in keys
|
|
17
|
+
assert "openai_codex" in keys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_auth_json_has_oauth_material() -> None:
|
|
21
|
+
pool_only = {
|
|
22
|
+
"version": 1,
|
|
23
|
+
"providers": {},
|
|
24
|
+
"credential_pool": {"openai-codex": [{"id": "x", "auth_type": "oauth"}]},
|
|
25
|
+
}
|
|
26
|
+
assert provider_bindings._auth_json_has_oauth_material(pool_only)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_merge_oauth_auth_into_profile() -> None:
|
|
30
|
+
pool_only = {
|
|
31
|
+
"version": 1,
|
|
32
|
+
"providers": {},
|
|
33
|
+
"credential_pool": {"openai-codex": [{"id": "x", "auth_type": "oauth"}]},
|
|
34
|
+
}
|
|
35
|
+
merged: dict = {"version": 1, "providers": {}, "credential_pool": {}}
|
|
36
|
+
assert provider_bindings._merge_oauth_auth_into_profile(merged, pool_only, "openai-codex")
|
|
37
|
+
assert merged["credential_pool"].get("openai-codex")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_parse_device_oauth_log() -> None:
|
|
41
|
+
text = "Open this URL\n https://auth.openai.com/device\nEnter this code\n ABCD-1234"
|
|
42
|
+
parsed = provider_bindings._parse_device_oauth_log(text)
|
|
43
|
+
assert parsed["verification_uri"] and "openai.com" in parsed["verification_uri"]
|
|
44
|
+
assert parsed["user_code"] == "ABCD-1234"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
test_hermes_oauth_auth_keys()
|
|
49
|
+
test_auth_json_has_oauth_material()
|
|
50
|
+
test_merge_oauth_auth_into_profile()
|
|
51
|
+
test_parse_device_oauth_log()
|
|
52
|
+
print("test_provider_bindings: ok")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""WF-032 provider_catalog 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
|
+
import provider_catalog # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_catalog_lookup() -> None:
|
|
14
|
+
gh = provider_catalog.catalog_provider("github")
|
|
15
|
+
assert gh is not None
|
|
16
|
+
assert gh.get("connect_mode") == "oauth"
|
|
17
|
+
assert provider_catalog.catalog_provider_for_llm("openai-codex") is not None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_user_only() -> None:
|
|
21
|
+
assert provider_catalog.provider_user_only("github")
|
|
22
|
+
assert not provider_catalog.provider_user_only("openrouter")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
test_catalog_lookup()
|
|
27
|
+
test_user_only()
|
|
28
|
+
print("test_provider_catalog: ok")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""ponytail self-check: session-less GET policy (WF-031).
|
|
2
|
+
|
|
3
|
+
Run: python services/workframe-api/test_public_routes.py
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
|
|
10
|
+
import server # noqa: E402
|
|
11
|
+
|
|
12
|
+
ALWAYS = "/api/health"
|
|
13
|
+
DATA = "/api/snapshot"
|
|
14
|
+
META = "/api/meta"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def allowed(path: str, mode: str, dev: bool = False) -> bool:
|
|
18
|
+
return server._sessionless_get_allowed(path, deployment_mode=mode, dev_local_unsafe=dev)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Always-public routes work in every deployment mode.
|
|
22
|
+
assert allowed(META, "public_multi_user")
|
|
23
|
+
assert allowed(ALWAYS, "trusted_team")
|
|
24
|
+
assert allowed("/api/public/branding/logo", "public_multi_user")
|
|
25
|
+
|
|
26
|
+
# Data routes: blocked for multi-user modes without DEV_LOCAL_UNSAFE.
|
|
27
|
+
assert not allowed(DATA, "public_multi_user")
|
|
28
|
+
assert not allowed(DATA, "trusted_team")
|
|
29
|
+
assert not allowed("/api/chat/messages", "public_multi_user")
|
|
30
|
+
assert not allowed("/api/files/list", "trusted_team")
|
|
31
|
+
|
|
32
|
+
# Data routes: allowed for single_user_local or dev unsafe.
|
|
33
|
+
assert allowed(DATA, "single_user_local")
|
|
34
|
+
assert allowed(DATA, "public_multi_user", dev=True)
|
|
35
|
+
assert allowed("/api/board", "single_user_local")
|
|
36
|
+
|
|
37
|
+
# _is_public_get composes static/UI paths.
|
|
38
|
+
assert server._is_public_get("/")
|
|
39
|
+
assert server._is_public_get("/assets/app.js")
|
|
40
|
+
|
|
41
|
+
print("public routes self-check ok")
|