create-caspian-app 0.4.0-rc.0 → 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
@@ -19,6 +19,8 @@ from fastapi import (
19
19
  )
20
20
  from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
21
21
  from starlette.datastructures import MutableHeaders
22
+ from starlette.middleware import Middleware
23
+ from starlette.middleware.cors import CORSMiddleware
22
24
  from starlette.middleware.sessions import SessionMiddleware
23
25
  from starlette.types import ASGIApp, Receive, Scope, Send
24
26
  from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -57,6 +59,78 @@ from casp.runtime_security import (
57
59
  load_dotenv()
58
60
  cfg = get_config()
59
61
 
62
+ # ====
63
+ # CORS configuration (shared .env convention, mirrors casp.rpc origin checks)
64
+ # ====
65
+
66
+
67
+ def _csv_env(name: str) -> list[str]:
68
+ return [item.strip() for item in os.getenv(name, "").split(",") if item.strip()]
69
+
70
+
71
+ def _bool_env(name: str, default: bool = False) -> bool:
72
+ raw = os.getenv(name)
73
+ if raw is None:
74
+ return default
75
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
76
+
77
+
78
+ def _configured_cors_origins() -> list[str]:
79
+ """Browser origins allowed to call the app, per the .env convention."""
80
+ origins: list[str] = []
81
+ for raw in (*_csv_env("CORS_ALLOWED_ORIGINS"), os.getenv("APP_BASE_URL", "")):
82
+ value = (raw or "").strip().rstrip("/")
83
+ if value and value not in origins:
84
+ origins.append(value)
85
+ return origins
86
+
87
+
88
+ def _build_mcp_cors_middleware() -> "Middleware":
89
+ """Build the MCP CORS layer from .env, adding MCP-required headers.
90
+
91
+ Browser MCP clients (e.g. MCP Inspector "Direct") send an OPTIONS preflight
92
+ and rely on the mcp-session-id / mcp-protocol-version headers, which are not
93
+ in the generic CORS_ALLOWED_HEADERS list, so they are merged in here.
94
+ """
95
+ origins = _configured_cors_origins()
96
+ allow_credentials = _bool_env("CORS_ALLOW_CREDENTIALS")
97
+
98
+ if not origins:
99
+ # The CORS spec forbids "*" together with credentials, so when no
100
+ # explicit origin is configured fall back to open + no credentials.
101
+ origins = ["*"]
102
+ allow_credentials = False
103
+
104
+ methods = _csv_env("CORS_ALLOWED_METHODS") or [
105
+ "GET", "POST", "DELETE", "OPTIONS"]
106
+
107
+ headers = _csv_env("CORS_ALLOWED_HEADERS")
108
+ for required in ("Content-Type", "Accept", "Authorization",
109
+ "mcp-session-id", "mcp-protocol-version"):
110
+ if required.lower() not in {h.lower() for h in headers}:
111
+ headers.append(required)
112
+
113
+ expose = _csv_env("CORS_EXPOSE_HEADERS")
114
+ for required in ("mcp-session-id", "mcp-protocol-version"):
115
+ if required.lower() not in {h.lower() for h in expose}:
116
+ expose.append(required)
117
+
118
+ try:
119
+ max_age = int(os.getenv("CORS_MAX_AGE", "600"))
120
+ except ValueError:
121
+ max_age = 600
122
+
123
+ return Middleware(
124
+ CORSMiddleware,
125
+ allow_origins=origins,
126
+ allow_credentials=allow_credentials,
127
+ allow_methods=methods,
128
+ allow_headers=headers,
129
+ expose_headers=expose,
130
+ max_age=max_age,
131
+ )
132
+
133
+
60
134
  # ====
61
135
  # MCP SERVER (mounted into this app so one deploy serves web + MCP)
62
136
  # ====
@@ -66,7 +140,7 @@ if cfg.mcp:
66
140
  # caspian.config.json, so suppress the static "module not found" check.
67
141
  from src.lib.mcp.mcp_server import mcp # type: ignore[import-not-found]
68
142
  # Inner path "/" so the mount prefix below is the full endpoint path.
69
- mcp_app = mcp.http_app(path="/")
143
+ mcp_app = mcp.http_app(path="/", middleware=[_build_mcp_cors_middleware()])
70
144
 
71
145
  # ====
72
146
  # AUTH CONFIGURATION (App behavior - customize here)
@@ -108,6 +182,11 @@ REQUEST_TIMEOUT_SECONDS = max(
108
182
  1.0,
109
183
  float(os.getenv('CASPIAN_REQUEST_TIMEOUT_SECONDS', 20)),
110
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',)
111
190
  MAX_CONTENT_LENGTH_BYTES = max(1, MAX_CONTENT_LENGTH_MB) * 1024 * 1024
112
191
 
113
192
 
@@ -412,6 +491,12 @@ class RequestDiagnosticsMiddleware:
412
491
  if should_log and not IS_PRODUCTION:
413
492
  print(f"[request:start] {method} {path}", flush=True)
414
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
+
415
500
  try:
416
501
  await asyncio.wait_for(
417
502
  self.app(scope, receive, send),
@@ -462,48 +547,6 @@ MAX_WEBSOCKET_MESSAGE_BYTES = max(
462
547
  )
463
548
 
464
549
 
465
- def _normalized_origin(value: str) -> str:
466
- return (value or "").strip().rstrip("/")
467
-
468
-
469
- def _configured_websocket_origins() -> set[str]:
470
- raw_values = []
471
- for env_name in (
472
- "WEBSOCKET_ALLOWED_ORIGINS",
473
- "CORS_ALLOWED_ORIGINS",
474
- "APP_BASE_URL",
475
- ):
476
- raw_values.extend(os.getenv(env_name, "").split(","))
477
-
478
- return {
479
- _normalized_origin(origin)
480
- for origin in raw_values
481
- if _normalized_origin(origin)
482
- }
483
-
484
-
485
- def _websocket_same_origin(websocket: WebSocket) -> str:
486
- scheme = "https" if websocket.url.scheme == "wss" else "http"
487
- return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
488
-
489
-
490
- def _is_websocket_origin_allowed(websocket: WebSocket) -> bool:
491
- origin = _normalized_origin(websocket.headers.get("origin", ""))
492
- if not origin:
493
- return not IS_PRODUCTION
494
-
495
- parsed_origin = urlparse(origin)
496
- if not parsed_origin.scheme or not parsed_origin.netloc:
497
- return False
498
-
499
- if not IS_PRODUCTION and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
500
- return parsed_origin.scheme == "http"
501
-
502
- allowed_origins = _configured_websocket_origins()
503
- allowed_origins.add(_websocket_same_origin(websocket))
504
- return origin in allowed_origins
505
-
506
-
507
550
  async def _run_websocket_channel(
508
551
  websocket: WebSocket,
509
552
  manager: Any,
@@ -581,30 +624,20 @@ if cfg.websocket:
581
624
  # Optional, feature-gated module: only generated when websocket is enabled
582
625
  # in caspian.config.json, so suppress the static "module not found" check.
583
626
  from src.lib.websocket.websocket_security import ( # type: ignore[import-not-found]
584
- get_authenticated_payload_from_session,
585
- get_websocket_session,
627
+ authorize_websocket,
586
628
  public_websocket_connections,
587
629
  websocket_connections,
588
630
  )
589
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
+
590
638
  @app.websocket(WEBSOCKET_PATH)
591
639
  async def websocket_live_endpoint(websocket: WebSocket):
592
- if not _is_websocket_origin_allowed(websocket):
593
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
594
- return
595
-
596
- session = get_websocket_session(websocket)
597
- user_payload = get_authenticated_payload_from_session(
598
- session,
599
- Auth.get_instance(),
600
- )
601
- if user_payload is None:
602
- await websocket.accept()
603
- await websocket.send_json({
604
- "type": "error",
605
- "message": "Authentication required.",
606
- })
607
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
640
+ if await authorize_websocket(websocket, require_auth=True) is None:
608
641
  return
609
642
 
610
643
  await _run_websocket_channel(
@@ -616,8 +649,7 @@ if cfg.websocket:
616
649
 
617
650
  @app.websocket(PUBLIC_WEBSOCKET_PATH)
618
651
  async def websocket_public_endpoint(websocket: WebSocket):
619
- if not _is_websocket_origin_allowed(websocket):
620
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
652
+ if await authorize_websocket(websocket, require_auth=False) is None:
621
653
  return
622
654
 
623
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.0",
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",