create-caspian-app 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,192 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from typing import Any
5
- from urllib.parse import urlparse
6
-
7
- from fastapi import WebSocket, status
8
-
9
- from casp.auth import Auth
10
- from casp.runtime_security import is_production_environment
11
-
12
- # ====
13
- # WebSocket security: ONE guard that reuses Caspian's `Auth` as the source of
14
- # truth, so socket auth lines up with HTTP route auth instead of duplicating it.
15
- #
16
- # Why this lives here and not in `AuthMiddleware`:
17
- # HTTP middleware in `main.py` early-returns on every non-`http` scope, so a
18
- # WebSocket handshake (`scope["type"] == "websocket"`) is never seen by
19
- # `AuthMiddleware`. Each socket endpoint must therefore authorize itself. We do
20
- # that by delegating to the same `Auth` instance that powers page privacy.
21
- # ====
22
-
23
-
24
- def _is_production() -> bool:
25
- # Shared fail-closed resolution: an unset or misspelled APP_ENV must not
26
- # silently enable the development handshake relaxations below.
27
- return is_production_environment()
28
-
29
-
30
- def _normalized_origin(value: str) -> str:
31
- return (value or "").strip().rstrip("/")
32
-
33
-
34
- def _configured_websocket_origins() -> set[str]:
35
- raw_values: list[str] = []
36
- for env_name in (
37
- "WEBSOCKET_ALLOWED_ORIGINS",
38
- "CORS_ALLOWED_ORIGINS",
39
- "APP_BASE_URL",
40
- ):
41
- raw_values.extend(os.getenv(env_name, "").split(","))
42
-
43
- return {
44
- _normalized_origin(origin)
45
- for origin in raw_values
46
- if _normalized_origin(origin)
47
- }
48
-
49
-
50
- def _websocket_same_origin(websocket: WebSocket) -> str:
51
- scheme = "https" if websocket.url.scheme == "wss" else "http"
52
- return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
53
-
54
-
55
- def is_websocket_origin_allowed(websocket: WebSocket) -> bool:
56
- """Anti-CSWSH origin check. NOT authentication.
57
-
58
- A browser cannot forge the `Origin` header, so this blocks cross-site
59
- script-driven handshakes. A raw client (wscat, python websockets) can send
60
- any origin, which is exactly why authentication is a separate gate.
61
- """
62
- origin = _normalized_origin(websocket.headers.get("origin", ""))
63
- if not origin:
64
- # No Origin header: tolerate local tooling in dev, reject in production.
65
- return not _is_production()
66
-
67
- parsed_origin = urlparse(origin)
68
- if not parsed_origin.scheme or not parsed_origin.netloc:
69
- return False
70
-
71
- if not _is_production() and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
72
- return parsed_origin.scheme == "http"
73
-
74
- allowed_origins = _configured_websocket_origins()
75
-
76
- # The same-origin fallback is derived from the Host header, which a client
77
- # controls directly and a misconfigured proxy will forward verbatim: sending
78
- # `Host: evil.tld` with `Origin: https://evil.tld` would otherwise satisfy
79
- # this check against itself. It is a convenience for development only --
80
- # production must name its origins explicitly.
81
- if not _is_production():
82
- allowed_origins.add(_websocket_same_origin(websocket))
83
-
84
- return origin in allowed_origins
85
-
86
-
87
- async def authorize_websocket(
88
- websocket: WebSocket,
89
- *,
90
- require_auth: bool,
91
- roles: list[str] | None = None,
92
- ) -> dict[str, Any] | None:
93
- """Single entry point for socket authorization.
94
-
95
- Returns the connecting identity on success, or `None` after closing the
96
- socket on failure. The return value gates the connection only; do not echo
97
- it to other clients (it may contain the authenticated user's payload).
98
-
99
- - `require_auth=True` -> authenticated session required, else close 1008.
100
- - `require_auth=False` -> guests allowed; authenticated users keep identity.
101
- - `roles` -> RBAC via `Auth.check_role`, same rule as HTTP routes.
102
-
103
- Auth is read from the session cookie that `SessionMiddleware` (the one
104
- middleware that runs on websocket scopes) exposes as `websocket.session`.
105
- Treat this as read-only: session writes are not persisted back to the cookie
106
- over a WebSocket, so this guard must not rely on `refresh_session`.
107
- """
108
- # 1. Origin / CSWSH check runs before accept so rejected handshakes never
109
- # upgrade.
110
- if not is_websocket_origin_allowed(websocket):
111
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
112
- return None
113
-
114
- # 2. Reuse Caspian `Auth` as the single source of truth. `Auth` only reads
115
- # `request.session`, and a WebSocket exposes `.session`, so binding the
116
- # socket as the request context lets us reuse the exact HTTP auth logic.
117
- Auth.set_request(websocket) # type: ignore[arg-type]
118
- auth = Auth.get_instance()
119
-
120
- if auth.is_authenticated():
121
- payload = auth.get_payload() or {}
122
- if roles and not auth.check_role(payload, roles):
123
- await _reject(websocket, "Forbidden.")
124
- return None
125
- return payload
126
-
127
- if require_auth:
128
- await _reject(websocket, "Authentication required.")
129
- return None
130
-
131
- return {"guest": True, "scope": "public"}
132
-
133
-
134
- async def _reject(websocket: WebSocket, message: str) -> None:
135
- """Accept, surface a JSON error so the browser UI can react, then close."""
136
- await websocket.accept()
137
- await websocket.send_json({"type": "error", "message": message})
138
- await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
139
-
140
-
141
- # Ceiling on simultaneous sockets in one pool. Every open connection is a live
142
- # task plus a broadcast target, so an unbounded pool lets cheap clients grow
143
- # server memory and turn each broadcast into an amplification.
144
- MAX_WEBSOCKET_CONNECTIONS = max(
145
- 1, int(os.getenv("MAX_WEBSOCKET_CONNECTIONS", 200))
146
- )
147
-
148
-
149
- class WebSocketConnectionManager:
150
- def __init__(self, max_connections: int = MAX_WEBSOCKET_CONNECTIONS) -> None:
151
- self._connections: set[WebSocket] = set()
152
- self._max_connections = max_connections
153
-
154
- @property
155
- def connection_count(self) -> int:
156
- return len(self._connections)
157
-
158
- async def connect(self, websocket: WebSocket) -> bool:
159
- """Accept and register the socket. Returns False when the pool is full.
160
-
161
- The capacity check runs before `accept()`, so a refused client is closed
162
- during the handshake and never reaches an open state. The caller must
163
- stop on a False result rather than run a read loop on a socket that was
164
- never added to the pool.
165
- """
166
- if len(self._connections) >= self._max_connections:
167
- await websocket.close(code=status.WS_1013_TRY_AGAIN_LATER)
168
- return False
169
-
170
- await websocket.accept()
171
- self._connections.add(websocket)
172
- return True
173
-
174
- def disconnect(self, websocket: WebSocket) -> None:
175
- self._connections.discard(websocket)
176
-
177
- async def broadcast_json(self, payload: dict[str, Any]) -> None:
178
- stale: list[WebSocket] = []
179
- for websocket in list(self._connections):
180
- try:
181
- await websocket.send_json(payload)
182
- except RuntimeError:
183
- stale.append(websocket)
184
-
185
- for websocket in stale:
186
- self.disconnect(websocket)
187
-
188
-
189
- # Separate pools keep authenticated traffic isolated from guest traffic, so a
190
- # private broadcast can never fan out to a public (guest) connection.
191
- websocket_connections = WebSocketConnectionManager()
192
- public_websocket_connections = WebSocketConnectionManager()