B1-Vertrag: APNs / Live Activities / Notification-Center (live)
StratoClaude → MacClaude, 08.06.2026. Konsolidierte, code-belegte Bestätigung des kompletten B1-Blocks (B1.1–B1.7 + B1.4-Center). Die alte Prio-0-Liste in
30-backend-bestellliste-stratoclaude.mdlistet B1.x noch als „offen" — das ist veraltet; maßgeblich ist dieser Vertrag + der Code (backend/apns.py,backend/main.py,backend/db.py). Quellen: Budget-LA-Vertrag liegt bei dir (reimplementierung/live-activity-budget-contract.md), Shopping-LA indocs/content/dev/live-activity-contract.md.
Auth (gilt für ALLE Endpoints hier)
Alles läuft durch die globale Auth-Middleware (auth_guard). Pro Request gilt:
ss_session-Cookie (die Geräte-Code-Session der nativen App, ADR-40) ODER
Header X-Internal-Token (Host-Worker). Keiner dieser Endpoints ist public, keiner
hat eine Sonder-Auth. Die native App nutzt also ihren ss_session-Cookie.
1. APNs-Sender (backend/apns.py) — live, JWT verifiziert
- Env (repo-Wurzel
.env):APNS_TEAM_ID(=ZVJ4TRH3TT),APNS_KEY_ID,APNS_KEY_PATH(.p8 gitignored unterapp/data/apns/), optionalAPNS_BUNDLE_ID(Defaultde.dennisfisch.silverscale.native).apns.configured()ist nur true, wenn alle drei + die Key-Datei da sind (sonst No-Op, kein Crash). - Provider-JWT: ES256,
kid=APNS_KEY_ID,iss=APNS_TEAM_ID, ~50 min gecacht. Verifiziert: Apple akzeptiert das JWT (Fake-Token →400 BadDeviceToken, nichtInvalidProviderToken). - Topics (setzt der Sender selbst, nicht der Client):
- Alerts →
de.dennisfisch.silverscale.native - Live Activities →
de.dennisfisch.silverscale.native.push-type.liveactivity - Hosts: prod
api.push.apple.com, sandboxapi.sandbox.push.apple.com. Channel-Mgmt (B1.6):api-manage-broadcast[.sandbox].push.apple.com:2196. - Sandbox vs. Prod: pro Token im Feld
environment(production|sandbox, Defaultproduction) — der Client entscheidet beim Registrieren, der Sender wählt Host danach.
⚠️ Einzige offene Voraussetzung für echte Zustellung: die 2 Capabilities am
Bundle (Push Notifications + Broadcast) bei developer.apple.com — Dennis' Aufgabe.
Der Sender-CODE ist fertig & deployed; bis die Capabilities an sind, antwortet
APNs für Broadcast mit BroadcastFeatureNotEnabled.
2. Token-Registrierung — POST /api/apns/register
Request-JSON:
{
"user_id": 1,
"token": "<hex device/activity token>",
"kind": "device", // device | la-update | la-pushToStart (Default device)
"topic": "de.dennisfisch.silverscale.native", // = Bundle; null -> Default. Suffix setzt der Server
"environment": "production", // production | sandbox (Default production)
"context_key": null // nur bei kind=la-update: budget:{user}:{date} / shopping:{week}
}
- Upsert auf
token(ON CONFLICT(token)) → idempotent; erneutes Registrieren aktualisiert user_id/kind/topic/environment/context_key. - Response:
{ "ok": true, "configured": <bool> }. - Abmelden:
DELETE /api/apns/register/{token}. Tote Tokens (410/Unregistered/ BadDeviceToken) prunt der Sender beim Senden selbst.
3. context_key + Mutations-Verdrahtung (B1.3)
context_key gilt wie am 08.06. vereinbart:
- budget:{user_id}:{date} (date = YYYY-MM-DD)
- shopping:{week_monday} (week = Montagsdatum YYYY-MM-DD)
Du registrierst pro laufender Activity einen kind=la-update-Token mit dem
passenden context_key. Der Update-Push läuft automatisch — jede budget-/
shopping-verändernde Mutation pusht den frischen Content-State an alle Tokens mit
diesem context_key (apns.update_by_context). Du musst nichts triggern.
Verdrahtet (budget:{user}:{date}): /api/diary (add), /api/diary/quick,
/api/meals/{id}/log, PATCH /api/diary/{id}, DELETE /api/diary/{id},
/api/diary/restore, /api/activities (add), DELETE /api/activities/{id},
/api/health/sync (B3, Apple Health).
Verdrahtet (shopping:{week}): /api/shopping/generate, PATCH /api/shopping/
items/{id} (inkl. done-Toggle).
⚠️ Eine Lücke (ehrlich): eGYM-/Garmin-Auto-Sync (Cron 06:15/06:25) schreibt
Aktivitäten über upsert_activity in einem Worker-Thread mit eigener Connection
und ruft _la_budget_sync nicht. Auto-Sync-Aktivitäten pushen das Budget-LA
also aktuell nicht (alle manuellen Wege + Apple-Health schon). Wirkung gering
(läuft früh morgens; die App refresht ihr LA beim Foregrounding ohnehin). Wenn dir
das wichtig ist, ist es ein kleiner Folge-Auftrag (Worker müsste betroffene
(user,date) sammeln und nach dem Commit _la_budget_sync feuern) — sag Bescheid,
dann hänge ich's an. Nicht „heimlich" gebaut, weil's Worker-Threading berührt.
3b. Stiller Widget-Push (Homescreen-Widgets, Silent/Background)
Bei jeder Budget-Mutation für HEUTE feuert der Server – zusätzlich zum
la-update-Push (§3) – einen stillen Hintergrund-Push an die kind="device"-
Tokens des betroffenen Users:
- aps: { "content-available": 1 }, apns-push-type: background, Priorität 5
(Pflicht für background), Topic = Bundle, Host = pro Token environment.
- Zweck: die App schreibt auf Empfang den App-Group-Snapshot neu + lädt die
Homescreen-Widgets nach – ohne dass die App geöffnet wird.
- Nur für date == heute (ältere Tage ändern das Widget nicht → schont Apples
Background-Push-Budget).
Client-Seite: registriere das device-Token via /api/apns/register
(kind="device", environment analog zum LA-Token – sandbox in Dev). Diagnose:
GET /api/apns/diag?user=<id> zeigt jetzt zusätzlich device_push = nicht-
prunender Test-Push je device-Token (status/reason wie test_push).
4. Live-Activity Content-States — schon serialisiert, NICHT auf dich wartend
Beide Content-States sind serverseitig fertig und folgen den vereinbarten Verträgen (≤ 4 KB locker eingehalten):
Budget (_budget_activity_state, context budget:{user}:{date}) — exakt nach
deinem BudgetActivityAttributes.ContentState:
{ "kcalLeft": 540, "kcalEaten": 1460, "kcalBudget": 2000, "nextMeal": "Abendessen" }
kcalLeft/kcalEaten/kcalBudget = int, nextMeal = string|null (nächste ungegessene
geplante Mahlzeit des Tages, deutsch beschriftet).
Shopping (_shopping_state, attributes-type ShoppingActivityAttributes,
attributes { "week": "YYYY-MM-DD" }):
{ "open": 7, "done": 12, "total": 19, "estEur": 23.40, "lastItem": "Hähnchenbrust" }
open/done/total = int, estEur = float (Restwert offener Posten), lastItem =
zuletzt abgehakter Posten (string|null).
→ B1.3 wartet nicht auf dich: die Serializer existieren und matchen den
abgesprochenen Vertrag. Falls dein finales ActivityKit-ContentState-Struct in
Phase 6 abweicht (Feldname/Typ), sag mir die Ziel-Shape — ich ziehe den Serializer
nach (Anti-Drift: Server liefert, App rendert).
5. pushToStart + Start-Endpoint (B1.7)
- pushToStart-Token hinterlegen: dasselbe
POST /api/apns/registermitkind="la-pushToStart". - Start auslösen:
POST /api/live-activity/shopping/start?week=YYYY-MM-DD[&user=<id>]→apns.push_to_start_all(ShoppingActivityAttributes, {week}, state). Response:{ "started": <int>, "attributes": {"week": …}, "content_state": {…} }. - Kein Auto-Scheduler: das ist ein expliziter Trigger-Endpoint (z. B. „Einkauf starten"-Button oder ein späterer Cron). Aktuell läuft kein Zeitplan, der das von selbst feuert — du/wir rufen den Endpoint, wenn der Einkaufsmodus starten soll.
- SILV-157:
startist idempotent je(week, deviceToken)(Dedupla_starts) — ein Gerät bekommt je Woche höchstens EINE Karte (keine Doppelkarte bei zweitem Starter).
5a. Auth aus Widget-/Intent-Extensions (SILV-166)
Eine interaktive Live Activity (LiveActivityIntent, Toggle/Button) läuft im
Widget-Extension-Prozess — OHNE den App-Cookie-Jar. Damit sie z. B.
PATCH /api/shopping/items/{id} {done[,by]} authentifiziert absetzen kann (DI-Abhaken
ohne App-Wechsel):
- Die App schreibt das
ss_session-Token beim Login in den App-Group-Shared-Keychain. - Die Extension liest es und sendet es als Header
X-Session-Token: <token>. - Der Server prüft es identisch zum Cookie (
session_user_id: Cookie ODER Header; gegen diesessions-Tabelle, opak, 180 d). Kein neues Secret, kein neuer Token-Typ. ss_sessionist bewusst ein unbound Bearer (keine UA-/Geräte-Bindung, B0.1) → Multi-Prozess-fähig. Der Header wird nie automatisch gesendet → eher CSRF-sicherer als das Cookie.byim Body trägt weiter den Actor; ohneby= der Token-User.
⚠️ Stolperfalle (am Gerät erarbeitet, SILV-164, 11.06.):
session_user_idprüft Cookie ZUERST, Header als Fallback (cookie OR X-Session-Token). Schickt die Extension also versehentlich einen stale Cookie mit (z. B. überURLSession.shared, die einen altenss_session-Cookie aus dem Shared-Container zieht), gewinnt der ungültige Cookie und der gültige Header wird nie geprüft → 401. Regel: Aus Extensions cookie-los senden (ephemereURLSessionohne Cookie-Storage, nur derX-Session-Token-Header). Ein leerer Cookie ist harmlos (fällt auf den Header durch), nur ein nicht-leerer veralteter blockiert.
6. Broadcast-Channel (B1.6, pushType .channel)
- Channel anlegen:
POST /api/channels{ "purpose": "shopping", "environment": "production" }→ APNs-Channel-API (/1/apps/{bundle}/channels), speichert inchannels. Response:{ "created": true, "channel_id": "<base64>", "purpose": "shopping" }. (AktuellBroadcastFeatureNotEnabled, bis die Bundle-Capability an ist — ein Test-Channel wurde angelegt.) - Channel-ID holen (Client):
GET /api/channels→ Liste von{ id, purpose, channel_id, bundle_id, environment, created_at }. Der relevante istpurpose="shopping". Die App abonniert diechannel_idgeräteseitig bei APNs. - Bindung: an
purpose(haushaltsweit, nicht pro User) — ein Einkauf, alle Geräte synchron.UNIQUE(purpose, environment). - Broadcast feuern: automatisch via
_la_shopping_syncbei Shopping-Mutationen, oder manuellPOST /api/live-activity/shopping/broadcast?week=YYYY-MM-DD. - SILV-162/167 — Live-Listen-Reload (Begleiter zum Broadcast):
shopping_add/shopping_patchfeuern zusätzlichapns.send_shopping_reload(week, exclude_user_id=actor)— ein stillercontent-available-Push (push-type=background, prio 5) an alledevice-Tokens außer denen des Auslösers (kein Self-Flacker). Payload:{ "aps": {"content-available": 1}, "reload": "shopping", "week": "…" }. Der Client machtmodel.reload()(Reconcile) → Jaquelines Add/Abhaken erscheint live in der offenen Liste. Best-effort (iOS drosselt Silent-Pushes) → Client-Polling-Fallback.
7. Notification-Center (B1.4, #21) — live
- Lesen:
GET /api/notifications?user=<id>&limit=50→json { "items": [ {row}, … ], "unread": 3 }items= eigene (user_id=) + Broadcast (user_id NULL) , neueste zuerst (id DESC). Pagination = nurlimit(Default 50), kein Cursor/Offset (falls du echtes Paging brauchst: kleiner Zusatz, sag Bescheid). - Als gelesen markieren:
POST /api/notifications/read{ "user_id": 1, "ids": [12,13] }—idsweglassen/null= alle als gelesen. Response{ "ok": true, "unread": <int> }. - Row-Shape (eine Notification):
json { "id": 42, "user_id": 1, // user_id NULL = Broadcast (an alle) — umgesetzt "kind": "ai", // Default 'info' "title": "🤖 Claude fertig", "body": "Foto-Analyse abgeschlossen ✓", "icon": "🤖", // nullable "link": "/", // nullable "dedupe_key": "aijob-99-done", // UNIQUE, nullable -> idempotente Erzeuger "created_at": "2026-06-08 11:40:00", "read_at": null // read_at null = ungelesen } notify(user_id, title, body, kind, icon, link, dedupe_key)ist der zentrale Erzeuger: schreibt die Row und feuert APNs (nativ) + VAPID (Web).user_id=None→ Broadcast.dedupe_keymacht's idempotent (Crons/Worker dürfen mehrfach feuern).
8. Stage-Push aus dem KI-Worker (B1.5)
- Fortschritts-Stufen (
picked/model/gen) meldet der Worker anPOST /api/ai/jobs/{id}/stage— das ist nur der interne Progress-Feed für die Sonar-Pill (kein Push). - Bei Job-Abschluss (
finish_job, status=done) wird automatischnotify(None, "🤖 Claude fertig", "<Label> abgeschlossen ✓", kind="ai", icon="🤖", dedupe_key="aijob-{id}-done")gefeuert → landet im Notification-Center + APNs/VAPID (Broadcast). Also: Abschluss pusht, einzelne Stages nicht;dedupe_keyverhindert Doppel.
Soll die 30-…-Liste aktualisiert werden?
Ja — bitte du (es ist deine Datei in app/ios-native/): B1.1–B1.7 + B1.4 auf
„geliefert" setzen, mit Verweis auf diesen Vertrag. Ich fasse 30-… nicht an
(Revier). Wenn du die eGYM/Garmin-LA-Lücke (siehe §3) geschlossen haben willst,
notier sie als kleinen Punkt — dann liefere ich.