create-caspian-app 0.4.0-rc.1 → 0.4.0-rc.2

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.
@@ -101,7 +101,8 @@ This is the top architectural requirement for this workspace. Treat it as a hard
101
101
  - Do not move normal file upload or file-manager behavior into `main.py`; keep those actions in the owning route `index.py` and shared helpers in `src/lib/**`.
102
102
  - Document route param behavior exactly as implemented here.
103
103
  - Do not use `main.py` alone to infer whether optional features are enabled; confirm that in `caspian.config.json` first.
104
- - Before changing WebSocket behavior, verify `cfg.websocket`, the app's endpoint registration, origin allow-list logic, session/auth checks, idle timeout, maximum message size, close codes, and connection cleanup. HTTP-only middleware does not automatically protect `scope["type"] == "websocket"` connections.
104
+ - Before changing WebSocket behavior, verify `cfg.websocket`, the app's endpoint registration, the `authorize_websocket(...)` guard in `src/lib/websocket/websocket_security.py`, idle timeout, maximum message size, close codes, and connection cleanup. HTTP-only middleware does not automatically protect `scope["type"] == "websocket"` connections, so socket auth lives in that guard, not `AuthMiddleware`.
105
+ - Authorize sockets with the single `authorize_websocket(...)` guard, which runs the origin check then delegates to Caspian `Auth` (`Auth.set_request(websocket)` + `is_authenticated`/`get_payload`/`check_role`). Add channels by calling that guard with `require_auth=`/`roles=`; do not re-implement session/`exp`/payload parsing per endpoint. Keep authenticated and guest broadcast pools separate, and treat the socket session as read-only.
105
106
 
106
107
  ### `src/lib/**/*.py`
107
108
 
@@ -112,7 +113,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
112
113
  - When `caspian.config.json` has `mcp: true`, keep app-owned MCP tools in `src/lib/mcp/mcp_server.py` and keep the default FastMCP config in `src/lib/mcp/fastmcp.json`. If those locations change, update `settings/restart-mcp.ts` and the MCP docs together.
113
114
  - Keep auth policy in `src/lib/auth/auth_config.py`. Keep auth bootstrap and middleware order changes in `main.py`.
114
115
  - Do not recreate or customize `src/lib/security/runtime_security.py` for normal application work. Runtime security helpers are package-owned in `casp.runtime_security`; app-specific policy should live in app-owned config or route/helper code instead.
115
- - Keep reusable WebSocket helpers under `src/lib/websocket/**` when they are shared across socket endpoints or route clients. Common shared helpers include session extraction, authenticated payload checks, origin utilities, connection managers, payload normalization, and broadcast fan-out.
116
+ - Keep reusable WebSocket helpers under `src/lib/websocket/**` when they are shared across socket endpoints or route clients. Common shared helpers include the `authorize_websocket(...)` guard (origin + `Auth`-delegated auth), origin utilities, connection managers, payload normalization, and broadcast fan-out.
116
117
 
117
118
  ### `src/components/**/*.py`
118
119
 
package/dist/AGENTS.md CHANGED
@@ -74,7 +74,8 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
74
74
  - When `caspian.config.json` has `websocket: true`, WebSocket behavior is app-owned in `main.py`. Do not assume fixed route names or endpoint paths across Caspian projects; define project-specific socket paths in the app, keep shared socket helpers in `src/lib/websocket/**` when needed, and let each route that needs a socket client pass its own `websocket_path` or `websocket_url` into its template.
75
75
  - WebSocket pages may be public, authenticated, role-gated, or mixed depending on the app. Keep route-specific browser UI in that route's `src/app/**/index.html`, keep first-render socket URL/path values in the matching `index.py`, and keep reusable session/auth/connection/broadcast helpers out of route files only when they are shared.
76
76
  - For WebSocket clients, use PulsePoint for component state and lifecycle but native `new WebSocket(...)` for the transport. Keep the socket in `pp.ref(...)`, close it in a cleanup effect, and keep direct socket event listeners inside the owning route template script.
