# Push Notifications Design ## Overview Add push notification support across three repos: hatk gains an `on-commit` hook primitive and push delivery infrastructure, grain wires notification events to push, and grain-native registers for and handles APNs. ## hatk: `on-commit` Hook Primitive A new hook trigger type that fires after the firehose indexer writes a record to the database. Follows the existing `defineHook(event, handler)` pattern with an options object for declarative collection filtering. ```ts // server/hooks/on-commit-favorite.ts export default defineHook( "on-commit", { collections: ["social.grain.favorite"] }, async ({ action, collection, record, repo, uri, db, lookup, push }) => { // action: "create" | "delete" // collection: NSID that matched // record: the record value // repo: DID of the committing actor // uri: AT URI of the record // db: { query, run } — raw SQL access // lookup: typed record lookup (same as BaseContext) // push: push delivery interface }, ); ``` The firehose indexer already processes commits in `flushBuffer()`. After `insertRecord` / `deleteRecord`, it invokes matching `on-commit` hooks filtered by `collections`. Multiple hooks can match the same commit. Hooks run async and non-blocking (same pattern as `runLabelRules`). ## hatk: Push Token Registration & Storage ### Database ```sql CREATE TABLE _push_tokens ( did TEXT NOT NULL, token TEXT NOT NULL, platform TEXT NOT NULL, -- "apns" for now, "fcm"/"web" later createdAt TEXT NOT NULL, PRIMARY KEY (did, token) ); ``` ### Built-in XRPC Endpoints - `social.hatk.push.registerToken` — accepts `{ token, platform }`, associates with authenticated viewer's DID. Handles duplicates via upsert. - `social.hatk.push.unregisterToken` — accepts `{ token }`, removes it. ### Configuration ```ts // hatk.config.ts push: { apns: { keyFile: "./certs/AuthKey_XXXX.p8", keyId: "XXXX", teamId: "YYYY", bundleId: "com.grain.app", } } ``` Push features are disabled entirely if no `push` config is present. ## hatk: `push.send()` Delivery The `push` object is injected into hook handler context: ```ts await push.send({ did: string, // recipient's DID title: string, // notification title body: string, // notification body data?: Record, // custom payload (e.g. { uri, type }) collapseId?: string, // APNs collapse-id for grouping }) ``` Internals: - Looks up all tokens for the DID in `_push_tokens` - Builds APNs payload, sends via HTTP/2 to Apple's push gateway - If Apple responds with invalid/expired token, deletes it from `_push_tokens` (self-cleaning) - Fire-and-forget — delivery failures are logged via `emit()` but don't throw - No queuing for v1. Hooks run async after flush (like label rules). A queue can be added behind the same API later. ## grain: Hook Implementations One `on-commit` hook file per notification trigger: ``` server/hooks/ on-login.ts (existing) on-commit-favorite.ts (new) on-commit-comment.ts (new) on-commit-follow.ts (new) ``` ### Example: Favorites ```ts export default defineHook( "on-commit", { collections: ["social.grain.favorite"] }, async ({ action, record, repo, db, lookup, push }) => { if (action !== "create") return; const [gallery] = await db.query( `SELECT did AS author, uri FROM "social.grain.gallery" WHERE uri = $1`, [record.subject], ); if (!gallery || gallery.author === repo) return; const profiles = await lookup("app.bsky.actor.profile", "did", [repo]); const actor = profiles.get(repo); await push.send({ did: gallery.author, title: "New favorite", body: `${actor?.value.displayName ?? "Someone"} favorited your gallery`, data: { type: "gallery-favorite", uri: gallery.uri }, }); }, ); ``` Comments and follows follow the same pattern. Mentions and replies parse facets or look up parent comment authors but use the same shape. The existing `getNotifications` query is unchanged — it remains the source of truth for the notification list. Push alerts the user to come look. ## grain-native: APNs Registration & Handling ### Token Registration Flow 1. On app launch (after auth), call `UNUserNotificationCenter.requestAuthorization()` 2. On grant, call `UIApplication.shared.registerForRemoteNotifications()` 3. In `AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken`, send the hex-encoded token to `social.hatk.push.registerToken` via XRPC client 4. Re-register on every app launch (token can change; server upserts) ### Handling Incoming Pushes - Tap on a notification reads `data.type` and `data.uri` from the payload - Routes to the appropriate view: gallery detail for favorites/comments, profile for follows ### Unregistration - On logout, call `social.hatk.push.unregisterToken` to remove the token server-side ### Entitlements - Add Push Notifications capability in Xcode - Add `aps-environment` entitlement (development/production) - Register bundle ID with APNs in Apple Developer portal ## What's NOT in v1 - No FCM / web push (table has `platform` column for future use) - No queuing / retry system (async inline delivery, failures logged) - No notification preferences / muting (all types push to all registered devices) - No rich notifications (images, actions) — title + body + data only