uro-server 0.2.0__py3-none-any.whl

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.
uro_server/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Uro Engine server: transport, sessions, wiring — no engine logic. Governing doc: 08."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("uro-server")
7
+ except PackageNotFoundError: # pragma: no cover - source checkout without an install
8
+ __version__ = "0.0.0+unknown"
uro_server/app.py ADDED
@@ -0,0 +1,645 @@
1
+ """The FastAPI shell (docs/08): transport, sessions, auth, wiring — NO engine logic.
2
+
3
+ Everything the engine does is reached through `ServerDeps` (a small port): token→participant
4
+ resolution, campaign existence, and a streaming beat runner. Production wires it to a connected
5
+ store + Engine (`engine_deps`); tests inject a fake — so the transport (auth, broadcast fan-out,
6
+ the WS play channel) is exercised without a live DB or model. The heavy engine path is tested
7
+ directly in uro-core.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections.abc import AsyncIterator, Awaitable, Callable
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from fastapi import Body, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
18
+ from starlette.websockets import WebSocketState
19
+ from uro_core.pipeline.engine import Engine
20
+ from uro_core.ports.projections import EngineStore
21
+ from uro_core.session import AdmitDecision, SoloArbiter, TurnArbiter, VoteCoordinator
22
+
23
+ from uro_server.sessions import SessionHub, TokenRegistry
24
+
25
+
26
+ def _bearer_token(request: Request) -> str:
27
+ """Extract a bearer token from the Authorization header, or ''."""
28
+ auth = request.headers.get("authorization", "")
29
+ return auth[7:] if auth.lower().startswith("bearer ") else ""
30
+
31
+
32
+ def _default_sheet(ruleset_id: str, version: str) -> tuple[dict[str, Any], str]:
33
+ """A default character sheet from a world's declared ruleset (docs/06, D-30) — mirrors the
34
+ CLI's `_build_pc_sheet` so a REST-created PC is sheeted+pinned exactly like a CLI-created one
35
+ (else the ruleset's move/harm layer never engages and the WS cross-ruleset guard is bypassed by
36
+ an empty pin). Built via the registry, independent of the server's process-bound ruleset.
37
+ Returns (sheet, resolved ruleset id)."""
38
+ from uro_core.rulesets.base import CharSpec
39
+ from uro_core.rulesets.registry import resolve as _resolve
40
+ from uro_core.rulesets.rng import Rng
41
+
42
+ ruleset = _resolve(ruleset_id, version)
43
+ return ruleset.new_character(CharSpec(), Rng(0)), ruleset.id
44
+
45
+
46
+ def _resolve_ruleset_id(ruleset_id: str, version: str) -> str:
47
+ """The resolved ruleset id for a world's declared (id, version) — for pinning a campaign whose
48
+ PC we don't (re)sheet (an adopted actor that already has one)."""
49
+ from uro_core.rulesets.registry import resolve as _resolve
50
+
51
+ return _resolve(ruleset_id, version).id
52
+
53
+
54
+ @dataclass
55
+ class ServerDeps:
56
+ """The seam between the transport shell and the engine (docs/01: server sees only ports)."""
57
+
58
+ resolve_participant: Callable[[str], str | None] # bearer token → participant_id (None = deny)
59
+ campaign_exists: Callable[[str], Awaitable[bool]]
60
+ run_beat: Callable[
61
+ [str, str, str], AsyncIterator[str]
62
+ ] # (campaign, participant, intent) → chunks
63
+ # Chronicler mode (D-25): distill an external game's outcome bundle → committed events.
64
+ report_outcome: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None = None
65
+ # Management surface (docs/18 B3): the EngineStore port (a Protocol — the server still sees only
66
+ # ports) powers the CRUD/read endpoints; `advance_time` runs an engine-backed time-skip+agendas.
67
+ # None → those endpoints return 501 (a fake-deps test that only exercises play/outcome).
68
+ store: EngineStore | None = None
69
+ advance_time: Callable[[str, int], Awaitable[dict[str, Any]]] | None = None
70
+ # Runtime token management (docs/18 B10, D-39): mint/revoke durable session tokens + the admin
71
+ # check, behind the same resolve_participant choke point. None → the token endpoints return 501.
72
+ mint_token: Callable[[str, str], Awaitable[str]] | None = None # (participant, campaign)→token
73
+ revoke_token: Callable[[str], Awaitable[bool]] | None = None # (plaintext token) → revoked?
74
+ is_admin: Callable[[str], bool] | None = None # (token) → operator tier (may act for others)?
75
+ token_campaign: Callable[[str], str | None] | None = None # minted token → its campaign (scope)
76
+ hydrate_tokens: Callable[[], Awaitable[None]] | None = None # load durable tokens at startup
77
+
78
+
79
+ def engine_deps(
80
+ store: EngineStore,
81
+ engine: Engine,
82
+ tokens: dict[str, str],
83
+ admin_tokens: set[str] | None = None,
84
+ ) -> ServerDeps:
85
+ """Production wiring: a connected store + Engine behind the transport port. `tokens` maps a
86
+ bearer token to a participant_id (docs/08 token mode) — ordinary PLAYER credentials; the
87
+ `admin_tokens` subset is the OPERATOR tier (may act for others, D-39 review). Runtime player
88
+ tokens live in a durable, campaign-scoped `TokenRegistry` over the same resolve choke point."""
89
+ registry = TokenRegistry(store, tokens, admin_tokens)
90
+
91
+ async def campaign_exists(campaign_id: str) -> bool:
92
+ return (await store.get_campaign(campaign_id)) is not None
93
+
94
+ async def run_beat(campaign_id: str, participant: str, text: str) -> AsyncIterator[str]:
95
+ campaign = await store.get_campaign(campaign_id)
96
+ if campaign is None:
97
+ return
98
+ # The server binds ONE ruleset per process (docs/08 deferral); a campaign pinned to a
99
+ # DIFFERENT ruleset would otherwise crash deep in sheet validation. Reject up front with a
100
+ # clear diagnostic instead (D-30 per-campaign binding; the CLI play path rebinds correctly).
101
+ if campaign.ruleset_id and engine.ruleset_id and campaign.ruleset_id != engine.ruleset_id:
102
+ raise ValueError(
103
+ f"campaign {campaign_id} is bound to ruleset {campaign.ruleset_id!r}, but this "
104
+ f"server runs {engine.ruleset_id!r} — start `uro serve --ruleset "
105
+ f"{campaign.ruleset_id}` (one ruleset per server process)"
106
+ )
107
+ async for chunk in engine.run_beat_stream(campaign, participant, text):
108
+ yield chunk
109
+
110
+ async def report_outcome(campaign_id: str, bundle_json: dict[str, Any]) -> dict[str, Any]:
111
+ from uro_core.chronicler import OutcomeBundle, distill_outcome_with_receipt
112
+
113
+ campaign = await store.get_campaign(campaign_id)
114
+ if campaign is None:
115
+ raise HTTPException(status_code=404, detail="no such campaign")
116
+ bundle = OutcomeBundle.model_validate(bundle_json)
117
+ result = await distill_outcome_with_receipt(store, campaign.branch_id, bundle)
118
+ # Commit + react in ONE call (B1, docs/18): an EXTERNAL death is the war-story premise —
119
+ # combat is non-lethal, so the Chronicler is the only runtime ActorDied source, and a pack
120
+ # rule triggering on ActorDied must fire here too, not only on the run_beat path. Using
121
+ # append_and_react (not a hand-rolled append_beat + react) is exactly what B1 landed for —
122
+ # authored-commit callers shouldn't re-implement the react step. react() is
123
+ # exception-isolated, so the outcome beat is durable even if a rule raises.
124
+ commit = await engine.append_and_react(campaign, result.events)
125
+ # The per-ref receipt (docs/18 B6) tells the reporting game what was applied/downgraded/
126
+ # dropped and why — otherwise a Chronicler consumer couldn't see the D-32 downgrades.
127
+ return {
128
+ "committed_events": len(result.events),
129
+ "commit_id": commit.commit_id,
130
+ "receipt": [r.model_dump() for r in result.receipt],
131
+ }
132
+
133
+ async def advance_time(campaign_id: str, days: int) -> dict[str, Any]:
134
+ campaign = await store.get_campaign(campaign_id)
135
+ if campaign is None:
136
+ raise HTTPException(status_code=404, detail="no such campaign")
137
+ await engine.agenda_tick(campaign.branch_id, days) # time-skip + downtime agendas (D-33)
138
+ world_day = await store.current_world_time(campaign.branch_id)
139
+ return {"branch_id": campaign.branch_id, "world_day": world_day}
140
+
141
+ return ServerDeps(
142
+ resolve_participant=registry.resolve,
143
+ campaign_exists=campaign_exists,
144
+ run_beat=run_beat,
145
+ report_outcome=report_outcome,
146
+ store=store,
147
+ advance_time=advance_time,
148
+ mint_token=registry.mint,
149
+ revoke_token=registry.revoke,
150
+ is_admin=registry.is_admin,
151
+ token_campaign=registry.campaign_of,
152
+ hydrate_tokens=registry.hydrate,
153
+ )
154
+
155
+
156
+ def create_app(deps: ServerDeps, *, arbiter: TurnArbiter | None = None) -> FastAPI:
157
+ app = FastAPI(title="Uro Engine server")
158
+ hub = SessionHub()
159
+ arb = arbiter or SoloArbiter()
160
+
161
+ @app.get("/healthz")
162
+ async def healthz() -> dict[str, str]:
163
+ return {"status": "ok"}
164
+
165
+ @app.post("/campaigns/{campaign_id}/encounters/{encounter_id}/outcome")
166
+ async def report_outcome(
167
+ campaign_id: str,
168
+ encounter_id: str,
169
+ request: Request,
170
+ bundle: dict[str, Any] = Body(...), # noqa: B008 (FastAPI DI-style default)
171
+ ) -> dict[str, Any]:
172
+ """Chronicler mode (D-25): an external game reports an outcome bundle; Uro distills it
173
+ into committed events (feats → witness rumors) and notifies the session. AUTHED — an
174
+ external resolver mutates the timeline, so it needs a trusted token like any client."""
175
+ token = request.query_params.get("token") or _bearer_token(request)
176
+ if deps.resolve_participant(token) is None:
177
+ raise HTTPException(status_code=401, detail="unauthorized")
178
+ # A minted token is campaign-scoped (D-39) — an outcome mutates the timeline, so enforce the
179
+ # scope HERE too, not just the WS play channel (D-41 review: this endpoint left it open).
180
+ # Static operator/legacy tokens (token_campaign → None) are server-wide and pass, intended.
181
+ tok_campaign = deps.token_campaign(token) if deps.token_campaign is not None else None
182
+ if tok_campaign is not None and tok_campaign != campaign_id:
183
+ raise HTTPException(status_code=403, detail="token not valid for this campaign")
184
+ if deps.report_outcome is None:
185
+ raise HTTPException(status_code=501, detail="Chronicler mode not enabled")
186
+ try:
187
+ # A malformed/forged bundle (an unknown field under extra='forbid', a bad `v`) raises a
188
+ # pydantic ValidationError (a ValueError) in report_outcome's model_validate — turn it
189
+ # into a loud 400 like every other mutating endpoint (D-41 review: it was a raw 500).
190
+ result = await deps.report_outcome(
191
+ campaign_id, {**bundle, "encounter_id": encounter_id}
192
+ )
193
+ except ValueError as exc:
194
+ raise HTTPException(status_code=400, detail=f"invalid outcome bundle: {exc}") from exc
195
+ await hub.publish(
196
+ campaign_id, {"type": "outcome_recorded", "encounter_id": encounter_id, **result}
197
+ )
198
+ return result
199
+
200
+ # --- management surface (docs/18 B3): the CRUD/read endpoints a non-Python or non-co-located
201
+ # consumer needs (before this, only WS play + outcome existed → every management op forced the
202
+ # library). All authed like the rest; 501 if the deps carry no store. ---
203
+
204
+ def _auth(request: Request) -> None:
205
+ token = request.query_params.get("token") or _bearer_token(request)
206
+ if deps.resolve_participant(token) is None:
207
+ raise HTTPException(status_code=401, detail="unauthorized")
208
+
209
+ def _scope(request: Request) -> tuple[str, str | None, bool]:
210
+ """The caller's (raw token, resolved participant, is-admin) — for self-or-admin token
211
+ scoping (D-39). A caller may mint/join/revoke for THEMSELVES; only an operator (bootstrap
212
+ --token) credential may act for another participant. Never 'any valid token → any identity'
213
+ (which, with durable minting, would be a PC takeover via pc_for_participant)."""
214
+ token = request.query_params.get("token") or _bearer_token(request)
215
+ caller = deps.resolve_participant(token)
216
+ admin = deps.is_admin(token) if deps.is_admin is not None else False
217
+ return token, caller, admin
218
+
219
+ def _mgmt() -> EngineStore:
220
+ if deps.store is None:
221
+ raise HTTPException(status_code=501, detail="management surface not enabled")
222
+ return deps.store
223
+
224
+ def _require(body: dict[str, Any], key: str) -> Any:
225
+ """A required body field → 400 (not a bare KeyError→500) when a client omits it."""
226
+ if key not in body:
227
+ raise HTTPException(status_code=400, detail=f"missing required field {key!r}")
228
+ return body[key]
229
+
230
+ @app.post("/worlds")
231
+ async def create_world(request: Request, body: dict[str, Any] = Body(...)) -> dict[str, Any]: # noqa: B008
232
+ _auth(request)
233
+ tone = body.get("tone")
234
+ if isinstance(tone, str): # allow a bare-string tone from a JSON consumer
235
+ tone = [tone]
236
+ try:
237
+ # create_world validates a Reaction-Layer rule_pack LOUDLY (the "silent pack death" fix,
238
+ # docs/18): a malformed pack from a REST client is bad INPUT → 400, not a 500.
239
+ pack = body.get("rule_pack") or None
240
+ w = await _mgmt().create_world(_require(body, "name"), tone=tone, rule_pack=pack)
241
+ except ValueError as exc: # pydantic ValidationError (bad rule_pack) subclasses ValueError
242
+ raise HTTPException(status_code=400, detail=f"invalid world: {exc}") from exc
243
+ return {"world_id": w.world_id, "main_branch_id": w.main_branch_id, "name": w.name}
244
+
245
+ @app.get("/worlds")
246
+ async def list_worlds(request: Request) -> list[dict[str, Any]]:
247
+ _auth(request)
248
+ return [w.model_dump() for w in await _mgmt().list_worlds()]
249
+
250
+ @app.post("/worlds/{world_id}/campaigns")
251
+ async def create_campaign(
252
+ world_id: str,
253
+ request: Request,
254
+ body: dict[str, Any] = Body(...), # noqa: B008
255
+ ) -> dict[str, Any]:
256
+ _auth(request)
257
+ store = _mgmt()
258
+ world = await store.get_world(world_id)
259
+ if world is None:
260
+ raise HTTPException(status_code=404, detail="no such world")
261
+ participant = _require(body, "participant")
262
+ new_pc_name = body.get("new_pc_name")
263
+ adopt_actor_id = body.get("adopt_actor_id")
264
+ try:
265
+ # Pin the WORLD's declared ruleset + sheet the PC (D-30 + Phase-3), like the CLI `uro
266
+ # campaign new` path — a REST-created PbtA campaign must not silently fall to an empty
267
+ # pin + no sheet (which bypasses the WS cross-ruleset guard and disables mechanics).
268
+ world_rid, world_rver = await store.world_ruleset(world.main_branch_id)
269
+ pc_sheet: dict[str, Any] | None = None
270
+ if new_pc_name is not None or (
271
+ adopt_actor_id is not None
272
+ and await store.get_sheet(world.main_branch_id, adopt_actor_id) is None
273
+ ):
274
+ pc_sheet, ruleset_id = _default_sheet(world_rid, world_rver)
275
+ else: # adopting an already-sheeted actor: keep its sheet, still pin the ruleset
276
+ ruleset_id = _resolve_ruleset_id(world_rid, world_rver)
277
+ c = await store.start_campaign(
278
+ world_id,
279
+ world.main_branch_id,
280
+ participant_id=participant,
281
+ new_pc_name=new_pc_name,
282
+ adopt_actor_id=adopt_actor_id,
283
+ pc_sheet=pc_sheet,
284
+ ruleset_id=ruleset_id,
285
+ ruleset_version=world_rver,
286
+ seed=int(body.get("seed") or 0), # docs/18 G-3: reproducible-combat seed
287
+ )
288
+ except (ValueError, KeyError) as exc: # bad PC choice, or world pins an absent ruleset
289
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
290
+ return {"campaign_id": c.campaign_id, "branch_id": c.branch_id}
291
+
292
+ @app.get("/campaigns")
293
+ async def list_campaigns(request: Request) -> list[dict[str, Any]]:
294
+ _auth(request)
295
+ world_id = request.query_params.get("world_id")
296
+ return [c.model_dump() for c in await _mgmt().list_campaigns(world_id)]
297
+
298
+ @app.get("/campaigns/{campaign_id}")
299
+ async def get_campaign(campaign_id: str, request: Request) -> dict[str, Any]:
300
+ _auth(request)
301
+ c = await _mgmt().get_campaign(campaign_id)
302
+ if c is None:
303
+ raise HTTPException(status_code=404, detail="no such campaign")
304
+ return c.model_dump()
305
+
306
+ @app.post("/campaigns/{campaign_id}/join")
307
+ async def join_campaign(
308
+ campaign_id: str,
309
+ request: Request,
310
+ body: dict[str, Any] = Body(...), # noqa: B008
311
+ ) -> dict[str, Any]:
312
+ _auth(request)
313
+ store = _mgmt()
314
+ campaign = await store.get_campaign(campaign_id)
315
+ if campaign is None:
316
+ raise HTTPException(status_code=404, detail="no such campaign")
317
+ participant = _require(body, "participant")
318
+ # Scoped mint (D-39): a caller may seat/mint for THEMSELVES; only an operator credential may
319
+ # seat another — else a durable token for an arbitrary identity would be a PC takeover.
320
+ _, caller, admin = _scope(request)
321
+ if participant != caller and not admin:
322
+ raise HTTPException(
323
+ status_code=403,
324
+ detail="can only join as yourself; seating another needs an operator --token",
325
+ )
326
+ new_pc_name = body.get("new_pc_name")
327
+ adopt_actor_id = body.get("adopt_actor_id")
328
+ try:
329
+ # Sheet the joining PC from the campaign's ruleset (D-30 + Phase-3), like CLI join.
330
+ pc_sheet: dict[str, Any] | None = None
331
+ if new_pc_name is not None or (
332
+ adopt_actor_id is not None
333
+ and await store.get_sheet(campaign.branch_id, adopt_actor_id) is None
334
+ ):
335
+ pc_sheet, _ = _default_sheet(campaign.ruleset_id, campaign.ruleset_version)
336
+ actor_id = await store.bind_pc(
337
+ campaign_id,
338
+ participant,
339
+ new_pc_name=new_pc_name,
340
+ adopt_actor_id=adopt_actor_id,
341
+ pc_sheet=pc_sheet,
342
+ ruleset_id=campaign.ruleset_id,
343
+ )
344
+ except (ValueError, KeyError) as exc: # bad PC choice, or an absent pinned ruleset
345
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
346
+ result: dict[str, Any] = {"actor_id": actor_id}
347
+ # Mint-on-join (D-39): the blessed path to a LIVE credential — a runtime-added player gets a
348
+ # durable token naming EXACTLY the participant just bound on THIS campaign (no restart, no
349
+ # arbitrary-identity mint). Only when the deps carry a token registry (a store-backed one).
350
+ if deps.mint_token is not None:
351
+ result["token"] = await deps.mint_token(participant, campaign_id)
352
+ return result
353
+
354
+ @app.post("/campaigns/{campaign_id}/tokens")
355
+ async def mint_token_endpoint(
356
+ campaign_id: str,
357
+ request: Request,
358
+ body: dict[str, Any] = Body(...), # noqa: B008
359
+ ) -> dict[str, Any]:
360
+ """Standalone scoped mint (D-39) — the fallback to mint-on-join: issue a durable token for
361
+ an ALREADY-SEATED participant (self-or-admin scope; requires an existing PC binding, so a
362
+ token can't be minted for an unbound identity)."""
363
+ _auth(request)
364
+ if deps.mint_token is None:
365
+ raise HTTPException(status_code=501, detail="runtime token management not enabled")
366
+ participant = _require(body, "participant")
367
+ _, caller, admin = _scope(request)
368
+ if participant != caller and not admin:
369
+ raise HTTPException(
370
+ status_code=403, detail="can only mint for yourself (or as operator)"
371
+ )
372
+ store = _mgmt()
373
+ if await store.pc_for_participant(campaign_id, participant) is None:
374
+ raise HTTPException(
375
+ status_code=400, detail="participant has no PC on this campaign — join first"
376
+ )
377
+ return {"token": await deps.mint_token(participant, campaign_id)}
378
+
379
+ @app.post("/campaigns/{campaign_id}/tokens/revoke")
380
+ async def revoke_token_endpoint(
381
+ campaign_id: str,
382
+ request: Request,
383
+ body: dict[str, Any] = Body(...), # noqa: B008
384
+ ) -> dict[str, Any]:
385
+ """Revoke a token (D-39), self-or-admin scoped. Blocks a NEW connect immediately; a live
386
+ socket survives until it disconnects (auth is checked once before accept — a residual)."""
387
+ _auth(request)
388
+ if deps.revoke_token is None:
389
+ raise HTTPException(status_code=501, detail="runtime token management not enabled")
390
+ target = _require(body, "token")
391
+ _, caller, admin = _scope(request)
392
+ owner = deps.resolve_participant(target) # whose token is it?
393
+ if owner is not None and owner != caller and not admin:
394
+ raise HTTPException(
395
+ status_code=403, detail="can only revoke your own token (or as operator)"
396
+ )
397
+ return {"revoked": await deps.revoke_token(target)}
398
+
399
+ @app.get("/campaigns/{campaign_id}/roster")
400
+ async def campaign_roster(campaign_id: str, request: Request) -> dict[str, Any]:
401
+ _auth(request)
402
+ return {"pcs": await _mgmt().campaign_pcs(campaign_id)}
403
+
404
+ @app.get("/campaigns/{campaign_id}/state")
405
+ async def campaign_state(campaign_id: str, request: Request) -> dict[str, Any]:
406
+ _auth(request)
407
+ store = _mgmt()
408
+ c = await store.get_campaign(campaign_id)
409
+ if c is None:
410
+ raise HTTPException(status_code=404, detail="no such campaign")
411
+ raw = request.query_params.get("sections") or "actors,threads,places,factions"
412
+ sections = raw.split(",")
413
+ try:
414
+ # query_across raises on an unknown section (typo-loud, B5) — a client typo is bad
415
+ # INPUT → 400, matching every mutating endpoint (not a bare 500).
416
+ across = await store.query_across([c.branch_id], sections)
417
+ except ValueError as exc:
418
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
419
+ return {"branch_id": c.branch_id, "state": across.get(c.branch_id, {})}
420
+
421
+ @app.get("/campaigns/{campaign_id}/chronicle")
422
+ async def campaign_chronicle(campaign_id: str, request: Request) -> dict[str, Any]:
423
+ _auth(request)
424
+ store = _mgmt()
425
+ c = await store.get_campaign(campaign_id)
426
+ if c is None:
427
+ raise HTTPException(status_code=404, detail="no such campaign")
428
+ try:
429
+ limit = int(request.query_params.get("limit") or 20)
430
+ except ValueError as exc: # ?limit=abc is bad input → 400, not a 500
431
+ raise HTTPException(status_code=400, detail="limit must be an integer") from exc
432
+ beats = await store.recent_beats(c.branch_id, limit)
433
+ return {"beats": [b.model_dump() for b in beats]}
434
+
435
+ @app.post("/campaigns/{campaign_id}/time-skip")
436
+ async def campaign_time_skip(
437
+ campaign_id: str,
438
+ request: Request,
439
+ body: dict[str, Any] = Body(...), # noqa: B008
440
+ ) -> dict[str, Any]:
441
+ _auth(request)
442
+ if deps.advance_time is None:
443
+ raise HTTPException(status_code=501, detail="time-skip not enabled")
444
+ try:
445
+ days = int(_require(body, "days"))
446
+ except (TypeError, ValueError) as exc:
447
+ raise HTTPException(status_code=400, detail="days must be an integer") from exc
448
+ if days <= 0: # store.time_skip rejects this deeper (a 500); catch it here as bad input
449
+ raise HTTPException(status_code=400, detail="days must be a positive integer")
450
+ return await deps.advance_time(campaign_id, days)
451
+
452
+ @app.websocket("/campaigns/{campaign_id}/play")
453
+ async def play(ws: WebSocket, campaign_id: str) -> None:
454
+ # Auth (docs/08 token mode): ?token=… → participant_id. Reject before accept.
455
+ token = ws.query_params.get("token", "")
456
+ participant = deps.resolve_participant(token)
457
+ if participant is None:
458
+ await ws.close(code=4401) # unauthorized
459
+ return
460
+ if not await deps.campaign_exists(campaign_id):
461
+ await ws.close(code=4404) # no such campaign
462
+ return
463
+ # A MINTED token is scoped to the campaign it was minted for (D-39 review): reject it on a
464
+ # DIFFERENT campaign's play channel — else a token authenticates its participant server-wide
465
+ # and could drive another campaign's PC (cross-campaign hijack). Static operator/legacy
466
+ # --token creds are intentionally server-wide (token_campaign → None) and unaffected.
467
+ tok_campaign = deps.token_campaign(token) if deps.token_campaign is not None else None
468
+ if tok_campaign is not None and tok_campaign != campaign_id:
469
+ await ws.close(code=4403) # token not valid for this campaign
470
+ return
471
+ await ws.accept()
472
+ # Everything fallible (a DB round-trip in pc_seats) runs INSIDE the try, so a transient
473
+ # failure can't orphan the hub subscription / turn-roster entry (D-39 review: an unguarded
474
+ # pre-try failure leaked the queue forever). The finally cleans up whatever was set up.
475
+ queue: asyncio.Queue[dict[str, Any]] | None = None
476
+ forward: asyncio.Task[None] | None = None
477
+ joined = False
478
+ try:
479
+ queue = hub.subscribe(campaign_id)
480
+ # Seed the arbiter's ring in DURABLE bind order (D-39, G-18) so reconnect/restart re-
481
+ # forms the SAME order regardless of connect race; None (fake-deps/no store) → append.
482
+ seats = await deps.store.pc_seats(campaign_id) if deps.store is not None else None
483
+ await arb.note_joined(campaign_id, participant, seats) # add to the turn roster (OQ-7)
484
+ joined = True
485
+ await hub.publish(
486
+ campaign_id, {"type": "participant_joined", "participant_id": participant}
487
+ )
488
+ forward = asyncio.create_task(_forward(ws, queue)) # fan hub → this socket
489
+ while True:
490
+ msg = await ws.receive_json()
491
+ mtype = msg.get("type")
492
+ if mtype == "intent":
493
+ await _run_and_broadcast(
494
+ deps, arb, hub, campaign_id, participant, str(msg.get("text", ""))
495
+ )
496
+ elif mtype == "table_talk":
497
+ # The NON-CANON coordination lane (D-38): out-of-world debate/proposals. It only
498
+ # calls hub.publish and returns — it NEVER reaches _run_and_broadcast → run_beat
499
+ # → append_beat, so by CONSTRUCTION it cannot move the branch head or mint an
500
+ # event. This structural guarantee (not a policy) lets the party propose,
501
+ # debate, and vote without burning a canonical beat.
502
+ talk = str(msg.get("text", ""))
503
+ if talk.strip():
504
+ await hub.publish(
505
+ campaign_id,
506
+ {"type": "table_talk", "participant_id": participant, "text": talk},
507
+ )
508
+ elif mtype == "vote":
509
+ # A consensus/vote arbiter (D-38, G-11) tallies votes in session-only state and
510
+ # broadcasts the running tally on the non-canon lane; a DECIDED vote is
511
+ # announced but still enacted as an ordinary beat by the turn-holder
512
+ # (take_pending deferred). If THIS server's arbiter has no vote shape, say so
513
+ # rather than silently swallow it (D-38 review) — the CLI advertises /vote
514
+ # unconditionally and can't know the server's arbiter.
515
+ choice = str(msg.get("choice", ""))
516
+ if not isinstance(arb, VoteCoordinator):
517
+ await hub.publish(
518
+ campaign_id,
519
+ {"type": "vote_unsupported", "participant_id": participant},
520
+ )
521
+ elif choice.strip():
522
+ outcome = await arb.cast_vote(campaign_id, participant, choice)
523
+ await hub.publish(
524
+ campaign_id,
525
+ {
526
+ "type": "vote_tally",
527
+ "participant_id": participant,
528
+ "choice": choice,
529
+ "tally": outcome.tally,
530
+ },
531
+ )
532
+ if outcome.decided is not None:
533
+ await hub.publish(
534
+ campaign_id,
535
+ {"type": "vote_decided", "choice": outcome.decided},
536
+ )
537
+ except WebSocketDisconnect:
538
+ pass
539
+ finally:
540
+ if forward is not None:
541
+ forward.cancel()
542
+ if queue is not None:
543
+ hub.unsubscribe(campaign_id, queue)
544
+ if joined: # only tear down a roster entry we created (guards a pre-join failure)
545
+ await arb.note_left(campaign_id, participant) # drop from the turn roster (OQ-7)
546
+ # A departure can COMPLETE a pending vote round — the last holdout LEFT instead of
547
+ # voting — which cast_vote never re-checks (D-38 review). Recompute and announce it,
548
+ # else the round would silently never resolve.
549
+ if isinstance(arb, VoteCoordinator):
550
+ pending = await arb.resolve_pending(campaign_id)
551
+ if pending is not None and pending.decided is not None:
552
+ await hub.publish(
553
+ campaign_id, {"type": "vote_decided", "choice": pending.decided}
554
+ )
555
+ await hub.publish(
556
+ campaign_id, {"type": "participant_left", "participant_id": participant}
557
+ )
558
+
559
+ return app
560
+
561
+
562
+ async def _forward(ws: WebSocket, queue: asyncio.Queue[dict[str, object]]) -> None:
563
+ """Pump this connection's queue to its socket until cancelled."""
564
+ while True:
565
+ message = await queue.get()
566
+ if ws.application_state != WebSocketState.CONNECTED:
567
+ return
568
+ await ws.send_json(message)
569
+
570
+
571
+ async def _run_and_broadcast(
572
+ deps: ServerDeps,
573
+ arbiter: TurnArbiter,
574
+ hub: SessionHub,
575
+ campaign_id: str,
576
+ participant: str,
577
+ text: str,
578
+ ) -> None:
579
+ """Admit the intent, run the beat, and broadcast its narration + commit to the whole session
580
+ (docs/08 broadcast-shaped output) — so every connected client sees the SAME beat."""
581
+ if not text.strip():
582
+ return
583
+ decision = await arbiter.admit(campaign_id, participant, text)
584
+ if decision == AdmitDecision.NOT_YOUR_TURN:
585
+ # Valid intent, but another participant holds the turn (round-robin, OQ-7) — tell the
586
+ # client to hold; it is NOT rejected, so a client may retry when the token rotates.
587
+ await hub.publish(
588
+ campaign_id, {"type": "not_your_turn", "participant_id": participant, "text": text}
589
+ )
590
+ return
591
+ if decision == AdmitDecision.QUEUED:
592
+ # A proposal-window arbiter HELD this non-holder intent as a PROPOSAL (D-38, G-10): surface
593
+ # it to the whole table — no beat, no rotation — so the party can debate it on the
594
+ # lane and the holder can enact it on their turn. NOT a rejection (the client may see it
595
+ # acted on next); NOT a silent not_your_turn (a proposal is a first-class, visible event).
596
+ await hub.publish(
597
+ campaign_id, {"type": "proposal_opened", "participant_id": participant, "text": text}
598
+ )
599
+ return
600
+ if decision != AdmitDecision.ADMITTED: # REJECTED → no beat now
601
+ await hub.publish(
602
+ campaign_id, {"type": "intent_rejected", "participant_id": participant, "text": text}
603
+ )
604
+ return
605
+ await hub.publish(
606
+ campaign_id, {"type": "beat_started", "participant_id": participant, "intent": text}
607
+ )
608
+ chunks: list[str] = []
609
+ try:
610
+ async for chunk in deps.run_beat(campaign_id, participant, text):
611
+ chunks.append(chunk)
612
+ await hub.publish(
613
+ campaign_id,
614
+ {"type": "narration_chunk", "participant_id": participant, "text": chunk},
615
+ )
616
+ except Exception as exc:
617
+ # A beat failure (e.g. a ruleset mismatch, a provider error) must NOT crash the WS
618
+ # connection — broadcast a graceful failure and keep the session alive (mirrors the CLI
619
+ # play loop's "beat failed; nothing was saved"). Nothing was committed (pre-commit crash).
620
+ await hub.publish(
621
+ campaign_id,
622
+ {
623
+ "type": "beat_failed",
624
+ "participant_id": participant,
625
+ "intent": text,
626
+ "error": str(exc),
627
+ },
628
+ )
629
+ # A failed turn STILL yields the token (cross-phase review P7xP3): otherwise a
630
+ # deterministically-failing holder (e.g. an unbound participant) would wedge the whole
631
+ # party forever. The trade-off — a transient failure costs a turn — is acceptable; the
632
+ # player gets the token back next round.
633
+ await arbiter.beat_committed(campaign_id, participant, "")
634
+ return
635
+ await hub.publish(
636
+ campaign_id,
637
+ {
638
+ "type": "beat_committed",
639
+ "participant_id": participant,
640
+ "intent": text,
641
+ "narration": "".join(chunks).strip(),
642
+ },
643
+ )
644
+ # The beat committed — let the arbiter rotate the turn token to the next participant (OQ-7).
645
+ await arbiter.beat_committed(campaign_id, participant, "")
uro_server/py.typed ADDED
File without changes
uro_server/sessions.py ADDED
@@ -0,0 +1,118 @@
1
+ """Broadcast-shaped session fan-out (docs/08). Server→client messages address a SESSION and
2
+ fan out to all its live connections; with one connection this is invisible, with four it is
3
+ already correct. This is the multiplayer seam — no per-participant logic, just fan-out.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import hashlib
10
+ import secrets
11
+ from typing import Any
12
+
13
+ from uro_core.ports.tokens import SessionTokenStore
14
+
15
+
16
+ def _hash(token: str) -> str:
17
+ return hashlib.sha256(token.encode()).hexdigest()
18
+
19
+
20
+ class TokenRegistry:
21
+ """Runtime token auth (docs/18 B10, D-39) behind the server's single `resolve_participant` choke
22
+ point. Tiers (kept DISTINCT — D-39 review): `--token` argv tokens are ordinary PLAYER creds
23
+ (server-wide, frozen at launch); a SUBSET flagged `--admin-token` is the OPERATOR tier that may
24
+ seat/mint/revoke for ANOTHER participant (an ordinary player may only act for THEMSELVES — else
25
+ every launch peer could impersonate any other). Runtime-minted player tokens are DURABLE (only
26
+ `sha256(token)` in `session_tokens`), CAMPAIGN-SCOPED (a minted token is valid only on the
27
+ campaign it was minted for — no cross-campaign hijack), and cached in-process so `resolve` stays
28
+ a sync lookup. The cache re-hydrates from the store at startup, so a minted token survives a
29
+ restart (an in-process-only registry would lock out every runtime-added player on restart)."""
30
+
31
+ def __init__(
32
+ self,
33
+ store: SessionTokenStore | None,
34
+ static_tokens: dict[str, str],
35
+ admin_tokens: set[str] | None = None,
36
+ ) -> None:
37
+ self._store = store
38
+ self._static = dict(static_tokens) # plaintext token → participant (server-wide players)
39
+ self._admin = set(admin_tokens or ()) # the OPERATOR subset (may act for others)
40
+ self._cache: dict[str, tuple[str, str]] = {} # sha256 → (participant, campaign) [minted]
41
+
42
+ async def hydrate(self) -> None:
43
+ """Load non-revoked runtime tokens from the store into the resolve cache (call once at
44
+ startup, AFTER the store connects)."""
45
+ if self._store is not None:
46
+ for token_hash, participant_id, campaign_id in await self._store.list_session_tokens():
47
+ self._cache[token_hash] = (participant_id, campaign_id)
48
+
49
+ def resolve(self, token: str) -> str | None:
50
+ """token → participant_id (or None). Sync: static by plaintext, minted by hash."""
51
+ if not token:
52
+ return None
53
+ if token in self._static:
54
+ return self._static[token]
55
+ row = self._cache.get(_hash(token))
56
+ return row[0] if row is not None else None
57
+
58
+ def campaign_of(self, token: str) -> str | None:
59
+ """The campaign a MINTED token is scoped to; None for a static/legacy (server-wide) or an
60
+ unknown token — the caller only rejects a minted token used on the WRONG campaign."""
61
+ if not token or token in self._static:
62
+ return None
63
+ row = self._cache.get(_hash(token))
64
+ return row[1] if row is not None else None
65
+
66
+ def is_admin(self, token: str) -> bool:
67
+ """Operator tier only (D-39 review): the `--admin-token` subset, NOT every `--token` peer
68
+ and never a runtime-minted player token. An operator may act for another participant."""
69
+ return bool(token) and token in self._admin
70
+
71
+ async def mint(self, participant_id: str, campaign_id: str) -> str:
72
+ """Mint a fresh durable, campaign-scoped token; returns the plaintext ONCE (only its sha256
73
+ is stored). Write the store first, then the cache (a crash re-hydrates from the store)."""
74
+ if self._store is None:
75
+ raise RuntimeError("token minting needs a store-backed server")
76
+ token = secrets.token_urlsafe(32)
77
+ token_hash = _hash(token)
78
+ await self._store.mint_token(token_hash, participant_id, campaign_id)
79
+ self._cache[token_hash] = (participant_id, campaign_id)
80
+ return token
81
+
82
+ async def revoke(self, token: str) -> bool:
83
+ """Revoke a token (by its plaintext); True iff a live token was actually revoked. Evicts the
84
+ cache so a NEW connect is denied immediately (a live socket survives — auth is checked once
85
+ before accept; the per-message re-check is a named residual, D-39)."""
86
+ if self._store is None:
87
+ return False
88
+ token_hash = _hash(token)
89
+ ok = await self._store.revoke_token(token_hash)
90
+ self._cache.pop(token_hash, None)
91
+ return ok
92
+
93
+
94
+ class SessionHub:
95
+ """Per-campaign pub/sub over live connections. Each connection subscribes a queue; a beat's
96
+ messages are published to the campaign and land on every subscriber's queue."""
97
+
98
+ def __init__(self) -> None:
99
+ self._subs: dict[str, set[asyncio.Queue[dict[str, Any]]]] = {}
100
+
101
+ def subscribe(self, campaign_id: str) -> asyncio.Queue[dict[str, Any]]:
102
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
103
+ self._subs.setdefault(campaign_id, set()).add(queue)
104
+ return queue
105
+
106
+ def unsubscribe(self, campaign_id: str, queue: asyncio.Queue[dict[str, Any]]) -> None:
107
+ subs = self._subs.get(campaign_id)
108
+ if subs is not None:
109
+ subs.discard(queue)
110
+ if not subs:
111
+ del self._subs[campaign_id]
112
+
113
+ async def publish(self, campaign_id: str, message: dict[str, Any]) -> None:
114
+ for queue in list(self._subs.get(campaign_id, ())):
115
+ queue.put_nowait(message)
116
+
117
+ def connections(self, campaign_id: str) -> int:
118
+ return len(self._subs.get(campaign_id, ()))
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: uro-server
3
+ Version: 0.2.0
4
+ Summary: Uro Engine headless server — thin FastAPI shell over uro-core (REST + WebSocket)
5
+ Project-URL: Homepage, https://github.com/cupskeee/uro
6
+ Project-URL: Repository, https://github.com/cupskeee/uro
7
+ Project-URL: Issues, https://github.com/cupskeee/uro/issues
8
+ Author: cupskeee
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: fastapi>=0.115
13
+ Requires-Dist: uro-core[llm,postgres]==0.2.0
14
+ Requires-Dist: uvicorn[standard]>=0.51.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # uro-server
18
+
19
+ **The Uro Engine headless server — a thin FastAPI shell over [`uro-core`](https://pypi.org/project/uro-core/).**
20
+
21
+ `uro-server` exposes the engine as a REST + WebSocket API. It is transport and session wiring
22
+ only — no engine logic. It runs the WebSocket play channel (broadcast fan-out, token auth) and
23
+ the Chronicler outcome endpoint over a testable dependency seam.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ pip install uro-server
29
+ ```
30
+
31
+ ## Runtime
32
+
33
+ The server drives the engine, which requires **PostgreSQL 17 + pgvector** and (for live play) an
34
+ LLM provider — both pulled via `uro-core`'s extras. The reference way to run a server is
35
+ `uro serve` from [`uro-cli`](https://pypi.org/project/uro-cli/), which builds the dependencies.
36
+
37
+ ## Links
38
+
39
+ - Source and docs (see `docs/08-api-and-sessions.md`): https://github.com/cupskeee/uro
40
+ - License: MIT
@@ -0,0 +1,8 @@
1
+ uro_server/__init__.py,sha256=r61i7AJy4pBVkW6bgXl2U1ytdYJrUaOYVEQvrVKGRU0,321
2
+ uro_server/app.py,sha256=im-xFiYGY819sQXVDXG0SsXor1Hm3YFEspcCkBeR6ME,33697
3
+ uro_server/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ uro_server/sessions.py,sha256=geKao1ZWGEF_LQRhF_7z1snPtlU4sDN-2f9YheDhV4Q,5616
5
+ uro_server-0.2.0.dist-info/METADATA,sha256=eVmcd6mRCfwXOCFcUyMWpzq353Hrjk4Duw5O_6rz04A,1404
6
+ uro_server-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ uro_server-0.2.0.dist-info/licenses/LICENSE,sha256=EULDR6F7DrDMXRHwkUgLPfsupPZdMj945mS6-DrTXWk,1065
8
+ uro_server-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cupskeee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.