diff --git a/tests/conftest.py b/tests/conftest.py index 62aa979e..e6493602 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -430,6 +430,40 @@ def mock_openai_client(): return client +@pytest.fixture +def make_config_store(): + """Factory for a lightweight ConfigStore double. + + ``make_config_store(**overrides)`` returns an object whose ``.get(key)`` + yields the override when present, else the registered SettingDef default — + mirroring the real :meth:`ConfigStore.get` fail-open (a bool setting reads + as its ``False`` default on a miss, never ``None``). Shared by the + ``server.require_project`` gate / advisory tests. + """ + + _unset = object() + + def _make(**overrides: Any) -> Any: + from turnstone.core.settings_registry import SETTINGS + + class _ConfigStoreDouble: + def get(self, key: str, default: Any = _unset) -> Any: + # Mirror ConfigStore.get precedence exactly: cache (overrides) + # first, then a caller-supplied default, then the registry + # default, then None — so a reused caller passing an explicit + # default for an unset key gets the same value production would. + if key in overrides: + return overrides[key] + if default is not _unset: + return default + defn = SETTINGS.get(key) + return defn.default if defn else None + + return _ConfigStoreDouble() + + return _make + + @pytest.fixture(autouse=True) def _clear_policy_cache(): """Drop the in-process tool-policy cache between tests. diff --git a/tests/test_app_js.py b/tests/test_app_js.py index ae4c2e73..84ffe83a 100644 --- a/tests/test_app_js.py +++ b/tests/test_app_js.py @@ -2311,3 +2311,49 @@ def test_console_has_matching_pane_hotkeys() -> None: assert '"Fork"' not in index and "New workstream" not in index, ( "Fork + New are intentionally omitted on the console" ) + + +def test_fork_hides_project_picker() -> None: + """A fork must NOT show a project picker. A fork inherits its source's project + (enforced server-side), and a re-fileable fork picker was a cross-tenant + history-relocation vector — so showNewWsModal hides the picker for forks and + the "Keep source's project" fork option is gone.""" + body = _APP_JS.read_text(encoding="utf-8") + assert "projSelect.hidden = !!_forkFromWsId" in body, ( + "the new-ws project picker must be hidden for forks" + ) + assert "Keep source's project" not in body, ( + "the fork project picker (and its 'Keep source's project' option) must be removed" + ) + + +def test_submit_gates_project_on_fork_flag() -> None: + """submitNewWs must send body.project_id ONLY for a fresh create + (!_forkFromWsId) — a fork never sends a project (its project is the source's, + enforced server-side). Not gated on picker visibility or a requireProject() + re-read.""" + body = _APP_JS.read_text(encoding="utf-8") + m = re.search(r"function submitNewWs\(\)\s*\{(.*?)\n\}", body, re.S) + assert m is not None, "could not locate submitNewWs" + fn = m.group(1) + assert "TurnstoneProjects.requireProject" not in fn, ( + "submit must not re-read the requireProject() advisory" + ) + assert re.search(r"project_id && !_forkFromWsId", fn), ( + "submit must gate project_id on !_forkFromWsId (a fork never sends a project)" + ) + + +def test_strict_picker_requires_explicit_pick() -> None: + """Under require_project the fresh picker must NOT auto-select the first + project (which silently mis-files a required chat under a possibly-shared + project) — it offers a 'Select a project…' prompt so the user consciously + chooses.""" + body = _APP_JS.read_text(encoding="utf-8") + assert "Select a project" in body, ( + "strict picker must offer an explicit 'Select a project…' prompt" + ) + m = re.search(r"function _reconcileRequiredProjectSelection\(sel\)\s*\{(.*?)\n\}", body, re.S) + assert m is not None, "could not locate _reconcileRequiredProjectSelection" + fn = m.group(1) + assert "real[0]" not in fn, "strict picker must not auto-select the first project" diff --git a/tests/test_require_project.py b/tests/test_require_project.py new file mode 100644 index 00000000..5360330c --- /dev/null +++ b/tests/test_require_project.py @@ -0,0 +1,608 @@ +"""``server.require_project`` — the opt-in, default-off gate refusing projectless +interactive creates. + +Three surfaces: + * the predicate matrix (``require_project_enabled`` / ``require_project_denies_create``); + * the fork/resume project inheritance + the cross-tenant 403-vs-400 oracle in the + interactive create validator (``_interactive_create_validate_request``); + * the console cluster-create proxy's surface-only-require_project / mask-everything + -else policy (``create_workstream``). + +Validator tests drive the coroutine synchronously via ``asyncio.run`` so they need no +async-plugin marker. Storage is a MagicMock patched onto the singleton getter that both +the RAW resume-resolve and ``ensure_project_attachable`` read. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import httpx +import pytest + +# --------------------------------------------------------------------------- +# Predicate matrix +# --------------------------------------------------------------------------- + + +class _Auth: + """Minimal AuthResult stand-in: ``has_scope`` + ``token_source``.""" + + def __init__( + self, scopes: tuple[str, ...] = (), token_source: str = "jwt", user_id: str = "alice" + ) -> None: + self._scopes = frozenset(scopes) + self.token_source = token_source + self.user_id = user_id + + def has_scope(self, scope: str) -> bool: + return scope in self._scopes + + +class TestRequireProjectPredicate: + def test_enabled_off_by_default(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_enabled + + assert require_project_enabled(make_config_store()) is False + + def test_enabled_when_set(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_enabled + + assert ( + require_project_enabled(make_config_store(**{"server.require_project": True})) is True + ) + + def test_enabled_none_config_store_fails_open(self) -> None: + from turnstone.core.auth import require_project_enabled + + assert require_project_enabled(None) is False + + def test_denies_projectless_when_on(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + assert require_project_denies_create(cs, _Auth(), "") is True + + def test_allows_when_off(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + # Flag off: even a projectless create is allowed (byte-identical to today). + assert require_project_denies_create(make_config_store(), _Auth(), "") is False + + def test_allows_none_config_store(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + # Storage unwired: fail open. + assert require_project_denies_create(None, _Auth(), "") is False + + def test_allows_with_project(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + assert require_project_denies_create(cs, _Auth(), "p1") is False + + def test_whitespace_project_is_projectless(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + assert require_project_denies_create(cs, _Auth(), " ") is True + + def test_service_scope_exempt(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + assert require_project_denies_create(cs, _Auth(scopes=("service",)), "") is False + + def test_coordinator_source_exempt(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + assert require_project_denies_create(cs, _Auth(token_source="coordinator"), "") is False + + def test_console_proxy_human_not_exempt(self, make_config_store: Any) -> None: + # The normal proxied human carries their OWN scopes (no service) and + # token_source "console-proxy" — gated, not exempt. + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + auth = _Auth(scopes=("read", "write"), token_source="console-proxy") + assert require_project_denies_create(cs, auth, "") is True + + def test_admin_operator_not_exempt(self, make_config_store: Any) -> None: + # An operator carries admin-derived scopes (approve) but never `service`, + # so `admin.coordinator` humans are gated — the predicate keys on scope / + # token_source, never a permission. + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + auth = _Auth(scopes=("read", "write", "approve"), token_source="jwt") + assert require_project_denies_create(cs, auth, "") is True + + def test_none_auth_denied_when_projectless(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + assert require_project_denies_create(cs, None, "") is True + + def test_none_auth_allowed_with_project(self, make_config_store: Any) -> None: + from turnstone.core.auth import require_project_denies_create + + cs = make_config_store(**{"server.require_project": True}) + assert require_project_denies_create(cs, None, "p1") is False + + +# --------------------------------------------------------------------------- +# Fork/resume inheritance + the 403-vs-400 cross-tenant oracle (node validator) +# --------------------------------------------------------------------------- + + +def _src_storage( + *, + project_id: str | None = None, + project_visibility: str = "private", + project_owner: str = "other", + members: tuple[str, ...] = (), + resolve_none: bool = False, + get_project_missing: bool = False, +) -> MagicMock: + """Storage double for the resume source: resolve + get_workstream (RAW) and + the get_project/is_project_member surface ``ensure_project_attachable`` reads.""" + storage = MagicMock() + storage.resolve_workstream.side_effect = lambda _x: None if resolve_none else "src-canon" + storage.get_workstream.return_value = { + "ws_id": "src-canon", + "project_id": project_id, + "user_id": "other", + } + if get_project_missing or project_id is None: + storage.get_project.return_value = None + else: + storage.get_project.return_value = { + "project_id": project_id, + "name": "P", + "owner_id": project_owner, + "visibility": project_visibility, + "state": "active", + } + storage.is_project_member.side_effect = lambda pid, uid: uid in members + return storage + + +def _validate(monkeypatch: Any, body: dict[str, Any], uid: str, cs: Any, storage: Any) -> Any: + """Run ``_interactive_create_validate_request`` with a patched storage getter.""" + import turnstone.server as server_mod + + monkeypatch.setattr("turnstone.core.storage._registry.get_storage", lambda: storage) + req = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(config_store=cs))) + return asyncio.run(server_mod._interactive_create_validate_request(req, body, uid, [])) + + +class TestResumeInheritanceOracle: + def _on(self, make_config_store: Any) -> Any: + return make_config_store(**{"server.require_project": True}) + + def test_inherits_attachable_source_project( + self, monkeypatch: Any, make_config_store: Any + ) -> None: + storage = _src_storage(project_id="ppub", project_visibility="public") + body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None + assert body["project_id"] == "ppub" # inherited (attachable) + + def test_member_of_private_source_inherits( + self, monkeypatch: Any, make_config_store: Any + ) -> None: + storage = _src_storage( + project_id="psecret", project_visibility="private", members=("alice",) + ) + body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None + assert body["project_id"] == "psecret" + + def test_private_source_no_403_oracle(self, monkeypatch: Any, make_config_store: Any) -> None: + # Source under a private project alice can't access → MUST NOT surface a + # distinguishable 403; drop to projectless so the gate 400s it uniformly. + storage = _src_storage(project_id="psecret", project_visibility="private", members=()) + body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None # NOT a 403 JSONResponse + assert body.get("project_id", "") == "" + + def test_projectless_source_no_inherit(self, monkeypatch: Any, make_config_store: Any) -> None: + storage = _src_storage(project_id=None) + body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None + assert body.get("project_id", "") == "" + + def test_nonexistent_source_no_inherit(self, monkeypatch: Any, make_config_store: Any) -> None: + storage = _src_storage(resolve_none=True) + body: dict[str, Any] = {"resume_ws": "ghost", "kind": "interactive"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None + assert body.get("project_id", "") == "" + + def test_dangling_source_project_no_oracle( + self, monkeypatch: Any, make_config_store: Any + ) -> None: + # Source's project was deleted → attach 400 → drop (uniform with the rest). + storage = _src_storage(project_id="pdead", get_project_missing=True) + body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None + assert body.get("project_id", "") == "" + + def test_private_and_projectless_indistinguishable( + self, monkeypatch: Any, make_config_store: Any + ) -> None: + # The R1 core: private-source and projectless-source produce IDENTICAL + # observable outcomes — no cross-tenant oracle. + cs = self._on(make_config_store) + b_priv: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"} + _validate(monkeypatch, b_priv, "alice", cs, _src_storage(project_id="psecret")) + b_none: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"} + _validate(monkeypatch, b_none, "alice", cs, _src_storage(project_id=None)) + assert b_priv.get("project_id", "") == b_none.get("project_id", "") == "" + + def test_flag_off_never_resolves(self, monkeypatch: Any, make_config_store: Any) -> None: + # Byte-identical when off: the source is never resolved, nothing inherited. + storage = _src_storage(project_id="ppub", project_visibility="public") + body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive"} + res = _validate(monkeypatch, body, "alice", make_config_store(), storage) + assert res is None + assert body.get("project_id", "") == "" + storage.resolve_workstream.assert_not_called() + + def test_explicit_project_discarded_for_projected_source( + self, monkeypatch: Any, make_config_store: Any + ) -> None: + # A fork DISCARDS any explicit project_id and inherits its SOURCE's + # project — an explicit pick can never re-file a fork's history ([1]). + storage = _src_storage(project_id="ppub", project_visibility="public") + body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive", "project_id": "pchosen"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None + assert body["project_id"] == "ppub" # overridden to the source's project + storage.resolve_workstream.assert_called() # a fork always resolves its source + + def test_explicit_project_discarded_projectless_source( + self, monkeypatch: Any, make_config_store: Any + ) -> None: + # The safe-vs-leaky discriminator: a fork of a PROJECTLESS source carrying + # an explicit owned project_id must NOT file under the pick — the pick is + # discarded, nothing inherited, so it funnels to the uniform projectless + # "" (400 downstream), indistinguishable from inaccessible/nonexistent. + storage = _src_storage(project_id=None) + body: dict[str, Any] = {"resume_ws": "src", "kind": "interactive", "project_id": "powned"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None + assert body.get("project_id", "") == "" + + def test_explicit_project_discarded_nonexistent_source( + self, monkeypatch: Any, make_config_store: Any + ) -> None: + # Same discriminator for a NONEXISTENT source + explicit owned pid: "". + storage = _src_storage(resolve_none=True) + body: dict[str, Any] = {"resume_ws": "ghost", "kind": "interactive", "project_id": "powned"} + res = _validate(monkeypatch, body, "alice", self._on(make_config_store), storage) + assert res is None + assert body.get("project_id", "") == "" + + +# --------------------------------------------------------------------------- +# Console cluster-create proxy: surface only require_project, mask the rest +# --------------------------------------------------------------------------- + +_CONSOLE_JWT_SECRET = "test-jwt-secret-minimum-32-chars!" + + +def _console_headers() -> dict[str, str]: + from turnstone.core.auth import JWT_AUD_CONSOLE, create_jwt + + tok = create_jwt( + user_id="op", + scopes=frozenset({"read", "write", "approve", "service"}), + source="test", + secret=_CONSOLE_JWT_SECRET, + audience=JWT_AUD_CONSOLE, + ) + return {"Authorization": f"Bearer {tok}"} + + +def _node_resp( + status_code: int, json_body: dict[str, Any] | None = None, content: bytes | None = None +) -> httpx.Response: + req = httpx.Request("POST", "http://a:8080/v1/api/workstreams/new") + if content is not None: + return httpx.Response(status_code, content=content, request=req) + return httpx.Response(status_code, json=json_body if json_body is not None else {}, request=req) + + +@contextlib.contextmanager +def _console_client( + node_response: httpx.Response | None = None, raise_exc: BaseException | None = None +) -> Any: + """A console TestClient whose proxied node create returns *node_response* (an + ``httpx.Response``) or raises *raise_exc*. Lifespan is not entered (matches the + existing cluster-create tests) so the manually-attached proxy_client survives.""" + from starlette.testclient import TestClient + + from turnstone.console.collector import ClusterCollector + from turnstone.console.server import _load_static, create_app + + collector = MagicMock(spec=ClusterCollector) + collector.get_node_detail.return_value = { + "node_id": "node-a", + "server_url": "http://a:8080", + "health": {}, + "workstreams": [], + "aggregate": {}, + "reachable": True, + } + collector.get_nodes.return_value = ( + [{"node_id": "node-a", "reachable": True, "max_ws": 10, "ws_total": 1}], + 1, + ) + collector.get_all_nodes.side_effect = lambda: collector.get_nodes.return_value[0] + collector.get_overview.return_value = { + "nodes": 1, + "workstreams": 0, + "states": {"running": 0, "idle": 0, "thinking": 0, "attention": 0, "error": 0}, + "aggregate": {"total_tokens": 0, "total_tool_calls": 0}, + } + + _load_static() + app = create_app(collector=collector, jwt_secret=_CONSOLE_JWT_SECRET) + + async def _mock_post(*_args: Any, **_kwargs: Any) -> httpx.Response: + if raise_exc is not None: + raise raise_exc + assert node_response is not None + return node_response + + mock_proxy = MagicMock(spec=httpx.AsyncClient) + mock_proxy.post = MagicMock(side_effect=_mock_post) + app.state.proxy_client = mock_proxy + + client = TestClient(app, raise_server_exceptions=False, headers=_console_headers()) + try: + yield client + finally: + client.close() + + +def _create(client: Any) -> httpx.Response: + return client.post("/v1/api/cluster/workstreams/new", json={"node_id": "node-a", "name": "x"}) + + +class TestConsoleRequireProjectSurfacing: + def test_require_project_400_surfaced(self) -> None: + from turnstone.core.auth import REQUIRE_PROJECT_CODE, REQUIRE_PROJECT_ERROR + + node = _node_resp(400, {"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE}) + with _console_client(node) as client: + resp = _create(client) + assert resp.status_code == 400 + data = resp.json() + assert data["code"] == REQUIRE_PROJECT_CODE + assert data["error"] == REQUIRE_PROJECT_ERROR + + def test_uncoded_400_masked_no_leak(self) -> None: + node = _node_resp(400, {"error": "cannot fork abc: SECRETPERSONA missing"}) + with _console_client(node) as client: + resp = _create(client) + assert resp.status_code == 502 + assert "SECRETPERSONA" not in resp.text + assert resp.json()["error"] == "Dispatch to node node-a failed" + + def test_other_coded_400_masked(self) -> None: + node = _node_resp(400, {"error": "too many files", "code": "too_many"}) + with _console_client(node) as client: + resp = _create(client) + assert resp.status_code == 502 + + def test_401_masked(self) -> None: + with _console_client(_node_resp(401, {"error": "unauthorized"})) as client: + resp = _create(client) + assert resp.status_code == 502 + + def test_429_masked(self) -> None: + with _console_client(_node_resp(429, {"error": "capacity"})) as client: + resp = _create(client) + assert resp.status_code == 502 + + def test_attach_denied_403_masked(self) -> None: + node = _node_resp( + 403, {"error": "cannot attach a workstream to a private project you don't belong to"} + ) + with _console_client(node) as client: + resp = _create(client) + assert resp.status_code == 502 + assert "private project" not in resp.text + + def test_500_masked(self) -> None: + with _console_client(_node_resp(500, {"error": "boom"})) as client: + resp = _create(client) + assert resp.status_code == 502 + + def test_non_json_2xx_masked(self) -> None: + # R7: a 2xx with no JSON body must mask to 502, not crash the console. + with _console_client(_node_resp(200, content=b"not json")) as client: + resp = _create(client) + assert resp.status_code == 502 + + def test_network_error_masked(self) -> None: + with _console_client(raise_exc=httpx.ConnectError("boom")) as client: + resp = _create(client) + assert resp.status_code == 502 + + def test_success_200_regression(self) -> None: + with _console_client(_node_resp(200, {"ws_id": "ws_new", "name": "x"})) as client: + resp = _create(client) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["correlation_id"] == "ws_new" + assert data["target_node"] == "node-a" + + +# --------------------------------------------------------------------------- +# Node gate kind-scoping — drives the REAL make_create_handler for BOTH kinds. +# The handler emits `code: "require_project"` ONLY at the gate, so that marker's +# presence/absence in the response is an exact witness of whether the gate fired +# — closing the load-bearing `cfg.list_kind == "interactive"` guard end-to-end. +# --------------------------------------------------------------------------- + + +def _gate_request(body: dict[str, Any], cs: Any, auth: Any) -> Any: + """A minimal Starlette Request: JSON body + auth_result on request.state + + config_store on request.app.state — enough to reach the require_project gate.""" + from starlette.requests import Request + + payload = json.dumps(body).encode() + delivered = {"done": False} + + async def _receive() -> dict[str, Any]: + if delivered["done"]: + return {"type": "http.disconnect"} + delivered["done"] = True + return {"type": "http.request", "body": payload, "more_body": False} + + scope = { + "type": "http", + "method": "POST", + "path": "/v1/api/workstreams/new", + "headers": [(b"content-type", b"application/json")], + "query_string": b"", + "app": SimpleNamespace(state=SimpleNamespace(config_store=cs)), + "state": {"auth_result": auth}, + } + return Request(scope, _receive) + + +def _is_require_project_400(resp: Any) -> bool: + if resp.status_code != 400: + return False + try: + return json.loads(resp.body).get("code") == "require_project" + except Exception: + return False + + +def _run_gate(list_kind: Any, flag_on: bool, body: dict[str, Any], make_config_store: Any) -> Any: + """Drive make_create_handler.create() to the require_project gate for one + (kind, flag, body). A gate pass-through raises out of a build_kwargs stub that + the handler's own try/except turns into a non-require_project response.""" + from turnstone.core.session_routes import SessionEndpointConfig, make_create_handler + from turnstone.core.workstream import WorkstreamKind + + def _build(*_a: Any, **_k: Any) -> dict[str, Any]: + raise RuntimeError("stopped just past the require_project gate") + + mgr = MagicMock() + mgr.kind = list_kind + cfg = SessionEndpointConfig( + permission_gate=lambda _req: None, + manager_lookup=lambda _req: (mgr, None), + tenant_check=None, + not_found_label="workstream", + audit_action_prefix="ws", + list_kind=list_kind, + # Derived per-kind HERE the way the real mounts wire it (interactive True, + # coordinator False); production keeps it a declarative field, not a kind check. + create_gate_require_project=(list_kind == WorkstreamKind.INTERACTIVE), + create_validate_request=None, + create_build_kwargs=_build, + create_supports_attachments=False, + create_supports_user_id_override=False, + ) + handler = make_create_handler(cfg) + cs = make_config_store(**({"server.require_project": True} if flag_on else {})) + auth = _Auth(scopes=("read", "write"), token_source="jwt") + return asyncio.run(handler(_gate_request(body, cs, auth))) + + +class TestNodeGateKindScoping: + def test_interactive_projectless_gated(self, make_config_store: Any, tmp_db: Any) -> None: + from turnstone.core.workstream import WorkstreamKind + + resp = _run_gate(WorkstreamKind.INTERACTIVE, True, {}, make_config_store) + assert _is_require_project_400(resp) + + def test_interactive_with_project_passes(self, make_config_store: Any, tmp_db: Any) -> None: + from turnstone.core.workstream import WorkstreamKind + + resp = _run_gate(WorkstreamKind.INTERACTIVE, True, {"project_id": "p1"}, make_config_store) + assert not _is_require_project_400(resp) + + def test_interactive_flag_off_passes(self, make_config_store: Any, tmp_db: Any) -> None: + from turnstone.core.workstream import WorkstreamKind + + resp = _run_gate(WorkstreamKind.INTERACTIVE, False, {}, make_config_store) + assert not _is_require_project_400(resp) + + def test_coordinator_projectless_exempt(self, make_config_store: Any, tmp_db: Any) -> None: + # The coordinator mount leaves create_gate_require_project False, so a + # projectless coordinator create is NOT gated even with the flag on. + from turnstone.core.workstream import WorkstreamKind + + resp = _run_gate(WorkstreamKind.COORDINATOR, True, {}, make_config_store) + assert not _is_require_project_400(resp) + + +# --------------------------------------------------------------------------- +# list_projects advisory field — the frontend composer reads data.require_project +# into requireProject(); pin that the endpoint actually emits it (and reflects +# the flag), so a refactor can't silently drop it and fail the picker open. +# --------------------------------------------------------------------------- + + +def _list_projects_request(cs: Any, auth: Any) -> Any: + from starlette.requests import Request + + async def _receive() -> dict[str, Any]: + return {"type": "http.request", "body": b"", "more_body": False} + + scope = { + "type": "http", + "method": "GET", + "path": "/v1/api/projects", + "headers": [], + "query_string": b"", + "app": SimpleNamespace(state=SimpleNamespace(config_store=cs)), + "state": {"auth_result": auth}, + } + return Request(scope, _receive) + + +class TestListProjectsAdvisory: + def test_require_project_field_on(self, make_config_store: Any, tmp_db: Any) -> None: + import turnstone.server as server_mod + + cs = make_config_store(**{"server.require_project": True}) + auth = _Auth(scopes=("service",)) # service scope bypasses require_permission + resp = asyncio.run(server_mod.list_projects(_list_projects_request(cs, auth))) + data = json.loads(resp.body) + assert data["require_project"] is True + assert "projects" in data + + def test_require_project_field_off_by_default( + self, make_config_store: Any, tmp_db: Any + ) -> None: + import turnstone.server as server_mod + + cs = make_config_store() + auth = _Auth(scopes=("service",)) + resp = asyncio.run(server_mod.list_projects(_list_projects_request(cs, auth))) + assert json.loads(resp.body)["require_project"] is False + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tests/test_server_authz.py b/tests/test_server_authz.py index d1ffbcde..33349273 100644 --- a/tests/test_server_authz.py +++ b/tests/test_server_authz.py @@ -2468,3 +2468,32 @@ class TestCompactCommandDispatch: assert resp.status_code == 409 assert resp.json()["status"] == "busy" assert ws.session.commands == [] + + +class TestRequireProjectMountWiring: + """server.require_project is wired on the REAL interactive create mount + (create_gate_require_project=True on interactive_endpoint_config). Synthetic- + cfg unit tests can't catch a mis-wire on the actual mount, so drive the + mounted endpoint end-to-end.""" + + def test_projectless_interactive_create_gated_when_on(self, app_client, make_config_store): + client, _mgr = app_client + client.app.state.config_store = make_config_store(**{"server.require_project": True}) + resp = client.post( + "/v1/api/workstreams/new", + json={"name": "no-project"}, + headers=_auth("user-1", permissions=frozenset({"workstreams.create"})), + ) + assert resp.status_code == 400, resp.json() + assert resp.json().get("code") == "require_project" + + def test_projectless_interactive_create_allowed_when_off(self, app_client, make_config_store): + client, _mgr = app_client + client.app.state.config_store = make_config_store() # flag off (default) + resp = client.post( + "/v1/api/workstreams/new", + json={"name": "no-project"}, + headers=_auth("user-1", permissions=frozenset({"workstreams.create"})), + ) + body = resp.json() + assert not (resp.status_code == 400 and body.get("code") == "require_project"), body diff --git a/turnstone/console/server.py b/turnstone/console/server.py index ea88f774..263327b0 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -652,6 +652,13 @@ def _bounded_body_preview(text: str | bytes, cap: int = 200) -> str: return _CONTROL_CHAR_RE.sub(" ", text)[:cap] +def _dispatch_failed(node_id: str) -> JSONResponse: + """The sanitized 502 the cluster-create proxy returns for every masked node + outcome (network failure, unparseable 2xx body, and any non-require_project + node status). One wording, one status — each caller keeps its own distinct log.""" + return JSONResponse({"error": f"Dispatch to node {node_id} failed"}, status_code=502) + + def _proxy_auth_headers(request: Request) -> dict[str, str]: """Build auth headers for proxied requests to upstream servers. @@ -2205,18 +2212,81 @@ async def create_workstream(request: Request) -> JSONResponse: ) else: resp = await client.post(node_url, json=ws_body, headers=headers) - resp.raise_for_status() except httpx.HTTPError as exc: log.warning("Workstream dispatch to %s failed: %s", node_id, exc) - return JSONResponse({"error": f"Dispatch to node {node_id} failed"}, status_code=502) + return _dispatch_failed(node_id) - return JSONResponse( - { - "status": "ok", - "correlation_id": resp.json().get("ws_id", ""), - "target_node": node_id, - } + # The node is the authoritative require_project gate. Surface ONLY its + # coded require_project 400 to the operator; mask every OTHER node outcome + # as an opaque 502. Rationale (do not "simplify" by re-emitting the node + # status/body generically): + # * a node 401 re-emitted here trips authFetch's reactive refresh + + # force-logout, dumping a VALID operator to the login overlay and + # double-sending the (non-idempotent) create; + # * a node 429 re-emitted here trips authFetch's auto-retry, minting a + # DUPLICATE workstream; + # * any non-require_project node body may carry internal detail + # ("cannot fork : ", skill/persona/path text) + # that would land in the operator's browser DOM. + # The discriminator is an EXACT code match: coded-but-different bodies + # (too_many/too_large/upload) and every un-coded 400 mask to 502. The + # actionable attach-denied 403 and factory-misconfig 503 are also masked + # to 502 — a deliberate leak boundary; do NOT surface 403/503 here without + # re-checking the leak. Branch on the explicit success range (not + # raise_for_status) so a future httpx that lets a 3xx through still cannot + # reach the 2xx json parse. Guard BOTH body reads (a 204 / non-JSON 2xx + # would otherwise raise a JSONDecodeError → console 500 → operator retry → + # orphan/duplicate ws). + try: + _raw = resp.json() + node_body = _raw if isinstance(_raw, dict) else None + except Exception: + # Broad by design at this console->node proxy boundary: ANY parse failure + # (a 204 with no body, an intermediary's HTML, a truncated body) must fall + # through to the 502 mask below, never propagate. Deliberately wider than + # the codebase's narrow (ValueError, JSONDecodeError) json-guard. + node_body = None + + if 200 <= resp.status_code < 300: + if node_body is None: + log.warning( + "Workstream dispatch to %s: unparseable %s success body: %s", + node_id, + resp.status_code, + _bounded_body_preview(resp.content), + ) + return _dispatch_failed(node_id) + return JSONResponse( + { + "status": "ok", + "correlation_id": node_body.get("ws_id", ""), + "target_node": node_id, + } + ) + + from turnstone.core.auth import REQUIRE_PROJECT_CODE, REQUIRE_PROJECT_ERROR + + if ( + resp.status_code == 400 + and node_body is not None + and node_body.get("code") == REQUIRE_PROJECT_CODE + ): + # Surface OUR canonical wording, not the node's echoed `error` string: a + # version-skewed or misbehaving node must not control the operator-facing + # text, and this keeps the message identical across every refusal cause. + log.info("Workstream dispatch to %s refused: require_project", node_id) + return JSONResponse( + {"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE}, + status_code=400, + ) + + log.warning( + "Workstream dispatch to %s failed: node status %s: %s", + node_id, + resp.status_code, + _bounded_body_preview(resp.content), ) + return _dispatch_failed(node_id) # --------------------------------------------------------------------------- diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index 14cb567d..a6d3394e 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -1529,6 +1529,13 @@ function _refreshAndPopulateProjects() { // Populate the launcher's Project picker from the shared cache, preserving the // current pick across the rebuild (same reason as _populateLauncherNodes) and // always appending the "+ New project…" sentinel after the live list. +// +// Under server.require_project the console launcher intentionally does NOT gate +// this picker on TurnstoneProjects.requireProject() (unlike the node new-ws +// dialog): the node create endpoint is the authoritative gate, and its refusal +// is surfaced to the operator by the console proxy (create_workstream). A +// client-side required-project picker here is a deferred UX nicety, not a +// correctness requirement. function _populateHomeProjectDropdown() { if (!_homeCoordComposer) return; const TP = window.TurnstoneProjects; diff --git a/turnstone/core/auth.py b/turnstone/core/auth.py index 8a5e99b7..1045a0d1 100644 --- a/turnstone/core/auth.py +++ b/turnstone/core/auth.py @@ -469,6 +469,61 @@ def ensure_project_attachable( return (403, "project access could not be verified") +# --------------------------------------------------------------------------- +# server.require_project — opt-in, default-off gate refusing projectless +# interactive creates. One predicate is the ONLY flag read (gate + advisory +# both call it) so authoritative and advisory logic cannot drift. +# --------------------------------------------------------------------------- + +# Discriminator on the node's 400 body. The console cluster-create proxy +# surfaces ONLY this coded 400 to the operator and masks every other node +# outcome to a sanitized 502, so this string is a shared contract — import it +# on both sides rather than re-spelling the literal. +REQUIRE_PROJECT_CODE = "require_project" + +# Operator-facing 400 message. Deliberately generic: a projectless, private, +# dangling, or nonexistent fork source must all yield this IDENTICAL text, or +# the message itself becomes a cross-tenant oracle. +REQUIRE_PROJECT_ERROR = ( + "This deployment requires every new chat to be filed under a project. " + "Choose a project and try again." +) + + +def require_project_enabled(config_store: Any) -> bool: + """Return True iff the ``server.require_project`` gate is switched on. + + The SINGLE flag read, shared by the authoritative create gate and the + advisory ``list_projects`` field so the two cannot diverge in logic. + Fail-open: a missing config store (storage unwired) reads as off. The + ConfigStore returns the registered SettingDef default (``False``) on a + cache miss — so an unset flag is off, not ``None`` — but only because the + SettingDef IS registered; forgetting it silently disables the feature. + """ + return config_store is not None and bool(config_store.get("server.require_project")) + + +def require_project_denies_create(config_store: Any, auth: Any, project_id: Any) -> bool: + """Return True to REFUSE an interactive create under ``server.require_project``. + + Refuses only when the gate is on, the caller is not exempt automation, and + no project is attached (whitespace-only counts as none). Exempt identities: + the ``service`` scope (channel gateway, scheduler) and coordinator SESSION + spawns (``token_source == "coordinator"``). NOT exempt on any admin + permission — ``admin.coordinator`` is a human-operator permission and would + leak every operator through the gate. ``console-proxy`` (the normal proxied + human) is deliberately NOT exempt: it carries the human's own scopes, which + never include ``service``. + """ + if not require_project_enabled(config_store): + return False + if auth is not None and auth.has_scope("service"): + return False + if getattr(auth, "token_source", "") == "coordinator": + return False + return not str(project_id or "").strip() + + def _permissions_to_scopes(permissions: set[str]) -> frozenset[str]: """Derive legacy scopes from a granular permission set.""" scopes: set[str] = set() diff --git a/turnstone/core/session_routes.py b/turnstone/core/session_routes.py index c8d9b4cc..05f78623 100644 --- a/turnstone/core/session_routes.py +++ b/turnstone/core/session_routes.py @@ -428,6 +428,12 @@ class SessionEndpointConfig: # wires ``False`` — coord create runs only on the console process # and the operator's auth result is the source of truth. create_supports_user_id_override: bool = False + # When ``True`` the shared create handler applies the ``server.require_project`` + # gate (refuse a projectless interactive create). Declarative per-mount + # capability — interactive wires ``True``; coordinator leaves it ``False`` so + # coordinator spawns are never gated. A cfg flag rather than a hardcoded kind + # literal, matching the ``create_supports_*`` idiom. + create_gate_require_project: bool = False # (request, body, uid, uploaded_files) -> JSONResponse | None. # Per-kind pre-create gate (ws_id format, parent ownership, kind # validation, etc. on interactive; 401-on-empty-uid on coord). @@ -2564,6 +2570,29 @@ def make_create_handler( if err_validate is not None: return err_validate + # --- require_project gate (interactive-create mounts only) ------- + # Gated by the declarative cfg.create_gate_require_project capability + # (True on the interactive create mount, default-False on coordinator) + # rather than a hardcoded kind literal, matching the create_supports_* + # idiom — coordinator spawns are exempt by design. By this point the + # validator has applied any parent-/resume-inherited project_id into body + # AND (for a fork) discarded any explicit pick to the source's project or + # "", so a private/dangling/projectless fork SOURCE funnels to the SAME + # uniform 400 as a projectless fresh create. + if cfg.create_gate_require_project: + from turnstone.core.auth import ( + REQUIRE_PROJECT_CODE, + REQUIRE_PROJECT_ERROR, + require_project_denies_create, + ) + + _config_store = getattr(request.app.state, "config_store", None) + if require_project_denies_create(_config_store, auth, body.get("project_id")): + return JSONResponse( + {"error": REQUIRE_PROJECT_ERROR, "code": REQUIRE_PROJECT_CODE}, + status_code=400, + ) + # --- Skill resolution -------------------------------------------- # Both kinds resolve a body ``skill`` field through # ``get_skill_by_name`` to the skill_data dict + the next diff --git a/turnstone/core/settings_registry.py b/turnstone/core/settings_registry.py index 5019ad8d..8c34a8d6 100644 --- a/turnstone/core/settings_registry.py +++ b/turnstone/core/settings_registry.py @@ -358,6 +358,23 @@ def _build_registry() -> dict[str, SettingDef]: "When the limit is reached, the oldest idle workstream is evicted to make room. " "Each workstream uses memory proportional to its conversation history.", ), + SettingDef( + "server.require_project", + "bool", + False, + "Require new interactive chats to be filed under a project", + "server", + help="When on, starting a new interactive chat is refused unless it is filed " + "under a project. This is off by default and changes nothing until you turn it " + "on. A person can start a chat only once they belong to at least one project: " + "there is no automatic or personal project, so before turning this on in a " + "strict deployment, give each user membership in a project or mark a project " + "public, or they will not be able to start chats at all. Forking or resuming a " + "chat keeps the source chat's project; forking a chat that has no project is " + "refused just like starting a fresh chat without one. Automation such as " + "channel and scheduler activity, and coordinator-spawned sessions, are not " + "affected.", + ), # -- cluster -------------------------------------------------------- SettingDef( "cluster.node_fan_out_limit", diff --git a/turnstone/server.py b/turnstone/server.py index 1f732f53..e6f9003c 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -2216,6 +2216,7 @@ async def _interactive_create_validate_request( status_code=400, ) inherited_pid = False + resume_inherited_pid = False body_parent = body.get("parent_ws_id") or None if body_parent is not None: from turnstone.core.storage._registry import get_storage as _get_storage_for_parent @@ -2243,6 +2244,60 @@ async def _interactive_create_validate_request( if not (body.get("project_id") or "") and parent_row.get("project_id"): body["project_id"] = parent_row.get("project_id") inherited_pid = True + # Fork/resume: a fork's project is STRUCTURALLY its source's, and only when + # the require_project gate is on (off is byte-identical — no resolve, no + # inherit, explicit project_id untouched). This DELIBERATELY DIVERGES from the + # sibling parent-coordinator block at :2244: that block DEFERS to an explicit + # body project_id (a coordinator spawn carries no history, so the + # spawn_workstream(project=…) escape hatch is legitimate), whereas a fork + # copies the SOURCE's conversation — which must never be re-filed under an + # unrelated project the caller merely owns — so here we DISCARD any explicit + # project_id up front and then inherit only the source's own project. An + # explicit body project_id can never influence a fork. + # + # No cross-tenant oracle, by construction: for a source that is inaccessible- + # private (attach 403 → resume_inherited_pid drop below), projectless, or + # nonexistent/unresolvable, body["project_id"] stays "" and the gate emits ONE + # uniform 400 — identical BODY *and* STATUS (the discard, not body-equalising, + # is what makes them indistinguishable). The residual side-channel is DB-query + # LATENCY only (an inaccessible-private source runs extra get_project/ + # is_project_member); constant-time storage is out of scope. + # + # RAW/raising storage is deliberate — the swallowing memory.resolve_workstream + # would turn a transient DB error into None → a misleading "requires a project" + # 400, whereas a raise here surfaces an honest 500 (consistent with the parent + # block's sync get_workstream). resume_ws is only read, never mutated: + # post_install still performs the real fork (one extra indexed lookup on the + # rare fork path). Caveat (pre-existing to the resume mechanic): post_install + # re-resolves via the swallowing resolver, so a delete/blip between here and + # there degrades the fork to a fresh chat in the inherited project. + from turnstone.core.auth import require_project_enabled + + if ( + isinstance(resume_ws_id, str) + and resume_ws_id + and require_project_enabled(getattr(request.app.state, "config_store", None)) + ): + # Discard any caller-supplied project_id FIRST: a fork is filed under its + # SOURCE's project, or (no accessible source project) refused — never a + # caller pick. This structural discard is what blocks the re-file and makes + # the {inaccessible/projectless/nonexistent}-source outcomes uniform. It + # gates on require_project_enabled (the flag) and NOT require_project_denies_ + # create (flag + service/coordinator exemptions), DELIBERATELY: the + # exemptions waive the "must have a project" MANDATE, but fork-integrity — a + # fork's copied history must never be re-filed under an unrelated project — + # is a security invariant that binds every forker while the feature is on. + # The two require_project predicates differ here on purpose. + body["project_id"] = "" + from turnstone.core.storage._registry import get_storage as _get_storage_for_resume + + _rstorage = _get_storage_for_resume() + if _rstorage is not None: + _canonical = _rstorage.resolve_workstream(resume_ws_id) + _src_row = _rstorage.get_workstream(_canonical) if _canonical else None + if _src_row and _src_row.get("project_id"): + body["project_id"] = _src_row["project_id"] + resume_inherited_pid = True # Project attach gate (explicit or parent-inherited): a private # project accepts new workstreams only from its owner/members, and a # nonexistent EXPLICIT project_id is a caller error rather than a @@ -2260,7 +2315,19 @@ async def _interactive_create_validate_request( denied = ensure_project_attachable(uid, attach_pid) if denied is not None: status, message = denied - if inherited_pid and status == 400: + if resume_inherited_pid: + # Resume-inherited project: ANY denial (unknown 400, private + # 403, storage-blip/None 403) drops to a projectless create, so + # a private/inaccessible/dangling SOURCE is indistinguishable + # from a projectless or nonexistent one. Surfacing the 403 would + # leak that the resume_ws id sits under a private project the + # caller can't see (a cross-tenant oracle). The require_project + # gate then emits ONE uniform 400 downstream. + body["project_id"] = "" + elif inherited_pid and status == 400: + # Parent-inherited dangling project (deleted): the child simply + # isn't attached. A 403 here (revoked coordinator membership) + # still hard-returns below — deliberately loud, unlike resume. body["project_id"] = "" else: return JSONResponse({"error": message}, status_code=status) @@ -3110,7 +3177,7 @@ def _project_request_uid(request: Request) -> tuple[str, JSONResponse | None]: async def list_projects(request: Request) -> JSONResponse: """GET /v1/api/projects — projects the caller owns, is a member of, or public.""" - from turnstone.core.auth import require_permission + from turnstone.core.auth import require_permission, require_project_enabled from turnstone.core.storage import get_storage err = require_permission(request, "project.read") @@ -3126,7 +3193,17 @@ async def list_projects(request: Request) -> JSONResponse: ) storage = get_storage() rows = storage.list_projects_for_user(uid, include_archived=include_archived) if storage else [] - return JSONResponse({"projects": [_project_view(r) for r in rows], "total": len(rows)}) + # Advisory only — the create gate on the enforcing node is authoritative. + # Read via the SAME predicate so advisory and gate can't drift in logic + # (they still read per-process ConfigStore caches, which converge at rest). + cs = getattr(request.app.state, "config_store", None) + return JSONResponse( + { + "projects": [_project_view(r) for r in rows], + "total": len(rows), + "require_project": require_project_enabled(cs), + } + ) async def create_project(request: Request) -> JSONResponse: @@ -4667,6 +4744,7 @@ def create_app( sse_executor_lookup=lambda request: request.app.state.sse_executor, create_supports_attachments=True, create_supports_user_id_override=True, + create_gate_require_project=True, create_validate_request=_interactive_create_validate_request, create_build_kwargs=_interactive_create_build_kwargs, create_post_install=_interactive_create_post_install, diff --git a/turnstone/shared_static/projects.js b/turnstone/shared_static/projects.js index 4c10e3cb..86cbe249 100644 --- a/turnstone/shared_static/projects.js +++ b/turnstone/shared_static/projects.js @@ -21,6 +21,7 @@ let _loaded = false; // has the first refresh attempt completed (ok or failed)? let _lastError = null; // last failure: HTTP status, 0 for network/parse, null when ok let _inflight = null; // shared pending refresh so concurrent callers coalesce let _fingerprint = null; // last fired map signature, for change-detection +let _requireProject = false; // server.require_project advisory; fail-open false const _subs = []; // () => void, fired after each CHANGED refresh function _fp(rows) { @@ -79,12 +80,16 @@ export function refreshProjects() { // than blanking it, and record the status so the failure is visible // instead of looking like an empty list. _lastError = r.status; + // The require_project advisory fails OPEN: a stale-true value would make + // the composer hide options on a transient error, so reset it to false. + _requireProject = false; console.warn("projects: GET /v1/api/projects -> " + r.status); return null; }) .then(function (data) { if (data) { _lastError = null; + _requireProject = !!data.require_project; _setCache(data.projects || []); } return _cache; @@ -94,6 +99,7 @@ export function refreshProjects() { // preserve the last-known cache, never reject (callers chain a bare // .then), and surface the failure. _lastError = 0; + _requireProject = false; // fail-open (see the non-OK branch above) console.warn("projects: refresh failed", e); return _cache; }) @@ -126,6 +132,15 @@ export function projectsError() { return _lastError; } +/** Whether this deployment requires new chats to be filed under a project + * (server.require_project). ADVISORY only — the create endpoint on the + * enforcing node is authoritative. Fails OPEN to false on any refresh failure + * so the composer never hides options on a stale-true value; read it only + * AFTER {@link refreshProjects} resolves. */ +export function requireProject() { + return _requireProject; +} + /** Display name for a project_id, or "" when unknown (not yet loaded, no * access, or since-removed). */ export function projectName(id) { @@ -169,6 +184,7 @@ window.TurnstoneProjects = { getProjects: getProjects, projectsLoaded: projectsLoaded, projectsError: projectsError, + requireProject: requireProject, projectName: projectName, projectChoices: projectChoices, onProjectsChange: onProjectsChange, diff --git a/turnstone/ui/static/app.js b/turnstone/ui/static/app.js index 39056e25..83ab582e 100644 --- a/turnstone/ui/static/app.js +++ b/turnstone/ui/static/app.js @@ -399,15 +399,37 @@ function showNewWsModal(forkFromWsId) { }); // Project picker — populated from the shared projects cache, refreshed on - // open so a project created elsewhere appears. Hidden when forking: a fork - // inherits its parent's project. + // open. Fresh creates SHOW it; forks HIDE it — a fork's project is its + // source's, enforced server-side (an explicit body project_id is discarded for + // a fork), so the frontend never sends a project for a fork. + // + // By design there is NO in-modal project picker for a fork: under + // require_project a fork of a PROJECTLESS source is refused by the node with a + // 400 and no project field (matches the setting help "forking a chat that has + // no project is refused just like starting a fresh chat without one") — the + // operator files the source under a project first. Do NOT "restore" a fork + // project picker here without re-opening the cross-project re-file hole it caused. const projLabel = document.querySelector('label[for="new-ws-project"]'); const projSelect = document.getElementById("new-ws-project"); + const projHint = projLabel ? projLabel.querySelector(".label-hint") : null; if (projLabel) projLabel.hidden = !!_forkFromWsId; if (projSelect) projSelect.hidden = !!_forkFromWsId; + // Seed the hint from the warm cache SYNCHRONOUSLY so a fresh create isn't + // mislabeled "optional" for the duration of the (redundant) refresh round-trip + // — requireProject() reads a cache the rail warms at startup. The refresh below + // re-affirms it for the rare cold-cache open. + if (projHint && !_forkFromWsId && window.TurnstoneProjects) { + projHint.textContent = window.TurnstoneProjects.requireProject() + ? "required" + : "optional"; + } if (projSelect && !_forkFromWsId && window.TurnstoneProjects) { window.TurnstoneProjects.refreshProjects().then(function () { - _populateProjectSelect(projSelect); + const strict = !!window.TurnstoneProjects.requireProject(); + // Honest label: under require_project a fresh create must resolve to a real + // project, so the static "optional" hint must not claim otherwise. + if (projHint) projHint.textContent = strict ? "required" : "optional"; + _populateProjectSelect(projSelect, { requireProject: strict }); }); } @@ -479,6 +501,9 @@ function _ensureStandaloneProjectCreator(sel) { }, onClose: function () { if (sel.value === _PROJECT_NEW) sel.value = ""; + // Under require_project a fresh picker must not be left blank after a + // cancelled "+ New project…" — snap back to a valid project. + _reconcileRequiredProjectSelection(sel); }, }); if (sel.parentNode) sel.parentNode.insertBefore(creator.el, sel.nextSibling); @@ -502,10 +527,7 @@ function _populatePersonaSelect(sel) { if (placeholder) sel.appendChild(placeholder); const choices = window.TurnstonePersonas.personaChoices("interactive"); choices.forEach(function (c) { - const opt = document.createElement("option"); - opt.value = c.value; - opt.textContent = c.text; - sel.appendChild(opt); + _appendOption(sel, c.value, c.text, false); }); const stillValid = choices.some(function (c) { return c.value === previous; @@ -518,28 +540,90 @@ function _populatePersonaSelect(sel) { } } -// Fill a project . +function _appendOption(sel, value, text, disabled) { + const opt = document.createElement("option"); + opt.value = value; + opt.textContent = text; + if (disabled) opt.disabled = true; + sel.appendChild(opt); +} + +// True when *val* is currently an