zettcode-gateway 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (41) hide show
  1. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/.gitignore +5 -0
  2. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/PKG-INFO +2 -2
  3. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/pyproject.toml +1 -1
  4. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/app.py +69 -5
  5. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/cli.py +58 -0
  6. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/runfile.py +5 -0
  7. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/settings.py +19 -0
  8. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/app.css +58 -0
  9. zettcode_gateway-0.4.2/src/zettcode/gateway/static/app.js +3 -0
  10. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/index.html +3 -0
  11. zettcode_gateway-0.4.0/src/zettcode/gateway/static/app.js +0 -3
  12. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/__init__.py +0 -0
  13. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/backend.py +0 -0
  14. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/chat.py +0 -0
  15. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/py.typed +0 -0
  16. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/service.py +0 -0
  17. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_AMS-Regular-U6PRYMIZ.woff2 +0 -0
  18. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Caligraphic-Bold-5QL5CMTE.woff2 +0 -0
  19. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Caligraphic-Regular-KX5MEWCF.woff2 +0 -0
  20. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Fraktur-Bold-2QVFK6NQ.woff2 +0 -0
  21. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Fraktur-Regular-2PEIFJSJ.woff2 +0 -0
  22. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Main-Bold-YP5VVQRP.woff2 +0 -0
  23. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Main-BoldItalic-N4V3DX7S.woff2 +0 -0
  24. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Main-Italic-RELBIK7M.woff2 +0 -0
  25. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Main-Regular-ARRPAO67.woff2 +0 -0
  26. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Math-BoldItalic-K4WTGH3J.woff2 +0 -0
  27. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Math-Italic-6KGCHLFN.woff2 +0 -0
  28. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Bold-RRNVJFFW.woff2 +0 -0
  29. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Italic-HMPFTM52.woff2 +0 -0
  30. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_SansSerif-Regular-XIQ62X4E.woff2 +0 -0
  31. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Script-Regular-APUWIHLP.woff2 +0 -0
  32. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Size1-Regular-5LRUTBFT.woff2 +0 -0
  33. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Size2-Regular-LELKET5D.woff2 +0 -0
  34. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Size3-Regular-WQRQ47UD.woff2 +0 -0
  35. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Size4-Regular-CDMV7U5C.woff2 +0 -0
  36. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/assets/KaTeX_Typewriter-Regular-VBYJ4NRC.woff2 +0 -0
  37. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/chunks/chunk-Y6SLVHK3.js +0 -0
  38. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/chunks/render-G6G3TSB3.js +0 -0
  39. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/icon.png +0 -0
  40. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/logo.svg +0 -0
  41. {zettcode_gateway-0.4.0 → zettcode_gateway-0.4.2}/src/zettcode/gateway/static/manifest.webmanifest +0 -0
@@ -35,3 +35,8 @@ docs/internal/palettes.html
35
35
 
36
36
  # The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
37
37
  packages/order.txt
38
+
39
+ # The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
40
+ # installers under release/.
41
+ clients/desktop/renderer/
42
+ clients/desktop/release/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: zettcode-gateway
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: The web gateway of ZettCode: the chat page and the graph over a running server
5
5
  Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
6
  Author: Chang-LeHung
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
15
15
  Classifier: Programming Language :: Python :: 3.15
16
16
  Classifier: Typing :: Typed
17
17
  Requires-Python: >=3.10
18
- Requires-Dist: starlette<2,>=0.48
18
+ Requires-Dist: starlette<2,>=0.51
19
19
  Requires-Dist: uvicorn<1,>=0.35
20
20
  Requires-Dist: zettcode-engine
21
21
  Requires-Dist: zettcode-sdk