77
- - Before changing socket security, verify the app's origin allow-list logic, session extraction, authenticated payload checks, idle timeout, message-size limits, and close codes in the running code. HTTP route privacy and `AuthMiddleware` do not by themselves protect WebSocket scopes.
77
+ - Before changing socket security, verify the app's origin allow-list logic, auth check, idle timeout, message-size limits, and close codes in the running code. HTTP route privacy and `AuthMiddleware` do not by themselves protect WebSocket scopes: the HTTP middleware stack early-returns on `scope["type"] == "websocket"`, so only `SessionMiddleware` runs and each socket endpoint must authorize itself.
78
+ - Socket authorization in this workspace is a single guard, `authorize_websocket(...)` in `src/lib/websocket/websocket_security.py`, that runs the origin check then delegates auth to Caspian's `Auth` (`Auth.set_request(websocket)` plus `is_authenticated`/`get_payload`/`check_role`). Reuse that guard for new channels and gate them with `require_auth=` / `roles=`; do not re-implement session/`exp`/payload parsing per endpoint. Keep authenticated and guest traffic in separate broadcast pools, and treat the socket session as read-only (mutations are not persisted to the cookie over a WebSocket).
78
79
  - Keep route-specific backend logic in that route's `src/app/**/index.py`, including first-render data loading, route-owned `@rpc()` actions, auth checks, redirects, and validation. Move logic to `src/lib/**` only when it is shared by more than one route, feature, component, or integration.
79
80
  - For grouped-subtree SPA navigation UX, the current browser runtime keeps unmarked shell scrollers stable and uses `pp-reset-scroll="true"` on the content pane that should reset. Check `pulsepoint.md`, `routing.md`, and `public/js/pp-reactive-v2.js` before changing that behavior.
80
81
  - Before updating docs, verify runtime-specific claims such as middleware order, route param injection, `layout()` behavior, `StateManager` persistence, safe public-file serving, response header, or session-secret behavior against the current `main.py` and installed `casp` package, especially `.venv/Lib/site-packages/casp/runtime_security.py`, rather than copying older notes.
package/dist/main.py CHANGED
@@ -182,6 +182,11 @@ REQUEST_TIMEOUT_SECONDS = max(
182
182
  1.0,
183
183
  float(os.getenv('CASPIAN_REQUEST_TIMEOUT_SECONDS', 20)),
184
184
  )
185
+ # Path prefixes that serve long-lived streaming responses (SSE, etc.) and must
186
+ # not be subject to the per-request timeout. The MCP streamable-HTTP transport
187
+ # keeps GET /mcp/ open indefinitely; wrapping it in asyncio.wait_for cancels the
188
+ # stream mid-response and corrupts the ASGI message sequence.
189
+ STREAMING_PATH_PREFIXES = ('/mcp',)
185
190
  MAX_CONTENT_LENGTH_BYTES = max(1, MAX_CONTENT_LENGTH_MB) * 1024 * 1024
186
191
 
187
192
 
@@ -486,6 +491,12 @@ class RequestDiagnosticsMiddleware:
486
491
  if should_log and not IS_PRODUCTION:
487
492
  print(f"[request:start] {method} {path}", flush=True)
488
493
 
494
+ # Long-lived streaming endpoints (MCP SSE) must bypass the timeout, or
495
+ # asyncio.wait_for cancels the stream and the ASGI send sequence breaks.
496
+ if path.startswith(STREAMING_PATH_PREFIXES):
497
+ await self.app(scope, receive, send)
498
+ return
499
+
489
500
  try:
