Files
ai-agent/backend/notif_read.py
Gabriel Vidal a82091f4fd feat(ai-agent): read-only API key + unread-notifications endpoint
Adds a read-ONLY credential and a server-side unread feed so the desk phone can
read out the latest notifications without any write access.

* READONLY_API_KEY (comma-separated): a caller presenting a matching X-API-Key
  may hit safe methods (GET/HEAD/OPTIONS) only — every mutating method is 403,
  even from an otherwise-trusted source IP. Checked before the trusted-IP path,
  so the phone (which reaches us over the trusted host loopback) is downgraded
  to read-only and can never spawn a session or PUT into .claude/.

* GET /api/notifications/unread — the last unread notifications, newest first,
  flattened server-side exactly like the frontend's buildFeed+mergeLog (same
  stable ids). Pure read: it NEVER marks anything read (listening on the phone
  isn't opening a notification).

* Server-side read ledger (notif_read.py / /data/notif-read.json): the "new"
  state used to live only in the browser. POST /api/notifications/seen|seed mark
  ids read; the PWA now mirrors its dismiss/seed into it so the phone's unread
  view agrees with what's been opened in the app.

Regenerated openapi.json + frontend types; mock handlers + tsc updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:32:01 +02:00

107 lines
3.9 KiB
Python

"""Server-side "read" ledger for the notification feed.
The feed's "new" (unread) state used to live only in the browser (a Zustand
persist store). That was fine for the PWA but opaque to anything else — in
particular the desk **phone**, which wants to fetch *unread* notifications and
read them aloud without a browser in the loop.
This is the small server-side counterpart: a set of notification ids that have
been **opened/seen in the UI**. Unread = feed ids not in this set. Two rules the
phone flow depends on:
* A read-only caller (the phone, presenting the read-only API key) fetching the
unread list must **not** mutate this — *listening on the phone doesn't count
as reading*. Only an explicit ``POST /api/notifications/seen`` (the PWA,
swiping/opening a card) marks ids read.
* A first sync from a fresh client shouldn't flood everything as "unread": the
``seed`` op adopts the current feed as the read baseline in one shot.
Store: one JSON file (``/data/notif-read.json``), atomic rewrite, same pattern as
``meta.py``/``notify.py``. Shape: ``{"seen": ["<id>", …], "seeded": bool}``. The
set is capped (newest kept by insertion order) so it can't grow without bound.
"""
import json
import os
import pathlib
import threading
_MAX_SEEN = 5000
class NotifReadStore:
def __init__(self, path: str):
self.path = pathlib.Path(path)
self._lock = threading.Lock()
self._seen: list[str] = []
self._seen_set: set[str] = set()
self._seeded = False
self._load()
def _load(self) -> None:
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
seen = [str(x) for x in (data.get("seen") or [])]
self._seen = seen
self._seen_set = set(seen)
self._seeded = bool(data.get("seeded"))
except (OSError, ValueError):
self._seen, self._seen_set, self._seeded = [], set(), False
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(
json.dumps({"seen": self._seen, "seeded": self._seeded}, indent=2),
encoding="utf-8",
)
os.replace(tmp, self.path)
def is_seen(self, nid: str) -> bool:
with self._lock:
return nid in self._seen_set
def seeded(self) -> bool:
with self._lock:
return self._seeded
def unread(self, ids: list[str]) -> list[str]:
"""Which of ``ids`` are unread. Pure read — never mutates the ledger.
Before the first ``seed``, nothing is considered read (so a fresh install
surfaces recent notifications rather than reporting zero); after seeding,
only ids not in the seen set are unread.
"""
with self._lock:
if not self._seeded:
return list(ids)
return [i for i in ids if i not in self._seen_set]
def _add(self, ids: list[str]) -> None:
for nid in ids:
if nid and nid not in self._seen_set:
self._seen_set.add(nid)
self._seen.append(nid)
if len(self._seen) > _MAX_SEEN:
drop = len(self._seen) - _MAX_SEEN
for nid in self._seen[:drop]:
self._seen_set.discard(nid)
self._seen = self._seen[drop:]
def mark_seen(self, ids: list[str]) -> int:
"""Mark ``ids`` read. Returns the total seen count. Also sets seeded so a
subsequent client that never called seed still gets a sane baseline."""
with self._lock:
self._add(ids)
self._seeded = True
self._save()
return len(self._seen)
def seed(self, ids: list[str]) -> int:
"""Adopt ``ids`` as the read baseline (idempotent first-sync)."""
with self._lock:
self._add(ids)
self._seeded = True
self._save()
return len(self._seen)