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 +8 -0
- uro_server/app.py +645 -0
- uro_server/py.typed +0 -0
- uro_server/sessions.py +118 -0
- uro_server-0.2.0.dist-info/METADATA +40 -0
- uro_server-0.2.0.dist-info/RECORD +8 -0
- uro_server-0.2.0.dist-info/WHEEL +4 -0
- uro_server-0.2.0.dist-info/licenses/LICENSE +21 -0
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,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.
|