@@ -19,7 +19,7 @@ classifiers = [
19
19
  dependencies = [
20
20
  "zettcode-engine",
21
21
  "zettcode-sdk",
22
- "starlette>=0.48,<2",
22
+ "starlette>=0.51,<2",
23
23
  "uvicorn>=0.35,<1",
24
24
  ]
25
25
 
@@ -12,6 +12,8 @@ carry the gateway's own.
12
12
  from __future__ import annotations
13
13
 
14
14
  import asyncio
15
+ import base64
16
+ import binascii
15
17
  import contextlib
16
18
  import hmac
17
19
  import json
@@ -29,6 +31,7 @@ from urllib.parse import urlsplit
29
31
 
30
32
  from starlette.applications import Starlette
31
33
  from starlette.middleware import Middleware
34
+ from starlette.middleware.cors import CORSMiddleware
32
35
  from starlette.requests import HTTPConnection, Request
33
36
  from starlette.responses import FileResponse, JSONResponse, PlainTextResponse, Response
34
37
  from starlette.routing import Mount, Route, WebSocketRoute
@@ -70,6 +73,12 @@ LOGIN_WINDOW = 600.0
70
73
  #: What a WebSocket is closed with when it is not signed in.
71
74
  UNAUTHORIZED = 4401
72
75
 
76
+ #: The subprotocol a native client asks for, and the prefix under which it offers its token. A browser cannot put an
77
+ #: ``Authorization`` header on a WebSocket, so the token goes beside the subprotocol, base64url-encoded: a protocol
78
+ #: name is a token and base64url is made of token characters.
79
+ WS_PROTOCOL = "zettcode"
80
+ WS_TOKEN_PREFIX = "zettcode.token."
81
+
73
82
  #: The names a browser on this machine reaches it by.
74
83
  LOOPBACK_NAMES = frozenset({"127.0.0.1", "::1", "localhost"})
75
84
 
@@ -149,19 +158,43 @@ class Auth:
149
158
  return scheme.lower() == "bearer" and self.token_ok(given.strip())
150
159
 
151
160
 
152
- def same_origin(connection: HTTPConnection, *, required: bool = False) -> bool:
161
+ def same_origin(connection: HTTPConnection, *, required: bool = False, allowed: tuple[str, ...] = ()) -> bool:
153
162
  """Return whether a request did not come from another site: it names this host in ``Origin``.
154
163
 
155
164
  A request with no ``Origin`` is let through unless ``required``: a browser sends one on every write and every socket,
156
165
  and with no token (nothing but this check stands between a page on another site and a gateway that runs commands) a
157
166
  program that writes to the gateway says where it is from too.
167
+
168
+ ``allowed`` names the origins of a native client (see :attr:`Settings.allowed_origins`); they are let through as
169
+ the gateway's own, and every request from them still has to carry the token.
158
170
  """
159
171
  origin = connection.headers.get("origin")
160
172
  if not origin:
161
173
  return not required
174
+ if origin in allowed:
175
+ return True
162
176
  return urlsplit(origin).netloc == connection.headers.get("host", "")
163
177
 
164
178
 
179
+ def offered(connection: HTTPConnection) -> list[str]:
180
+ """Return the WebSocket subprotocols the client offered, trimmed."""
181
+ header = connection.headers.get("sec-websocket-protocol", "")
182
+ return [item.strip() for item in header.split(",") if item.strip()]
183
+
184
+
185
+ def websocket_token(connection: HTTPConnection) -> str:
186
+ """Return the token a native client offered as a subprotocol, or ``""`` when it offered none it can be read from."""
187
+ for protocol in offered(connection):
188
+ if not protocol.startswith(WS_TOKEN_PREFIX):
189
+ continue
190
+ encoded = protocol[len(WS_TOKEN_PREFIX) :]
191
+ try:
192
+ return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode()
193
+ except (binascii.Error, UnicodeDecodeError, ValueError):
194
+ return ""
195
+ return ""
196
+
197
+
165
198
  class Headers:
166
199
  """ASGI middleware that puts the response headers every answer of the gateway carries on it."""
167
200
 
@@ -205,6 +238,7 @@ Handler = Callable[[Request], Awaitable[Any]]
205
238
  def create_app(settings: Settings, backend: Backend | None = None, *, static: Path = STATIC) -> Starlette:
206
239
  """Return the application for these settings; a test gives a backend of its own, and the folder the page is in."""
207
240
  auth = Auth(settings.token)
241
+ allowed = settings.allowed_origins
208
242
  gateway = Gateway(backend if backend is not None else SdkBackend(settings.workspace, settings.directory), settings)
209
243
 
210
244
  @asynccontextmanager
@@ -235,7 +269,9 @@ def create_app(settings: Settings, backend: Backend | None = None, *, static: Pa
235
269
  try:
236
270
  if not public and not auth.allowed(request):
237
271
  raise GatewayError(401, "unauthorized", "Sign in first")
238
- if request.method not in ("GET", "HEAD") and not same_origin(request, required=auth.open):
272
+ if request.method not in ("GET", "HEAD") and not same_origin(
273
+ request, required=auth.open, allowed=allowed
274
+ ):
239
275
  raise GatewayError(403, "forbidden", "This request came from another site")
240
276
  result = await handler(request)
241
277
  return result if isinstance(result, Response) else JSONResponse(result)
@@ -394,10 +430,19 @@ def create_app(settings: Settings, backend: Backend | None = None, *, static: Pa
394
430
 
395
431
  # -- the event socket -------------------------------------------------------------------------
396
432
  async def events(websocket: WebSocket) -> None:
397
- if not auth.allowed(websocket) or not same_origin(websocket, required=auth.open):
433
+ protocols = offered(websocket)
434
+ subprotocol = WS_PROTOCOL if WS_PROTOCOL in protocols else None
435
+ if not (auth.allowed(websocket) or auth.token_ok(websocket_token(websocket))) or not same_origin(
436
+ websocket, required=auth.open, allowed=allowed
437
+ ):
438
+ # Accept first: a close before the handshake is an HTTP refusal, which a browser reports as 1006, and the
439
+ # page could not tell a refused token from a dropped network. Echo the offered subprotocol as well: a
440
+ # browser that negotiated one refuses a handshake that comes back without it.
441
+ await websocket.accept(subprotocol=subprotocol)
398
442
  await websocket.close(UNAUTHORIZED)
399
443
  return
400
- await websocket.accept()
444
+ # A native client offers ``zettcode`` beside its token; echo it, or the browser would refuse the socket.
445
+ await websocket.accept(subprotocol=subprotocol)
401
446
  queue = gateway.subscribe()
402
447
 
403
448
  async def pump() -> None:
@@ -441,6 +486,7 @@ def create_app(settings: Settings, backend: Backend | None = None, *, static: Pa
441
486
  Route("/api/threads/{id}/recheck", api(recheck), methods=["POST"]),
442
487
  Route("/api/threads/{id}/send", api(send), methods=["POST"]),
443
488
  Route("/api/threads/{id}/pictures/{picture}", api(picture)),
489
+ Route("/api/groups/{name}/send", api(send_group), methods=["POST"]),
444
490
  Route("/api/daemons", api(spawn), methods=["POST"]),
445
491
  Route("/api/daemons/{id}", api(delete), methods=["DELETE"]),
446
492
  Route("/api/daemons/{id}/stop", api(stop), methods=["POST"]),
@@ -448,7 +494,25 @@ def create_app(settings: Settings, backend: Backend | None = None, *, static: Pa
448
494
  WebSocketRoute("/api/events", events),
449
495
  Mount("/static", Files(directory=static, check_dir=False), name="static"),
450
496
  ]
451
- app = Starlette(routes=routes, lifespan=lifespan, middleware=[Middleware(Headers)])
497
+ app = Starlette(
498
+ routes=routes,
499
+ lifespan=lifespan,
500
+ middleware=[
501
+ # Outermost, so a refusal from the routes below still carries the headers a native client's browser
502
+ # needs to read it, and a preflight is answered before it can be mistaken for a write that needs the token.
503
+ Middleware(
504
+ CORSMiddleware,
505
+ allow_origins=list(allowed),
506
+ allow_methods=["GET", "HEAD", "POST", "PATCH", "DELETE", "OPTIONS"],
507
+ allow_headers=["authorization", "content-type"],
508
+ # The desktop page, or an operator's --client-origin page, can be reached over a private network;
509
+ # the allowlist is what keeps a public page out, and the token is still required.
510
+ allow_private_network=True,
511
+ max_age=600,
512
+ ),
513
+ Middleware(Headers),
514
+ ],
515
+ )
452
516
  app.state.gateway = gateway
453
517
  app.state.auth = auth
454
518
  return app
@@ -27,6 +27,7 @@ import sys
27
27
  import time
28
28
  from pathlib import Path
29
29
  from typing import Any, Literal
30
+ from urllib.parse import urlsplit
30
31
 
31
32
  from zettcode import logging_setup
32
33
  from zettcode.processes import command_line, is_module_command, still_there
@@ -56,6 +57,40 @@ EXIT_OK = 0
56
57
  EXIT_FAILED = 1
57
58
 
58
59
 
60
+ def origin(value: str) -> str:
61
+ """Return ``value`` as an origin, or stop: a scheme and a host, and nothing after a port.
62
+
63
+ A browser sends ``Origin`` as ``scheme://host[:port]`` and never with a trailing slash, so a flag that accepts one
64
+ would never match anything. Only schemes a page or a native shell can run under are accepted: ``ws://`` is a
65
+ socket, not an origin a browser ever sends.
66
+ """
67
+ text = value.strip().rstrip("/")
68
+ parts = urlsplit(text)
69
+ try:
70
+ port = parts.port
71
+ except ValueError:
72
+ raise argparse.ArgumentTypeError(f"not an origin: {value!r} (the port is out of range)") from None
73
+ scheme = parts.scheme.lower()
74
+ host = parts.hostname
75
+ if (
76
+ scheme not in ("http", "https", "app")
77
+ or not host
78
+ or parts.path
79
+ or parts.query
80
+ or parts.fragment
81
+ or "@" in parts.netloc
82
+ or parts.netloc.endswith(":")
83
+ or (port is not None and not 0 < port < 65536)
84
+ ):
85
+ raise argparse.ArgumentTypeError(f"not an origin: {value!r} (write scheme://host[:port])")
86
+ # A browser omits a scheme's default port; strip it here or the flag would never match.
87
+ if port is None or (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
88
+ authority = f"[{host}]" if ":" in host else host
89
+ else:
90
+ authority = f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
91
+ return f"{scheme}://{authority}"
92
+
93
+
59
94
  def build_parser() -> argparse.ArgumentParser:
60
95
  """Return the parser: the action, where to listen, who may sign in, and where agents may be started."""
61
96
  parser = argparse.ArgumentParser(
@@ -112,6 +147,17 @@ def build_parser() -> argparse.ArgumentParser:
112
147
  default=argparse.SUPPRESS,
113
148
  help="let the page start daemons with the full shell level again, undoing a recorded --no-full",
114
149
  )
150
+ parser.add_argument(
151
+ "--client-origin",
152
+ metavar="ORIGIN",
153
+ action="append",
154
+ type=origin,
155
+ default=argparse.SUPPRESS,
156
+ help=(
157
+ "let a page served from this origin call the gateway, on top of the built-in native client origins "
158
+ "(repeatable; needs a token). An origin is scheme://host[:port], without a path"
159
+ ),
160
+ )
115
161
  parser.add_argument(
116
162
  "--directory",
117
163
  metavar="DIR",
@@ -171,6 +217,11 @@ def settings_from(argv: list[str] | None = None, *, base: Settings | None = None
171
217
  else:
172
218
  directory = base.directory if base is not None else default_directory()
173
219
  log_level = getattr(args, "log_level", None) or (base.log_level if base is not None else "info")
220
+ origins_given = getattr(args, "client_origin", None)
221
+ if origins_given is None:
222
+ client_origins = base.client_origins if base is not None else ()
223
+ else:
224
+ client_origins = tuple(dict.fromkeys(origins_given))
174
225
 
175
226
  if not 0 < port < 65536:
176
227
  raise SystemExit(f"zettcode gateway: --port must be between 1 and 65535, not {port}")
@@ -182,6 +233,10 @@ def settings_from(argv: list[str] | None = None, *, base: Settings | None = None
182
233
  f"zettcode gateway: listening on {host} needs a token (--token or ${TOKEN_VARIABLE}): "
183
234
  "without one anybody on the network could run commands as you"
184
235
  )
236
+ if client_origins and not token:
237
+ raise SystemExit(
238
+ "zettcode gateway: --client-origin needs a token: without one another site could drive the gateway"
239
+ )
185
240
  return Settings(
186
241
  token=token,
187
242
  host=host,
@@ -190,6 +245,7 @@ def settings_from(argv: list[str] | None = None, *, base: Settings | None = None
190
245
  allow_full=allow_full,
191
246
  directory=directory,
192
247
  log_level=log_level,
248
+ client_origins=client_origins,
193
249
  )
194
250
 
195
251
 
@@ -351,6 +407,8 @@ def spawn(settings: Settings) -> subprocess.Popen[bytes]:
351
407
  arguments.append("--no-full")
352
408
  for root in settings.roots:
353
409
  arguments.extend(("--root", str(root)))
410
+ for source in settings.client_origins:
411
+ arguments.extend(("--client-origin", source))
354
412
  environment = {**os.environ, TOKEN_VARIABLE: settings.token}
355
413
  options: dict[str, Any] = {
356
414
  "stdin": subprocess.DEVNULL,
@@ -66,6 +66,7 @@ def write(settings: Settings) -> None:
66
66
  "roots": [str(root) for root in settings.roots],
67
67
  "allow_full": settings.allow_full,
68
68
  "log_level": settings.log_level,
69
+ "client_origins": list(settings.client_origins),
69
70
  }
70
71
  descriptor, temporary = tempfile.mkstemp(dir=settings.directory, prefix=".run-", suffix=".tmp")
71
72
  try:
@@ -96,6 +97,7 @@ def read(directory: Path) -> Running | None:
96
97
  pid, port = raw.get("pid"), raw.get("port")
97
98
  host, token, log_level = raw.get("host"), raw.get("token"), raw.get("log_level")
98
99
  roots, allow_full = raw.get("roots"), raw.get("allow_full")
100
+ client_origins = raw.get("client_origins", [])
99
101
  if not (_whole(pid) and pid > 0 and _whole(port) and 0 < port < 65536):
100
102
  return None
101
103
  if not (isinstance(host, str) and isinstance(token, str) and isinstance(log_level, str)):
@@ -104,6 +106,8 @@ def read(directory: Path) -> Running | None:
104
106
  return None
105
107
  if not isinstance(roots, list) or not all(isinstance(root, str) for root in roots):
106
108
  return None
109
+ if not isinstance(client_origins, list) or not all(isinstance(item, str) for item in client_origins):
110
+ return None
107
111
  settings = Settings(
108
112
  token=token,
109
113
  host=host,
@@ -114,6 +118,7 @@ def read(directory: Path) -> Running | None:
114
118
  # folder.
115
119
  directory=directory,
116
120
  log_level=log_level,
121
+ client_origins=tuple(client_origins),
117
122
  )
118
123
  return Running(pid, settings)
119
124
 
@@ -13,6 +13,11 @@ DIRECTORY_VARIABLE = "ZETTCODE_GATEWAY_DIR"
13
13
  DEFAULT_HOST = "127.0.0.1"
14
14
  DEFAULT_PORT = 8765
15
15
 
16
+ #: The origin the desktop client runs under: Electron's ``app://`` page. A gateway with a token lets it in; with no
17
+ #: token it is ignored, because then the origin check is the only thing between another site and a gateway that runs
18
+ #: commands. The Android app loads the gateway's own page at the gateway's origin and needs no entry here.
19
+ APP_ORIGINS = ("app://-",)
20
+
16
21
 
17
22
  def default_directory() -> Path:
18
23
  """Return the gateway's own folder: ``$ZETTCODE_GATEWAY_DIR``, or ``~/.zettcode/gateway``."""
@@ -35,6 +40,8 @@ class Settings:
35
40
  command line (``--no-full`` turns it off), never from the page.
36
41
  directory: The gateway's own folder: its workspace, the id of its session, and what the page remembers.
37
42
  log_level: uvicorn's log level.
43
+ client_origins: Extra origins a native client or a page of its own is served from, on top of
44
+ :data:`APP_ORIGINS`. Ignored without a token.
38
45
  """
39
46
 
40
47
  token: str = ""
@@ -44,12 +51,24 @@ class Settings:
44
51
  allow_full: bool = False
45
52
  directory: Path = field(default_factory=default_directory)
46
53
  log_level: str = "info"
54
+ client_origins: tuple[str, ...] = ()
47
55
 
48
56
  @property
49
57
  def open(self) -> bool:
50
58
  """Return whether anybody who can reach the gateway may use it, with no token."""
51
59
  return not self.token
52
60
 
61
+ @property
62
+ def allowed_origins(self) -> tuple[str, ...]:
63
+ """Return the origins besides the gateway's own that may call it, and may write to it.
64
+
65
+ Empty when there is no token. The token is what keeps people out; an origin is not a credential, so letting a
66
+ native client's origin through is only safe while every request still has to show the token.
67
+ """
68
+ if self.open:
69
+ return ()
70
+ return tuple(dict.fromkeys((*APP_ORIGINS, *self.client_origins)))
71
+
53
72
  @property
54
73
  def workspace(self) -> Path:
55
74
  """Return the directory the gateway's own session works in: it has nothing to edit, and its name is public."""
@@ -1376,6 +1376,64 @@ a.member:hover { background: var(--hover);
1376
1376
  }
1377
1377
 
1378
1378
 
1379
+ /* src/components/shell/ServerGate.vue */
1380
+
1381
+ .gate { height: 100%; overflow-y: auto; background: var(--surface-2); overscroll-behavior: contain;
1382
+ }
1383
+ .gate-inner {
1384
+ width: 100%; max-width: 34rem; margin: 0 auto; padding: calc(24px + var(--safe-top)) 16px calc(28px + var(--safe-bottom));
1385
+ display: flex; flex-direction: column; gap: 12px;
1386
+ }
1387
+ .gate-head { display: flex; align-items: center; gap: 14px; padding: 8px 4px 4px;
1388
+ }
1389
+ .gate-head img { object-fit: contain; flex: none;
1390
+ }
1391
+ .gate-head h1 { margin: 0; font-size: 1.45rem; line-height: 1.15;
1392
+ }
1393
+ .gate-head p { margin: 4px 0 0; color: var(--muted); font-size: 0.92rem; line-height: 1.35;
1394
+ }
1395
+ .gate-form { margin: 0; padding-bottom: 4px;
1396
+ }
1397
+ .gate-field { display: flex; flex-direction: column; gap: 4px; padding: 9px 16px;
1398
+ }
1399
+ .gate-field + .gate-field { border-top: 1px solid var(--divider);
1400
+ }
1401
+ .gate-field span { color: var(--muted); font-size: 0.78rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase;
1402
+ }
1403
+ .gate-field input { width: 100%; min-width: 0; border: 0; outline: 0; background: none; color: var(--text); font-size: 1rem; padding: 4px 0;
1404
+ }
1405
+ .gate-server { display: flex; align-items: center; gap: 2px; padding-right: 4px;
1406
+ }
1407
+ .gate-server + .gate-server { border-top: 1px solid var(--divider);
1408
+ }
1409
+ .gate-pick {
1410
+ display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; min-height: 64px; padding: 8px 12px 8px 16px;
1411
+ text-align: left; color: inherit; transition: background 0.12s;
1412
+ }
1413
+ .gate-pick:hover { background: var(--hover);
1414
+ }
1415
+ .gate-plug { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; background: var(--accent-soft); color: var(--accent); flex: none;
1416
+ }
1417
+ .gate-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px;
1418
+ }
1419
+ .gate-text b { font-weight: 600; font-size: 1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1420
+ }
1421
+ .gate-text small { color: var(--muted); font-family: var(--mono); font-size: 0.76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1422
+ }
1423
+ .gate-active { color: var(--ok); flex: none;
1424
+ }
1425
+ .gate-hint { margin: 0; padding: 4px 6px; color: var(--muted); font-size: 0.8rem; line-height: 1.4; text-align: center;
1426
+ }
1427
+ .gate .iconbtn.danger { color: var(--bad);
1428
+ }
1429
+ .gate .iconbtn.danger:hover { background: var(--bad-soft);
1430
+ }
1431
+ @media (max-width: 719px) {
1432
+ .gate-inner { padding-left: 10px; padding-right: 10px;
1433
+ }
1434
+ }
1435
+
1436
+
1379
1437
  /* src/components/shell/WelcomeMain.vue */
1380
1438
 
1381
1439
  .welcome { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 24px; text-align: center; color: var(--muted); background: var(--surface-2);