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>
107 lines
3.9 KiB
Python
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)
|