490
501
  await asyncio.wait_for(
491
502
  self.app(scope, receive, send),
@@ -536,48 +547,6 @@ MAX_WEBSOCKET_MESSAGE_BYTES = max(
536
547
  )
537
548
 
538
549
 
539
- def _normalized_origin(value: str) -> str:
540
- return (value or "").strip().rstrip("/")
541
-
542
-
543
- def _configured_websocket_origins() -> set[str]:
544
- raw_values = []
545
- for env_name in (
546
- "WEBSOCKET_ALLOWED_ORIGINS",
547
- "CORS_ALLOWED_ORIGINS",
548
- "APP_BASE_URL",
549
- ):
550
- raw_values.extend(os.getenv(env_name, "").split(","))
551
-
552
- return {
553
- _normalized_origin(origin)
554
- for origin in raw_values
555
- if _normalized_origin(origin)
556
- }
557
-
558
-
559
- def _websocket_same_origin(websocket: WebSocket) -> str:
560
- scheme = "https" if websocket.url.scheme == "wss" else "http"
561
- return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
562
-
563
-
564
- def _is_websocket_origin_allowed(websocket: WebSocket) -> bool:
565
- origin = _normalized_origin(websocket.headers.get("origin", ""))
566
- if not origin:
567
- return not IS_PRODUCTION
568
-
569
- parsed_origin = urlparse(origin)
570
- if not parsed_origin.scheme or not parsed_origin.netloc:
571
- return False
572
-
573
- if not IS_PRODUCTION and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
574
- return parsed_origin.scheme == "http"
575
-
576
- allowed_origins = _configured_websocket_origins()
577
- allowed_origins.add(_websocket_same_origin(websocket))
578
- return origin in allowed_origins
579
-
580
-
581
550
  async def _run_websocket_channel(
582
551
  websocket: WebSocket,
583
552
  manager: Any,
@@ -655,30 +624,20 @@ if cfg.websocket:
655
624
  # Optional, feature-gated module: only generated when websocket is enabled
656
625
  # in caspian.config.json, so suppress the static "module not found" check.
657
626
  from src.lib.websocket.websocket_security import ( # type: ignore[import-not-found]
658
- get_authenticated_payload_from_session,
659
- get_websocket_session,
627
+ authorize_websocket,
660
628
  public_websocket_connections,
661
629
  websocket_connections,
662
630
  )
663
631
 
632
+ # Both endpoints share ONE guard (`authorize_websocket`) that delegates to
633
+ # Caspian's `Auth`, and ONE transport loop (`_run_websocket_channel`). They
634
+ # differ only by auth policy and broadcast pool. To role-gate a channel,
635
+ # pass `roles=[...]`; to add another channel, add an endpoint that calls the
636
+ # same guard.
637
+
664
638
  @app.websocket(WEBSOCKET_PATH)
665
639
  async def websocket_live_endpoint(websocket: WebSocket):
666
- if not _is_websocket_origin_allowed(websocket):
667
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
668
- return
669
-
670
- session = get_websocket_session(websocket)
671
- user_payload = get_authenticated_payload_from_session(
672
- session,
673
- Auth.get_instance(),
674
- )
675
- if user_payload is None:
676
- await websocket.accept()
677
- await websocket.send_json({
678
- "type": "error",
679
- "message": "Authentication required.",
680
- })
681
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
640
+ if await authorize_websocket(websocket, require_auth=True) is None:
682
641
  return
683
642
 
684
643
  await _run_websocket_channel(
@@ -690,8 +649,7 @@ if cfg.websocket:
690
649
 
691
650
  @app.websocket(PUBLIC_WEBSOCKET_PATH)
692
651
  async def websocket_public_endpoint(websocket: WebSocket):
693
- if not _is_websocket_origin_allowed(websocket):
694
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
652
+ if await authorize_websocket(websocket, require_auth=False) is None:
695
653
  return
696
654
 
697
655
  await _run_websocket_channel(
@@ -14,13 +14,13 @@ FILES_LIST_PATH = PROJECT_ROOT / "settings" / "files-list.json"
14
14
  COMPONENT_MAP_PATH = PROJECT_ROOT / "settings" / "component-map.json"
15
15
 
16
16
 
17
- mcp = FastMCP(
18
- name="Caspian MCP",
19
- instructions=(
20
- "Read-only workspace metadata for the Caspian application. "
21
- "Use these tools for project configuration, generated file inventory, and component discovery."
22
- ),
23
- )
17
+ mcp = FastMCP(
18
+ name="Caspian MCP",
19
+ instructions=(
20
+ "Read-only workspace metadata for the Caspian application. "
21
+ "Use these tools for project configuration, generated file inventory, and component discovery."
22
+ ),
23
+ )
24
24
 
25
25
 
26
26
  def _load_json(path: Path, fallback: Any) -> Any:
@@ -88,4 +88,4 @@ def component_inventory() -> dict[str, Any]:
88
88
  return {
89
89
  "count": len(components),
90
90
  "components": components,
91
- }
91
+ }
@@ -1,60 +1,130 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import MutableMapping
4
- from datetime import datetime, timezone
3
+ import os
5
4
  from typing import Any
5
+ from urllib.parse import urlparse
6
6
 
7
- from fastapi import WebSocket
7
+ from fastapi import WebSocket, status
8
8
 
9
+ from casp.auth import Auth
9
10
 
10
- def get_websocket_session(websocket: WebSocket) -> MutableMapping[str, Any]:
11
- session = getattr(websocket, "session", None)
12
- if isinstance(session, MutableMapping):
13
- return session
11
+ # ====
12
+ # WebSocket security: ONE guard that reuses Caspian's `Auth` as the source of
13
+ # truth, so socket auth lines up with HTTP route auth instead of duplicating it.
14
+ #
15
+ # Why this lives here and not in `AuthMiddleware`:
16
+ # HTTP middleware in `main.py` early-returns on every non-`http` scope, so a
17
+ # WebSocket handshake (`scope["type"] == "websocket"`) is never seen by
18
+ # `AuthMiddleware`. Each socket endpoint must therefore authorize itself. We do
19
+ # that by delegating to the same `Auth` instance that powers page privacy.
20
+ # ====
14
21
 
15
- scope_session = websocket.scope.get("session")
16
- if isinstance(scope_session, MutableMapping):
17
- return scope_session
18
22
 
19
- return {}
23
+ def _is_production() -> bool:
24
+ return os.getenv("APP_ENV") == "production"
20
25
 
21
26
 
22
- def get_authenticated_payload_from_session(
23
- session: MutableMapping[str, Any],
24
- auth_instance: Any,
25
- ) -> dict[str, Any] | None:
26
- payload_key = getattr(auth_instance, "PAYLOAD_SESSION_KEY", "")
27
- payload_name = getattr(auth_instance, "PAYLOAD_NAME", "")
27
+ def _normalized_origin(value: str) -> str:
28
+ return (value or "").strip().rstrip("/")
29
+
30
+
31
+ def _configured_websocket_origins() -> set[str]:
32
+ raw_values: list[str] = []
33
+ for env_name in (
34
+ "WEBSOCKET_ALLOWED_ORIGINS",
35
+ "CORS_ALLOWED_ORIGINS",
36
+ "APP_BASE_URL",
37
+ ):
38
+ raw_values.extend(os.getenv(env_name, "").split(","))
39
+
40
+ return {
41
+ _normalized_origin(origin)
42
+ for origin in raw_values
43
+ if _normalized_origin(origin)
44
+ }
45
+
46
+
47
+ def _websocket_same_origin(websocket: WebSocket) -> str:
48
+ scheme = "https" if websocket.url.scheme == "wss" else "http"
49
+ return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
50
+
51
+
52
+ def is_websocket_origin_allowed(websocket: WebSocket) -> bool:
53
+ """Anti-CSWSH origin check. NOT authentication.
54
+
55
+ A browser cannot forge the `Origin` header, so this blocks cross-site
56
+ script-driven handshakes. A raw client (wscat, python websockets) can send
57
+ any origin, which is exactly why authentication is a separate gate.
58
+ """
59
+ origin = _normalized_origin(websocket.headers.get("origin", ""))
60
+ if not origin:
61
+ # No Origin header: tolerate local tooling in dev, reject in production.
62
+ return not _is_production()
28
63
 
29
- payload_wrapper = session.get(payload_key)
30
- if not isinstance(payload_wrapper, dict):
64
+ parsed_origin = urlparse(origin)
65
+ if not parsed_origin.scheme or not parsed_origin.netloc:
66
+ return False
67
+
68
+ if not _is_production() and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
69
+ return parsed_origin.scheme == "http"
70
+
71
+ allowed_origins = _configured_websocket_origins()
72
+ allowed_origins.add(_websocket_same_origin(websocket))
73
+ return origin in allowed_origins
74
+
75
+
76
+ async def authorize_websocket(
77
+ websocket: WebSocket,
78
+ *,
79
+ require_auth: bool,
80
+ roles: list[str] | None = None,
81
+ ) -> dict[str, Any] | None:
82
+ """Single entry point for socket authorization.
83
+
84
+ Returns the connecting identity on success, or `None` after closing the
85
+ socket on failure. The return value gates the connection only; do not echo
86
+ it to other clients (it may contain the authenticated user's payload).
87
+
88
+ - `require_auth=True` -> authenticated session required, else close 1008.
89
+ - `require_auth=False` -> guests allowed; authenticated users keep identity.
90
+ - `roles` -> RBAC via `Auth.check_role`, same rule as HTTP routes.
91
+
92
+ Auth is read from the session cookie that `SessionMiddleware` (the one
93
+ middleware that runs on websocket scopes) exposes as `websocket.session`.
94
+ Treat this as read-only: session writes are not persisted back to the cookie
95
+ over a WebSocket, so this guard must not rely on `refresh_session`.
96
+ """
97
+ # 1. Origin / CSWSH check runs before accept so rejected handshakes never
98
+ # upgrade.
99
+ if not is_websocket_origin_allowed(websocket):
100
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
31
101
  return None
32
102
 
33
- exp = payload_wrapper.get("exp")
34
- if exp is not None:
35
- try:
36
- if float(exp) < datetime.now(timezone.utc).timestamp():
37
- session.pop(payload_key, None)
38
- return None
39
- except Exception:
40
- session.pop(payload_key, None)
103
+ # 2. Reuse Caspian `Auth` as the single source of truth. `Auth` only reads
104
+ # `request.session`, and a WebSocket exposes `.session`, so binding the
105
+ # socket as the request context lets us reuse the exact HTTP auth logic.
106
+ Auth.set_request(websocket) # type: ignore[arg-type]
107
+ auth = Auth.get_instance()
108
+
109
+ if auth.is_authenticated():
110
+ payload = auth.get_payload() or {}
111
+ if roles and not auth.check_role(payload, roles):
112
+ await _reject(websocket, "Forbidden.")
41
113
  return None
114
+ return payload
42
115
 
43
- payload = payload_wrapper.get(payload_name)
44
- if payload is None:
45
- session.pop(payload_key, None)
116
+ if require_auth:
117
+ await _reject(websocket, "Authentication required.")
46
118
  return None
47
119
 
48
- if isinstance(payload, dict):
49
- if getattr(getattr(auth_instance, "settings", None), "token_auto_refresh", False):
50
- refreshed_wrapper = dict(payload_wrapper)
51
- refreshed_wrapper["exp"] = auth_instance._calculate_expiration(
52
- auth_instance.settings.default_token_validity
53
- )
54
- session[payload_key] = refreshed_wrapper
55
- return payload
120
+ return {"guest": True, "scope": "public"}
121
+
56
122
 
57
- return {"value": payload}
123
+ async def _reject(websocket: WebSocket, message: str) -> None:
124
+ """Accept, surface a JSON error so the browser UI can react, then close."""
125
+ await websocket.accept()
126
+ await websocket.send_json({"type": "error", "message": message})
127
+ await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
58
128
 
59
129
 
60
130
  class WebSocketConnectionManager:
@@ -80,5 +150,7 @@ class WebSocketConnectionManager:
80
150
  self.disconnect(websocket)
81
151
 
82
152
 
83
- websocket_connections = WebSocketConnectionManager()
84
- public_websocket_connections = WebSocketConnectionManager()
153
+ # Separate pools keep authenticated traffic isolated from guest traffic, so a
154
+ # private broadcast can never fan out to a public (guest) connection.
155
+ websocket_connections = WebSocketConnectionManager()
156
+ public_websocket_connections = WebSocketConnectionManager()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "0.4.0-rc.1",
3
+ "version": "0.4.0-rc.2",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",