woolroom 0.3.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.
Files changed (107) hide show
  1. app/__init__.py +0 -0
  2. app/api/__init__.py +0 -0
  3. app/api/admin.py +396 -0
  4. app/api/deps.py +78 -0
  5. app/api/http.py +1221 -0
  6. app/api/ws.py +232 -0
  7. app/auth/__init__.py +0 -0
  8. app/auth/session.py +46 -0
  9. app/auth/site_access.py +128 -0
  10. app/channels/__init__.py +0 -0
  11. app/channels/base.py +17 -0
  12. app/channels/webapp.py +341 -0
  13. app/config.py +163 -0
  14. app/data/__init__.py +0 -0
  15. app/data/body_language.py +1015 -0
  16. app/data/quirks_catalog.py +253 -0
  17. app/data/species.py +83 -0
  18. app/data/voice.py +302 -0
  19. app/engine/__init__.py +0 -0
  20. app/engine/aging.py +120 -0
  21. app/engine/mood.py +132 -0
  22. app/engine/outings.py +92 -0
  23. app/engine/quirks.py +286 -0
  24. app/eval/__init__.py +16 -0
  25. app/eval/corpus.py +110 -0
  26. app/eval/runner.py +255 -0
  27. app/main.py +511 -0
  28. app/memory/__init__.py +0 -0
  29. app/memory/buffer.py +112 -0
  30. app/memory/core.py +44 -0
  31. app/memory/moments.py +206 -0
  32. app/packs/__init__.py +52 -0
  33. app/packs/lint.py +3 -0
  34. app/packs/loader.py +237 -0
  35. app/packs/profiles/dog/pack.yaml +5 -0
  36. app/packs/profiles/dog/phrases/dog.yaml +536 -0
  37. app/packs/profiles/dog/species/dog.svg +56 -0
  38. app/packs/profiles/dog/species/dog.yaml +36 -0
  39. app/packs/profiles/dog/voice.yaml +5 -0
  40. app/packs/profiles/pig/pack.yaml +5 -0
  41. app/packs/profiles/pig/phrases/pig.yaml +536 -0
  42. app/packs/profiles/pig/species/pig.svg +53 -0
  43. app/packs/profiles/pig/species/pig.yaml +30 -0
  44. app/packs/profiles/pig/voice.yaml +4 -0
  45. app/packs/sanitize.py +39 -0
  46. app/room_contract.py +92 -0
  47. app/runtime/__init__.py +0 -0
  48. app/runtime/actions.py +336 -0
  49. app/runtime/client.py +165 -0
  50. app/runtime/llm_log.py +175 -0
  51. app/runtime/pet_state.py +381 -0
  52. app/runtime/prompt.py +103 -0
  53. app/runtime/respond.py +235 -0
  54. app/runtime/scene_fx.py +237 -0
  55. app/runtime/shared_trace.py +38 -0
  56. app/runtime/validator.py +75 -0
  57. app/runtime/visits.py +78 -0
  58. app/scheduler/__init__.py +0 -0
  59. app/scheduler/jobs.py +319 -0
  60. app/static/access.html +172 -0
  61. app/static/app.js +70 -0
  62. app/static/apple-touch-icon.png +0 -0
  63. app/static/favicon.svg +14 -0
  64. app/static/icon-192.png +0 -0
  65. app/static/icon-512-maskable.png +0 -0
  66. app/static/icon-512.png +0 -0
  67. app/static/index.html +946 -0
  68. app/static/js/api.js +632 -0
  69. app/static/js/figures.js +245 -0
  70. app/static/js/memory.js +55 -0
  71. app/static/js/presence.js +188 -0
  72. app/static/js/quirks.js +195 -0
  73. app/static/js/sound.js +316 -0
  74. app/static/js/state.js +117 -0
  75. app/static/js/ui.js +86 -0
  76. app/static/js/wool.js +615 -0
  77. app/static/js/woolevents.js +508 -0
  78. app/static/js/woolfx.js +329 -0
  79. app/static/js/woolvisits.js +146 -0
  80. app/static/js/ws.js +303 -0
  81. app/static/manifest.json +17 -0
  82. app/static/style.css +1647 -0
  83. app/static/vendor/alpine-3.14.1.min.js +5 -0
  84. app/storage/__init__.py +0 -0
  85. app/storage/db.py +30 -0
  86. app/storage/models.py +222 -0
  87. app/storage/repo.py +707 -0
  88. app/time.py +46 -0
  89. woolroom/__init__.py +78 -0
  90. woolroom/adoption.py +33 -0
  91. woolroom/auth.py +54 -0
  92. woolroom/database.py +753 -0
  93. woolroom/migrations/README +3 -0
  94. woolroom/migrations/__init__.py +1 -0
  95. woolroom/migrations/env.py +114 -0
  96. woolroom/migrations/script.py.mako +28 -0
  97. woolroom/migrations/versions/48575234f9ca_initial_schema.py +196 -0
  98. woolroom/migrations/versions/__init__.py +1 -0
  99. woolroom/overlay.py +114 -0
  100. woolroom/py.typed +1 -0
  101. woolroom-0.3.0.dist-info/METADATA +265 -0
  102. woolroom-0.3.0.dist-info/RECORD +107 -0
  103. woolroom-0.3.0.dist-info/WHEEL +5 -0
  104. woolroom-0.3.0.dist-info/entry_points.txt +2 -0
  105. woolroom-0.3.0.dist-info/licenses/LICENSE +21 -0
  106. woolroom-0.3.0.dist-info/licenses/packages/woolpack/LICENSE-CC0 +121 -0
  107. woolroom-0.3.0.dist-info/top_level.txt +2 -0
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
File without changes
app/api/admin.py ADDED
@@ -0,0 +1,396 @@
1
+ """Token-gated /admin/* operator routes: user/pet triage, merges, hard
2
+ deletes, recovery-link management, LLM telemetry. Separate from http.py so
3
+ the ops surface and the product surface read apart; auth is the
4
+ X-Admin-Token header against settings.admin_token (empty disables admin
5
+ entirely), throttled by main.py's auth-failure middleware."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import secrets as _secrets
10
+
11
+ from fastapi import APIRouter, Depends, Header, HTTPException, Request
12
+ from pydantic import BaseModel
13
+ from sqlalchemy import select as _sa_select
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from app.api.deps import db
17
+ from app.api.http import _absolute_url
18
+ from app.config import settings
19
+ from app.storage import repo
20
+ from app.storage.models import User
21
+ from app.time import iso_z, utc_now
22
+
23
+ router = APIRouter()
24
+
25
+ class RegenRecoveryIn(BaseModel):
26
+ user_id: str | None = None
27
+ display_name: str | None = None
28
+
29
+
30
+ def _require_admin(token_header: str | None) -> None:
31
+ if not settings.admin_token:
32
+ raise HTTPException(status_code=403, detail="admin disabled")
33
+ if not token_header or not _secrets.compare_digest(token_header, settings.admin_token):
34
+ raise HTTPException(status_code=403, detail="bad admin token")
35
+
36
+
37
+ @router.get("/admin/users")
38
+ async def admin_list_users(
39
+ x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
40
+ session: AsyncSession = Depends(db),
41
+ ) -> dict:
42
+ """List every user with whether they're a participant, what pet, and when
43
+ they were last seen. For triage when display_name lookup is ambiguous
44
+ (e.g. a test user and a real user sharing one display name)."""
45
+ _require_admin(x_admin_token)
46
+ from app.storage.models import PetParticipant
47
+ q = (
48
+ _sa_select(User, PetParticipant.pet_id)
49
+ .outerjoin(PetParticipant, PetParticipant.user_id == User.id)
50
+ .order_by(User.created_at.asc(), User.id.asc())
51
+ )
52
+ rows = (await session.execute(q)).all()
53
+ results: list[dict] = []
54
+ for user, pet_id in rows:
55
+ pet = await repo.get_pet(session, pet_id) if pet_id else None
56
+ results.append({
57
+ "user_id": user.id,
58
+ "display_name": user.display_name,
59
+ "created_at": iso_z(user.created_at),
60
+ "last_seen_at": iso_z(user.last_seen_at),
61
+ "is_participant": pet is not None,
62
+ "pet_id": pet.id if pet else None,
63
+ "pet_name": pet.name if pet else None,
64
+ })
65
+ return {"users": results}
66
+
67
+
68
+ @router.delete("/admin/user/{user_id}")
69
+ async def admin_delete_user(
70
+ user_id: str,
71
+ x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
72
+ session: AsyncSession = Depends(db),
73
+ ) -> dict:
74
+ """Hard-delete a user. Cascades by hand since the schema doesn't declare
75
+ ON DELETE CASCADE. PetParticipant rows are deleted (orphan participant
76
+ rows would dangle). BufferEvent + Outing user_id is nulled (we keep the
77
+ events, just untie them from the deleted user). MagicLink rows for that
78
+ user are deleted (no FK, plain string match on issued_for)."""
79
+ _require_admin(x_admin_token)
80
+ from sqlalchemy import delete, update
81
+ from app.storage.models import BufferEvent, MagicLink, Outing, PetParticipant
82
+ user = await session.get(User, user_id)
83
+ if not user:
84
+ raise HTTPException(status_code=404, detail="no such user")
85
+ await session.execute(delete(PetParticipant).where(PetParticipant.user_id == user_id))
86
+ await session.execute(update(BufferEvent).where(BufferEvent.user_id == user_id).values(user_id=None))
87
+ await session.execute(update(Outing).where(Outing.triggered_by_user_id == user_id).values(triggered_by_user_id=None))
88
+ await session.execute(delete(MagicLink).where(MagicLink.issued_for == user_id))
89
+ await session.delete(user)
90
+ await session.commit()
91
+ return {"deleted_user_id": user_id, "display_name": user.display_name}
92
+
93
+
94
+ class MergePetIn(BaseModel):
95
+ source_pet_id: str
96
+ target_pet_id: str
97
+
98
+
99
+ @router.post("/admin/merge-pet")
100
+ async def admin_merge_pet(
101
+ body: MergePetIn,
102
+ x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
103
+ session: AsyncSession = Depends(db),
104
+ ) -> dict:
105
+ """Reparent everything from source_pet_id onto target_pet_id, then delete
106
+ the source pet. Useful for "someone accidentally adopted a duplicate pet —
107
+ fold it into the shared one." The (pet_id, user_id) PK is honored: if
108
+ source's participant is already in target, source's row is dropped
109
+ instead of moved."""
110
+ _require_admin(x_admin_token)
111
+ from sqlalchemy import delete, update
112
+ from app.storage.models import (
113
+ BufferEvent, CoreFact, MagicLink, Moment, Outing, Pet, PetParticipant,
114
+ )
115
+ if body.source_pet_id == body.target_pet_id:
116
+ raise HTTPException(status_code=400, detail="source and target are the same")
117
+ source = await session.get(Pet, body.source_pet_id)
118
+ target = await session.get(Pet, body.target_pet_id)
119
+ if not source or not target:
120
+ raise HTTPException(status_code=404, detail="source or target pet not found")
121
+
122
+ moved_events = 0
123
+ moved_moments = 0
124
+ moved_participants = 0
125
+ dropped_participants = 0
126
+
127
+ # Participants: per-user unique constraint means a user can be in only one
128
+ # pet at a time. Use raw UPDATE (not ORM delete+insert) so the unique index
129
+ # never sees both rows simultaneously.
130
+ target_user_ids_subq = (
131
+ _sa_select(PetParticipant.user_id)
132
+ .where(PetParticipant.pet_id == body.target_pet_id)
133
+ .scalar_subquery()
134
+ )
135
+ r = await session.execute(
136
+ update(PetParticipant)
137
+ .where(
138
+ PetParticipant.pet_id == body.source_pet_id,
139
+ PetParticipant.user_id.notin_(target_user_ids_subq),
140
+ )
141
+ .values(pet_id=body.target_pet_id)
142
+ )
143
+ moved_participants = int(r.rowcount or 0)
144
+ r = await session.execute(
145
+ delete(PetParticipant).where(PetParticipant.pet_id == body.source_pet_id)
146
+ )
147
+ dropped_participants = int(r.rowcount or 0)
148
+
149
+ # Events + moments: reparent to target so the activity history follows.
150
+ r = await session.execute(
151
+ update(BufferEvent)
152
+ .where(BufferEvent.pet_id == body.source_pet_id)
153
+ .values(pet_id=body.target_pet_id)
154
+ )
155
+ moved_events = int(r.rowcount or 0)
156
+ r = await session.execute(
157
+ update(Moment)
158
+ .where(Moment.pet_id == body.source_pet_id)
159
+ .values(pet_id=body.target_pet_id)
160
+ )
161
+ moved_moments = int(r.rowcount or 0)
162
+
163
+ # Core facts: key conflicts likely — drop source's facts to avoid overwriting
164
+ # target's lovingly-curated adopted_by, etc.
165
+ await session.execute(delete(CoreFact).where(CoreFact.pet_id == body.source_pet_id))
166
+ # Outings + magic_links scoped to source: drop. Stale once source is gone.
167
+ await session.execute(delete(Outing).where(Outing.pet_id == body.source_pet_id))
168
+ await session.execute(delete(MagicLink).where(MagicLink.pet_id == body.source_pet_id))
169
+
170
+ source_name = source.name
171
+ await session.delete(source)
172
+ await session.commit()
173
+ return {
174
+ "source_pet_id": body.source_pet_id,
175
+ "source_name": source_name,
176
+ "target_pet_id": body.target_pet_id,
177
+ "target_name": target.name,
178
+ "moved_participants": moved_participants,
179
+ "dropped_participants": dropped_participants,
180
+ "moved_events": moved_events,
181
+ "moved_moments": moved_moments,
182
+ }
183
+
184
+
185
+ @router.delete("/admin/pet/{pet_id}")
186
+ async def admin_delete_pet(
187
+ pet_id: str,
188
+ x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
189
+ session: AsyncSession = Depends(db),
190
+ ) -> dict:
191
+ """Hard-delete a pet. Cascades through every per-pet row (participants,
192
+ events, moments, core_facts, outings, pet-scoped magic_links). The Pet
193
+ itself goes last so foreign-key constraints don't fire mid-transaction."""
194
+ _require_admin(x_admin_token)
195
+ from sqlalchemy import delete
196
+ from app.storage.models import (
197
+ BufferEvent, CoreFact, MagicLink, Moment, Outing, Pet, PetParticipant,
198
+ )
199
+ pet = await session.get(Pet, pet_id)
200
+ if not pet:
201
+ raise HTTPException(status_code=404, detail="no such pet")
202
+ await session.execute(delete(PetParticipant).where(PetParticipant.pet_id == pet_id))
203
+ await session.execute(delete(BufferEvent).where(BufferEvent.pet_id == pet_id))
204
+ await session.execute(delete(Moment).where(Moment.pet_id == pet_id))
205
+ await session.execute(delete(CoreFact).where(CoreFact.pet_id == pet_id))
206
+ await session.execute(delete(Outing).where(Outing.pet_id == pet_id))
207
+ await session.execute(delete(MagicLink).where(MagicLink.pet_id == pet_id))
208
+ pet_name = pet.name
209
+ await session.delete(pet)
210
+ await session.commit()
211
+ return {"deleted_pet_id": pet_id, "name": pet_name}
212
+
213
+
214
+ @router.post("/admin/regenerate-recovery")
215
+ async def admin_regenerate_recovery(
216
+ request: Request,
217
+ body: RegenRecoveryIn,
218
+ x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
219
+ session: AsyncSession = Depends(db),
220
+ ) -> dict:
221
+ """Mint a fresh recovery URL for one or more users. Use when a user has lost
222
+ their cookie AND their saved recovery URL — the only way back to their
223
+ original account otherwise is direct DB intervention.
224
+
225
+ Auth: X-Admin-Token header must match settings.admin_token.
226
+ Match: either user_id (single) or display_name (may match multiple).
227
+ """
228
+ _require_admin(x_admin_token)
229
+ if body.user_id:
230
+ existing = await session.get(User, body.user_id)
231
+ users = [existing] if existing else []
232
+ elif body.display_name:
233
+ q = _sa_select(User).where(User.display_name == body.display_name)
234
+ users = list((await session.execute(q)).scalars().all())
235
+ else:
236
+ raise HTTPException(status_code=400, detail="user_id or display_name required")
237
+ if not users:
238
+ raise HTTPException(status_code=404, detail="no matching user")
239
+
240
+ results: list[dict] = []
241
+ for u in users:
242
+ # Additive — older URLs for this user stay valid. Use admin_revoke
243
+ # for the rare "I think this leaked" case.
244
+ link = await repo.mint_recovery_link(session, u.id)
245
+ pet = await repo.get_pet_for_user(session, u.id)
246
+ results.append({
247
+ "user_id": u.id,
248
+ "display_name": u.display_name,
249
+ "pet_id": pet.id if pet else None,
250
+ "pet_name": pet.name if pet else None,
251
+ "is_participant": pet is not None,
252
+ "recovery_url": _absolute_url(request, f"/r/{link.token}"),
253
+ })
254
+ await session.commit()
255
+ return {"results": results}
256
+
257
+
258
+ @router.post("/admin/revoke-recovery")
259
+ async def admin_revoke_recovery(
260
+ body: RegenRecoveryIn,
261
+ x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
262
+ session: AsyncSession = Depends(db),
263
+ ) -> dict:
264
+ """Nuke every recovery token for a user. Use after a suspected leak.
265
+ Pair with regenerate-recovery to issue a fresh URL afterward."""
266
+ _require_admin(x_admin_token)
267
+ if body.user_id:
268
+ target_ids = [body.user_id]
269
+ elif body.display_name:
270
+ q = _sa_select(User.id).where(User.display_name == body.display_name)
271
+ target_ids = list((await session.execute(q)).scalars().all())
272
+ else:
273
+ raise HTTPException(status_code=400, detail="user_id or display_name required")
274
+ if not target_ids:
275
+ raise HTTPException(status_code=404, detail="no matching user")
276
+ from sqlalchemy import delete
277
+ from app.storage.models import MagicLink as _ML
278
+ total = 0
279
+ for uid in target_ids:
280
+ result = await session.execute(
281
+ delete(_ML).where(_ML.issued_for == uid, _ML.purpose == "recovery")
282
+ )
283
+ total += int(result.rowcount or 0)
284
+ await session.commit()
285
+ return {"revoked_count": total, "user_ids": target_ids}
286
+
287
+
288
+ @router.get("/admin/llm/stats")
289
+ async def admin_llm_stats(
290
+ x_admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
291
+ hours: int = 24,
292
+ session: AsyncSession = Depends(db),
293
+ ) -> dict:
294
+ """Aggregate LLM-call telemetry over the last N hours (default 24).
295
+
296
+ Returns total calls, status distribution, validator acceptance rate,
297
+ latency percentiles, and per-(provider, model, prompt_version) breakdown.
298
+ Backs the "we measure our LLM stack" claim on the portfolio page.
299
+ """
300
+ _require_admin(x_admin_token)
301
+ from datetime import timedelta
302
+ from sqlalchemy import func as _f
303
+ from app.storage.models import LLMCall
304
+
305
+ cutoff = utc_now() - timedelta(hours=max(1, hours))
306
+
307
+ total = (
308
+ await session.execute(
309
+ _sa_select(_f.count(LLMCall.id)).where(LLMCall.ts >= cutoff)
310
+ )
311
+ ).scalar_one()
312
+
313
+ status_rows = (
314
+ await session.execute(
315
+ _sa_select(LLMCall.status, _f.count(LLMCall.id))
316
+ .where(LLMCall.ts >= cutoff)
317
+ .group_by(LLMCall.status)
318
+ )
319
+ ).all()
320
+
321
+ verdict_rows = (
322
+ await session.execute(
323
+ _sa_select(LLMCall.validator_verdict, _f.count(LLMCall.id))
324
+ .where(LLMCall.ts >= cutoff, LLMCall.status == "ok")
325
+ .group_by(LLMCall.validator_verdict)
326
+ )
327
+ ).all()
328
+
329
+ # Per-(provider, model, prompt_version) latency + status snapshot.
330
+ breakdown_rows = (
331
+ await session.execute(
332
+ _sa_select(
333
+ LLMCall.provider,
334
+ LLMCall.model,
335
+ LLMCall.prompt_version,
336
+ _f.count(LLMCall.id),
337
+ _f.avg(LLMCall.latency_ms),
338
+ _f.max(LLMCall.latency_ms),
339
+ _f.min(LLMCall.latency_ms),
340
+ )
341
+ .where(LLMCall.ts >= cutoff)
342
+ .group_by(LLMCall.provider, LLMCall.model, LLMCall.prompt_version)
343
+ )
344
+ ).all()
345
+
346
+ ok_latencies = (
347
+ await session.execute(
348
+ _sa_select(LLMCall.latency_ms)
349
+ .where(LLMCall.ts >= cutoff, LLMCall.status == "ok")
350
+ .order_by(LLMCall.latency_ms.asc())
351
+ )
352
+ ).scalars().all()
353
+
354
+ def _pct(values: list[int], q: float) -> int | None:
355
+ if not values:
356
+ return None
357
+ i = min(len(values) - 1, int(len(values) * q))
358
+ return int(values[i])
359
+
360
+ accepted = next((c for v, c in verdict_rows if v == "accepted"), 0)
361
+ rejected = next((c for v, c in verdict_rows if v == "rejected"), 0)
362
+ pending_verdict = next((c for v, c in verdict_rows if v == "n/a"), 0)
363
+
364
+ return {
365
+ "window_hours": hours,
366
+ "total_calls": int(total),
367
+ "status_counts": {s: int(c) for s, c in status_rows},
368
+ "validator": {
369
+ "accepted": int(accepted),
370
+ "rejected": int(rejected),
371
+ "pending_or_na": int(pending_verdict),
372
+ "acceptance_rate": (
373
+ round(accepted / (accepted + rejected), 4)
374
+ if (accepted + rejected) > 0
375
+ else None
376
+ ),
377
+ },
378
+ "latency_ms": {
379
+ "p50": _pct(list(ok_latencies), 0.50),
380
+ "p95": _pct(list(ok_latencies), 0.95),
381
+ "max": int(max(ok_latencies)) if ok_latencies else None,
382
+ "n": len(ok_latencies),
383
+ },
384
+ "by_prompt_version": [
385
+ {
386
+ "provider": p,
387
+ "model": m,
388
+ "prompt_version": v,
389
+ "count": int(c),
390
+ "avg_latency_ms": int(avg) if avg is not None else None,
391
+ "min_latency_ms": int(lo) if lo is not None else None,
392
+ "max_latency_ms": int(hi) if hi is not None else None,
393
+ }
394
+ for p, m, v, c, avg, hi, lo in breakdown_rows
395
+ ],
396
+ }
app/api/deps.py ADDED
@@ -0,0 +1,78 @@
1
+ """FastAPI dependencies. Session-cookie user + pet lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+
7
+ from fastapi import Depends, HTTPException, Request, status
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from app.auth.session import load_user
11
+ from app.storage import repo
12
+ from app.storage.db import SessionLocal
13
+ from app.storage.models import Pet, User
14
+
15
+
16
+ async def db() -> AsyncIterator[AsyncSession]:
17
+ async with SessionLocal() as session:
18
+ try:
19
+ yield session
20
+ await session.commit()
21
+ except Exception:
22
+ await session.rollback()
23
+ raise
24
+
25
+
26
+ async def current_user_optional(
27
+ request: Request,
28
+ session: AsyncSession = Depends(db),
29
+ ) -> User | None:
30
+ namespace = request.app.state.auth_namespace
31
+ return await load_user(
32
+ session,
33
+ request.cookies.get(namespace.session_cookie),
34
+ namespace,
35
+ )
36
+
37
+
38
+ async def current_user(
39
+ user: User | None = Depends(current_user_optional),
40
+ ) -> User:
41
+ if user is None:
42
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session")
43
+ return user
44
+
45
+
46
+ async def current_pet(
47
+ request: Request,
48
+ user: User = Depends(current_user),
49
+ session: AsyncSession = Depends(db),
50
+ ) -> Pet:
51
+ """The room the caller is acting in. Rooms are addressed explicitly via
52
+ the ?pet=<id> query param (every room-scoped route, GET or POST); without
53
+ it, fall back to the human's active room (last-left, else founding).
54
+
55
+ An unconfirmed co-adoption participant gets a 403 everywhere except the
56
+ ceremony endpoints — the second cat's room opens for them once they've
57
+ picked its second quirk."""
58
+ requested = request.query_params.get("pet")
59
+ if requested:
60
+ participant = await repo.get_participant(session, requested, user.id)
61
+ if participant is None:
62
+ raise HTTPException(
63
+ status_code=status.HTTP_403_FORBIDDEN,
64
+ detail="not your room",
65
+ )
66
+ if participant.confirmed_adoption_at is None:
67
+ raise HTTPException(
68
+ status_code=status.HTTP_403_FORBIDDEN,
69
+ detail="meet him first — pick his second habit",
70
+ )
71
+ pet = await repo.get_pet(session, requested)
72
+ if pet is None:
73
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no such room")
74
+ return pet
75
+ pet = await repo.resolve_active_pet(session, user)
76
+ if pet is None:
77
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no pet yet")
78
+ return pet