diff --git a/docs/push-notifications.md b/docs/push-notifications.md index f4c7f52c..2f6b696e 100644 --- a/docs/push-notifications.md +++ b/docs/push-notifications.md @@ -179,7 +179,7 @@ notifier the owner can uninstall is one those producers cannot rely on. ``` producer (queue / email / agent / platform / plugin) - │ POST /_officer/notify { c, userId, n?, id? } + │ POST /_officer/notify { code, userId, n?, id? } ▼ officer-notify ─┬─ apns → api.push.apple.com (HTTP/2, ES256 JWT) ├─ fcm → fcm.googleapis.com/v1 (OAuth2 bearer) @@ -246,10 +246,14 @@ tokens rotate. - **`bundle_id` / `app_slug` on a DEVICE go near-constant.** The columns exist to let several apps register separately; with a hub there is one bundle id per device. Keep them — a second receiver later (a watch, a desktop build) costs nothing this way — but "which apps has this user got" stops being a delivery question. -- **`NotifyType` is replaced by codes.** The `'job' | 'mail' | 'agent' | 'download' | 'test'` enum was the - rigidity this design exists to remove. `test` should survive as a reserved code for first light. -- **`text.ts` moves to the device.** Server-side `renderTitle`/`renderBody` becomes the mapping. Keep a - minimal server-side generic for the `alert` fallback. +- ~~**`NotifyType` is replaced by codes.**~~ Done. The producer contract is now + `POST /_officer/notify { code, userId, n?, id? }`, validated on the SHAPE of the code + (`/:`) and deliberately **not** against the catalogue — a producer firing a + code the catalogue has not caught up with is sending a real event, and the device already has a defined + fallback. `ok` is gone: a failed job is its own code (`officer/core:job-failed`), which is what made the + enum rigid in the first place. +- ~~**`text.ts` moves to the device.**~~ Done — replaced by `render.ts`, which renders server-side only for + Discord (no app to resolve a code) and for the `alert` fallback string. ## Failure handling diff --git a/src/servers/sidecar/notify/apns-payload.ts b/src/servers/sidecar/notify/apns-payload.ts index 70e9ed83..17e627f3 100644 --- a/src/servers/sidecar/notify/apns-payload.ts +++ b/src/servers/sidecar/notify/apns-payload.ts @@ -1,45 +1,59 @@ +import type { Catalogue } from '../../notifications/catalogue'; import type { Notification } from './types'; -import { renderTitle, renderBody } from './text'; +import { FALLBACK_TITLE, threadFor } from './render'; // The APNs payload. // -// `aps` is Apple's reserved key and controls what the system does; everything beside it is ours and -// arrives in the app untouched. The doorbell rule lives in what we put in each: the alert carries only a -// generic title composed from the category, and the custom keys carry ids the app uses to FETCH the real -// content over the tailnet. Nothing here should ever be readable as the thing itself. +// `aps` is Apple's reserved key and controls what the SYSTEM does; everything beside it is ours and +// arrives in the app untouched. The split is the whole design: +// +// - `aps.alert` is what iOS draws if nothing else happens. It is a fixed generic, because it is the one +// part Apple can read. +// - `officer` carries the code, which the Notification Service Extension resolves against its synced +// catalogue and uses to REWRITE the banner before it is shown. +// +// `mutable-content: 1` is what permits that rewrite. Without it the extension never runs and the user +// sees `alert.title` — which is why that string has to be a graceful generic and not a decoy sentence: +// it is the degraded case, and it should read as a real notification carrying no detail rather than as +// something misleading. export type ApnsPayload = { aps: { - alert: { title: string; body?: string }; + alert: { title: string }; sound: string; + /** Lets the extension replace the banner. Without it, `alert` is final. */ + 'mutable-content': 1; /** Drives the red badge. Only meaningful where a count is, so it is set only then. */ badge?: number; /** Lets the app group/replace banners rather than stacking one per event. */ 'thread-id'?: string; }; - /** Ours. The app routes on these; they are ids, never content. */ + /** Ours. Ids and integers, never content. */ officer: { - type: string; + c: string; + n?: number; id?: string | number; - ok?: boolean; + /** Catalogue version this was rendered against; the device resyncs when it differs from its own. */ + mv: number; }; }; -export function buildApnsPayload(n: Notification): ApnsPayload { - const body = renderBody(n); - +export function buildApnsPayload(n: Notification, catalogue: Catalogue): ApnsPayload { return { aps: { - alert: body ? { title: renderTitle(n), body } : { title: renderTitle(n) }, + // Deliberately NOT the catalogue's title: sending the real text would hand Apple the category + // vocabulary in plaintext, which is exactly what the code exists to withhold. + alert: { title: FALLBACK_TITLE }, sound: 'default', - ...(typeof n.count === 'number' ? { badge: n.count } : {}), - // One thread per category: three job notifications collapse instead of filling the shade. - 'thread-id': n.type, + 'mutable-content': 1, + ...(typeof n.n === 'number' ? { badge: n.n } : {}), + 'thread-id': threadFor(n, catalogue), }, officer: { - type: n.type, + c: n.code, + ...(n.n !== undefined ? { n: n.n } : {}), ...(n.id !== undefined ? { id: n.id } : {}), - ...(n.ok !== undefined ? { ok: n.ok } : {}), + mv: catalogue.version, }, }; } @@ -47,8 +61,8 @@ export function buildApnsPayload(n: Notification): ApnsPayload { /** * `apns-collapse-id` — Apple replaces an undelivered notification with the same id rather than queueing * both. Right for "3 new emails" superseding "1 new email"; wrong for jobs, where each completion is its - * own event, so those collapse per id instead of per category. + * own event, so those collapse per id instead of per code. */ export function collapseId(n: Notification): string { - return n.id !== undefined ? `${n.type}-${n.id}` : n.type; + return n.id !== undefined ? `${n.code}-${n.id}` : n.code; } diff --git a/src/servers/sidecar/notify/apns.test.ts b/src/servers/sidecar/notify/apns.test.ts index 41b5d3b9..b900bfe1 100644 --- a/src/servers/sidecar/notify/apns.test.ts +++ b/src/servers/sidecar/notify/apns.test.ts @@ -2,8 +2,9 @@ import { describe, test, expect } from 'bun:test'; import { generateKeyPairSync, createVerify } from 'node:crypto'; import { ApnsAuthToken } from './apns-jwt'; import { buildApnsPayload, collapseId } from './apns-payload'; -import { renderTitle } from './text'; +import { renderNotification, FALLBACK_TITLE } from './render'; import type { Notification } from './types'; +import type { Catalogue } from '../../notifications/catalogue'; // What can be proven without Apple: the token is a well-formed ES256 JWT with the encoding Apple // actually requires, and the payload never carries content. @@ -51,61 +52,78 @@ describe('APNs provider token', () => { }); describe('APNs payload', () => { - const mail: Notification = { type: 'mail', userId: 1, count: 3 }; + const catalogue: Catalogue = { + version: 99, + entries: { + 'officer/core:mail': { title: '{n} new emails', thread: 'officer/core:mail' }, + 'officer/core:job': { title: 'Job finished', thread: 'officer/core:job' }, + }, + }; + const mail: Notification = { code: 'officer/core:mail', userId: 1, n: 3 }; - test('carries a generic title and no content', () => { - const p = buildApnsPayload(mail); - expect(p.aps.alert.title).toBe('3 new emails'); + test('the alert Apple can read is a fixed generic, and the code rides beside it', () => { + const p = buildApnsPayload(mail, catalogue); + // NOT '3 new emails' — sending the catalogue's title would hand Apple the category vocabulary in + // plaintext, which is the whole thing the code exists to withhold. + expect(p.aps.alert.title).toBe(FALLBACK_TITLE); expect(p.aps.badge).toBe(3); - expect(p.officer).toEqual({ type: 'mail' }); + expect(p.officer).toEqual({ c: 'officer/core:mail', n: 3, mv: 99 }); }); - // The doorbell rule, asserted rather than trusted: whatever a producer sends, nothing resembling + test('mutable-content is set, or the extension never runs and the generic is final', () => { + expect(buildApnsPayload(mail, catalogue).aps['mutable-content']).toBe(1); + }); + + test('the catalogue version travels, so the device knows when to resync', () => { + expect(buildApnsPayload(mail, { ...catalogue, version: 12345 }).officer.mv).toBe(12345); + }); + + // The payload rule, asserted rather than trusted: whatever a producer sends, nothing resembling // content may appear in the serialized payload that leaves for Apple. test('a producer cannot smuggle content through', () => { const sneaky = { - type: 'mail', + code: 'officer/core:mail', userId: 1, - count: 1, + n: 1, // none of these are part of Notification, but a caller in JS could still pass them subject: 'Re: your invoice', from: 'accounts@example.com', body: 'please pay', } as unknown as Notification; - const json = JSON.stringify(buildApnsPayload(sneaky)); + const json = JSON.stringify(buildApnsPayload(sneaky, catalogue)); expect(json).not.toContain('invoice'); expect(json).not.toContain('example.com'); expect(json).not.toContain('please pay'); }); + test('an unknown code still sends, and threads by itself', () => { + // The mapping has not caught up. The event is real, so it goes — the device renders its own + // unknown-code fallback rather than the user losing the notification. + const p = buildApnsPayload({ code: 'officerdev/music:12.3', userId: 1 }, catalogue); + expect(p.officer.c).toBe('officerdev/music:12.3'); + expect(p.aps['thread-id']).toBe('officerdev/music:12.3'); + }); + test('ids travel, so the app can fetch the real thing', () => { - const p = buildApnsPayload({ type: 'job', userId: 1, id: 412, ok: false }); - expect(p.officer).toEqual({ type: 'job', id: 412, ok: false }); - expect(p.aps.alert.title).toBe('Job failed'); + const p = buildApnsPayload({ code: 'officer/core:job', userId: 1, id: 412 }, catalogue); + expect(p.officer).toEqual({ c: 'officer/core:job', id: 412, mv: 99 }); expect(p.aps.badge).toBeUndefined(); }); - test('threads by category so banners group instead of stacking', () => { - expect(buildApnsPayload(mail).aps['thread-id']).toBe('mail'); + test('threads by the catalogue entry so banners group instead of stacking', () => { + expect(buildApnsPayload(mail, catalogue).aps['thread-id']).toBe('officer/core:mail'); }); // Collapse replaces an undelivered notification. Right for mail (one count supersedes the last), // wrong for jobs, where each completion is its own event. - test('collapses mail by category and jobs by id', () => { - expect(collapseId(mail)).toBe('mail'); - expect(collapseId({ type: 'job', userId: 1, id: 412 })).toBe('job-412'); - }); -}); - -describe('rendered text', () => { - test('singular and plural mail read correctly', () => { - expect(renderTitle({ type: 'mail', userId: 1, count: 1 })).toBe('New email'); - expect(renderTitle({ type: 'mail', userId: 1, count: 5 })).toBe('5 new emails'); + test('collapses by code, and by id where there is one', () => { + expect(collapseId(mail)).toBe('officer/core:mail'); + expect(collapseId({ code: 'officer/core:job', userId: 1, id: 412 })).toBe('officer/core:job-412'); }); - test('job wording follows ok', () => { - expect(renderTitle({ type: 'job', userId: 1, ok: true })).toBe('Job finished'); - expect(renderTitle({ type: 'job', userId: 1, ok: false })).toBe('Job failed'); + test('server-side rendering interpolates, and falls back on an unknown code', () => { + expect(renderNotification(mail, catalogue).title).toBe('3 new emails'); + expect(renderNotification({ code: 'nope/nope:x', userId: 1 }, catalogue).title).toBe(FALLBACK_TITLE); }); }); diff --git a/src/servers/sidecar/notify/apns.ts b/src/servers/sidecar/notify/apns.ts index bb98bb6e..d44faa54 100644 --- a/src/servers/sidecar/notify/apns.ts +++ b/src/servers/sidecar/notify/apns.ts @@ -1,6 +1,6 @@ import http2 from 'node:http2'; import { getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from 'officerdb'; -import type { Channel, DeliveryResult, Notification } from './types'; +import type { Channel, DeliveryResult, Notification, SendContext } from './types'; import { ApnsAuthToken } from './apns-jwt'; import { buildApnsPayload, collapseId } from './apns-payload'; @@ -52,9 +52,15 @@ function session(host: string): http2.ClientHttp2Session { type SendOutcome = { ok: boolean; dead: boolean; reason?: string }; -function sendOne(host: string, token: string, bundleId: string, n: Notification): Promise { +function sendOne( + host: string, + token: string, + bundleId: string, + n: Notification, + ctx: SendContext, +): Promise { return new Promise((resolve) => { - const body = Buffer.from(JSON.stringify(buildApnsPayload(n))); + const body = Buffer.from(JSON.stringify(buildApnsPayload(n, ctx.catalogue))); const req = session(host).request({ ':method': 'POST', @@ -106,10 +112,10 @@ export const apnsChannel: Channel = { isConfigured: () => auth !== null, - async send(n: Notification): Promise { + async send(n: Notification, ctx: SendContext): Promise { if (!auth) return { channel: 'apns', sent: 0, failed: 0 }; - const devices = (await getPushDevices(n.userId, n.appSlug)).filter((d) => d.platform === 'ios'); + const devices = (await getPushDevices(n.userId)).filter((d) => d.platform === 'ios'); if (devices.length === 0) return { channel: 'apns', sent: 0, failed: 0 }; const dead: string[] = []; @@ -119,7 +125,7 @@ export const apnsChannel: Channel = { await Promise.all( devices.map(async (d) => { const host = HOSTS[d.environment as keyof typeof HOSTS] ?? HOSTS.production; - const outcome = await sendOne(host, d.token, d.bundleId, n); + const outcome = await sendOne(host, d.token, d.bundleId, n, ctx); if (outcome.ok) { sent++; diff --git a/src/servers/sidecar/notify/discord.ts b/src/servers/sidecar/notify/discord.ts index d34bd234..5e0bb496 100644 --- a/src/servers/sidecar/notify/discord.ts +++ b/src/servers/sidecar/notify/discord.ts @@ -1,5 +1,5 @@ -import type { Channel, Notification } from './types'; -import { renderTitle } from './text'; +import type { Channel, Notification, SendContext } from './types'; +import { renderNotification } from './render'; // Discord notifications, via a channel webhook. // @@ -22,11 +22,15 @@ export const discordChannel: Channel = { isConfigured: () => Boolean(WEBHOOK_URL), - async send(n: Notification) { + async send(n: Notification, ctx: SendContext) { if (!WEBHOOK_URL) return { channel: 'discord', sent: 0, failed: 0 }; + // Rendered HERE, unlike the push channels: Discord has no app to resolve a code, so the server is + // the last stop. The catalogue's titles are templates and carry no user content, so this leaks + // nothing the code itself would not. + const r = renderNotification(n, ctx.catalogue); // Discord rejects anything over 2000 characters outright. - const content = renderTitle(n).slice(0, 2000); + const content = [r.title, r.body].filter(Boolean).join(' — ').slice(0, 2000); try { const res = await fetch(WEBHOOK_URL, { diff --git a/src/servers/sidecar/notify/dispatch.ts b/src/servers/sidecar/notify/dispatch.ts index b4f6bc5a..6f74cce1 100644 --- a/src/servers/sidecar/notify/dispatch.ts +++ b/src/servers/sidecar/notify/dispatch.ts @@ -1,4 +1,5 @@ import type { Channel, DeliveryResult, Notification } from './types'; +import { loadCatalogue } from '../../notifications/catalogue'; import { discordChannel } from './discord'; import { apnsChannel } from './apns'; import { fcmChannel } from './fcm'; @@ -24,10 +25,15 @@ export async function dispatch(n: Notification): Promise { const active = channels.filter((c) => c.isConfigured()); if (active.length === 0) return []; + // Read ONCE, here — not per channel and not per device. `version` travels with the push as `mv`, and + // every copy of one notification must carry the same one; two channels reading disk either side of a + // plugin install would otherwise tell two devices different things about the same event. + const ctx = { catalogue: loadCatalogue() }; + return Promise.all( active.map(async (c) => { try { - return await c.send(n); + return await c.send(n, ctx); } catch (err) { // A channel that throws instead of returning is a bug in that channel, not a reason to fail // the notification or the producer behind it. diff --git a/src/servers/sidecar/notify/fcm.test.ts b/src/servers/sidecar/notify/fcm.test.ts index 9d028d9f..4f5d325e 100644 --- a/src/servers/sidecar/notify/fcm.test.ts +++ b/src/servers/sidecar/notify/fcm.test.ts @@ -3,6 +3,7 @@ import { generateKeyPairSync, createVerify } from 'node:crypto'; import { FcmAccessToken, parseServiceAccount } from './fcm-auth'; import { __testing } from './fcm'; import type { Notification } from './types'; +import type { Catalogue } from '../../notifications/catalogue'; // What can be proven without Google: the assertion is a well-formed RS256 JWT with the claims the token // endpoint requires, and the message body matches FCM v1's shape — including the string-only `data` rule @@ -51,44 +52,55 @@ describe('OAuth2 assertion', () => { describe('FCM message', () => { const build = __testing.buildMessage; + const catalogue: Catalogue = { version: 77, entries: { 'officer/core:mail': { title: '{n} new emails' } } }; + const ctx = { catalogue }; // The rule that breaks first integrations: FCM rejects a non-string in `data` with a bare // INVALID_ARGUMENT that says nothing about which field. - test('every data value is a string, including numbers and booleans', () => { - const msg = build('tok', { type: 'job', userId: 1, id: 412, ok: false, count: 2 }); + test('every data value is a string, including numbers', () => { + const msg = build('tok', { code: 'officer/core:job', userId: 1, id: 412, n: 2 }, ctx); for (const [key, value] of Object.entries(msg.message.data)) { expect(typeof value, `data.${key}`).toBe('string'); } - expect(msg.message.data).toEqual({ type: 'job', id: '412', ok: 'false', count: '2' }); + expect(msg.message.data).toEqual({ c: 'officer/core:job', mv: '77', id: '412', n: '2' }); }); test('omits keys that were not provided, rather than sending "undefined"', () => { - const msg = build('tok', { type: 'mail', userId: 1, count: 3 }); - expect(msg.message.data).toEqual({ type: 'mail', count: '3' }); + const msg = build('tok', { code: 'officer/core:mail', userId: 1, n: 3 }, ctx); + expect(msg.message.data).toEqual({ c: 'officer/core:mail', mv: '77', n: '3' }); expect(Object.keys(msg.message.data)).not.toContain('id'); }); - test('title is the generic rendered one and the token is set', () => { - const msg = build('device-token', { type: 'mail', userId: 1, count: 3 }); + // DATA-ONLY is the whole Android half of the design. A `notification` block would be rendered by the + // system, putting the code on the lock screen with no chance for the app to resolve it. + test('sends no notification block, so the app composes the banner itself', () => { + const msg = build('device-token', { code: 'officer/core:mail', userId: 1, n: 3 }, ctx); expect(msg.message.token).toBe('device-token'); - expect(msg.message.notification.title).toBe('3 new emails'); + expect('notification' in msg.message).toBe(false); }); - test('tags by category so banners group instead of stacking', () => { - expect(build('t', { type: 'mail', userId: 1 }).message.android.notification.tag).toBe('mail'); + // Load-bearing: a normal-priority data message can be deferred indefinitely in Doze, and a + // notification that arrives tomorrow is not a notification. + test('is high priority', () => { + expect(build('t', { code: 'officer/core:mail', userId: 1 }, ctx).message.android.priority).toBe('high'); }); - // The same doorbell guarantee asserted for APNs, on the other channel. + test('the catalogue version travels, so the device knows when to resync', () => { + const msg = build('t', { code: 'officer/core:mail', userId: 1 }, { catalogue: { ...catalogue, version: 5 } }); + expect(msg.message.data.mv).toBe('5'); + }); + + // The same payload guarantee asserted for APNs, on the other channel. test('a producer cannot smuggle content through', () => { const sneaky = { - type: 'mail', + code: 'officer/core:mail', userId: 1, - count: 1, + n: 1, subject: 'Re: your invoice', from: 'accounts@example.com', } as unknown as Notification; - const json = JSON.stringify(build('tok', sneaky)); + const json = JSON.stringify(build('tok', sneaky, ctx)); expect(json).not.toContain('invoice'); expect(json).not.toContain('example.com'); }); diff --git a/src/servers/sidecar/notify/fcm.ts b/src/servers/sidecar/notify/fcm.ts index 2f381552..998533fb 100644 --- a/src/servers/sidecar/notify/fcm.ts +++ b/src/servers/sidecar/notify/fcm.ts @@ -1,7 +1,6 @@ import { getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from 'officerdb'; -import type { Channel, DeliveryResult, Notification } from './types'; +import type { Channel, DeliveryResult, Notification, SendContext } from './types'; import { FcmAccessToken, parseServiceAccount } from './fcm-auth'; -import { renderTitle, renderBody } from './text'; // The Google channel. FCM HTTP v1, called directly — no Expo, no firebase-admin. // @@ -24,50 +23,47 @@ const auth = raw ? new FcmAccessToken(parseServiceAccount(raw)) : null; type FcmMessage = { message: { token: string; - notification: { title: string; body?: string }; + /** + * DATA-ONLY — deliberately no `notification` block. + * + * A `notification` block is rendered by the system, which would put the code on the lock screen and + * give the app no chance to resolve it. Data-only hands the message to the app, which composes the + * banner from its synced catalogue — the same thing the Notification Service Extension does on iOS, + * except Android needs no extension for it. + * + * The cost is that `priority: high` is load-bearing: a normal-priority data message can be deferred + * indefinitely in Doze, and a notification that arrives tomorrow is not a notification. + */ data: Record; android: { priority: 'high'; - notification: { - /** Groups banners the way APNs' thread-id does. */ - tag: string; - /** Replaces an existing banner with the same tag rather than stacking. */ - notification_priority: 'PRIORITY_HIGH'; - }; }; }; }; -function buildMessage(token: string, n: Notification): FcmMessage { - const body = renderBody(n); - - // Every value a string — see the note at the top. The app parses `id` and `ok` back. - const data: Record = { type: n.type }; +function buildMessage(token: string, n: Notification, ctx: SendContext): FcmMessage { + // Every value a string — FCM's data map takes nothing else. The app parses them back. + const data: Record = { c: n.code, mv: String(ctx.catalogue.version) }; if (n.id !== undefined) data.id = String(n.id); - if (n.ok !== undefined) data.ok = String(n.ok); - if (n.count !== undefined) data.count = String(n.count); + if (n.n !== undefined) data.n = String(n.n); - return { - message: { - token, - notification: body ? { title: renderTitle(n), body } : { title: renderTitle(n) }, - data, - android: { - priority: 'high', - notification: { tag: n.type, notification_priority: 'PRIORITY_HIGH' }, - }, - }, - }; + return { message: { token, data, android: { priority: 'high' } } }; } type SendOutcome = { ok: boolean; dead: boolean; reason?: string }; -async function sendOne(accessToken: string, projectId: string, token: string, n: Notification): Promise { +async function sendOne( + accessToken: string, + projectId: string, + token: string, + n: Notification, + ctx: SendContext, +): Promise { try { const res = await fetch(ENDPOINT(projectId), { method: 'POST', headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, - body: JSON.stringify(buildMessage(token, n)), + body: JSON.stringify(buildMessage(token, n, ctx)), signal: AbortSignal.timeout(10_000), }); @@ -93,10 +89,10 @@ export const fcmChannel: Channel = { isConfigured: () => auth !== null, - async send(n: Notification): Promise { + async send(n: Notification, ctx: SendContext): Promise { if (!auth) return { channel: 'fcm', sent: 0, failed: 0 }; - const devices = (await getPushDevices(n.userId, n.appSlug)).filter((d) => d.platform === 'android'); + const devices = (await getPushDevices(n.userId)).filter((d) => d.platform === 'android'); if (devices.length === 0) return { channel: 'fcm', sent: 0, failed: 0 }; let accessToken: string; @@ -114,7 +110,7 @@ export const fcmChannel: Channel = { await Promise.all( devices.map(async (d) => { - const outcome = await sendOne(accessToken, auth.projectId, d.token, n); + const outcome = await sendOne(accessToken, auth.projectId, d.token, n, ctx); if (outcome.ok) { sent++; diff --git a/src/servers/sidecar/notify/index.ts b/src/servers/sidecar/notify/index.ts index cf10a8b6..e0acf095 100644 --- a/src/servers/sidecar/notify/index.ts +++ b/src/servers/sidecar/notify/index.ts @@ -5,7 +5,7 @@ import { handleDeviceRoute } from './devices'; import { loadCatalogue } from '../../notifications/catalogue'; import { resolveNotifyUser } from './resolve-user'; import { closeApnsSessions } from './apns'; -import type { Notification, NotifyType } from './types'; +import type { Notification } from './types'; import { API_URL } from '../../officer-url.mjs'; // The officer-notify sidecar. The one place anything leaves this machine to tell the owner something. @@ -19,17 +19,26 @@ import { API_URL } from '../../officer-url.mjs'; // HTTP CONTRACT — the platform strips its /api/notify mount prefix before forwarding. // // GET /_health liveness, plus which channels are configured -// POST /_officer/notify send one. Body: { type, userId, id?, count?, ok?, appSlug? } +// POST /_officer/notify send one. Body: { code, userId, n?, id? } // POST /_officer/devices register/refresh a device (idempotent; call every launch) // DELETE /_officer/devices/:token deregister, on sign-out // GET /_officer/devices the caller's registered devices // GET /mapping the code-to-text catalogue the device caches // -// The notify body is deliberately narrow: a category and an id, never content. See ./text.ts for why -// the visible string is composed here rather than sent by the producer. +// The notify body is deliberately narrow: a code and some integers, never content. What the code MEANS +// lives in the catalogue and is rendered on the DEVICE — see ./render.ts for the two cases where the +// server still has to render it itself. // ───────────────────────────────────────────────────────────────────────────────────────────────── -const VALID_TYPES: NotifyType[] = ['job', 'mail', 'agent', 'download', 'test']; +/** + * `/:` — the shape, not a list of known codes. + * + * Deliberately NOT validated against the catalogue. A producer firing a code the catalogue has not caught + * up with is sending a real event, and dropping it here would trade a degraded notification for none — + * the device already has a defined fallback for exactly this. The shape is checked so a typo fails loudly + * at the producer instead of arriving on a phone as an unresolvable code. + */ +const CODE_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._-]*$/; /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { @@ -72,8 +81,11 @@ const server = Bun.serve({ return Response.json({ error: 'invalid json' }, { status: 400 }); } - if (!body.type || !VALID_TYPES.includes(body.type)) { - return Response.json({ error: `type must be one of ${VALID_TYPES.join(', ')}` }, { status: 400 }); + if (typeof body.code !== 'string' || !CODE_SHAPE.test(body.code)) { + return Response.json( + { error: 'code must look like /:, e.g. officer/core:job' }, + { status: 400 }, + ); } // The header wins over the body wherever it is present — see ./resolve-user.ts for why that // ordering is the whole access control on this route. diff --git a/src/servers/sidecar/notify/render.ts b/src/servers/sidecar/notify/render.ts new file mode 100644 index 00000000..48026569 --- /dev/null +++ b/src/servers/sidecar/notify/render.ts @@ -0,0 +1,48 @@ +import type { Catalogue } from '../../notifications/catalogue'; +import type { Notification } from './types'; + +// Rendering a code into words, SERVER-SIDE. +// +// This is not what iOS and Android see. They get the code and render it themselves from their synced copy +// of the catalogue — that is the entire point of the design. This exists for the two cases where the +// server is the last stop: +// +// - Discord, which has no app to render anything. +// - The APNs/FCM fallback string, shown only when the device's own rendering does not run. +// +// Replaced text.ts, which held a hardcoded switch over a five-value enum. That enum was the rigidity the +// catalogue exists to remove: adding a notification meant editing a server file AND shipping an app. + +/** The generic shown when a device cannot render for itself. Never a decoy sentence — see the doc. */ +export const FALLBACK_TITLE = 'Officer'; + +const interpolate = (template: string, n: Notification): string => + template.replace(/\{(n|id)\}/g, (_, key: 'n' | 'id') => { + const value = key === 'n' ? n.n : n.id; + return value === undefined ? '' : String(value); + }); + +export type Rendered = { title: string; body?: string; thread: string }; + +/** + * Render one notification against the catalogue. + * + * An unknown code renders as the generic rather than throwing or being dropped: the event is real even + * when the mapping has not caught up, and the device has its own defined fallback for the same case. + */ +export function renderNotification(n: Notification, catalogue: Catalogue): Rendered { + const entry = catalogue.entries[n.code]; + if (!entry) return { title: FALLBACK_TITLE, thread: n.code }; + + const body = entry.body ? interpolate(entry.body, n) : undefined; + return { + title: interpolate(entry.title, n), + ...(body ? { body } : {}), + thread: entry.thread ?? n.code, + }; +} + +/** The grouping key for a code, whether or not the catalogue knows it. */ +export function threadFor(n: Notification, catalogue: Catalogue): string { + return catalogue.entries[n.code]?.thread ?? n.code; +} diff --git a/src/servers/sidecar/notify/text.ts b/src/servers/sidecar/notify/text.ts deleted file mode 100644 index 631a198b..00000000 --- a/src/servers/sidecar/notify/text.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Notification } from './types'; - -// The visible text, composed HERE from the category — never sent by a producer and never carrying -// content. This is what stops "Re: your invoice from Acme" reaching Apple or Google. -// -// It has to exist somewhere: APNs and FCM both need a title/body to render a banner, so "send nothing" -// is not an option. What we control is that the string is generic and derived from `type` alone, so the -// most either service learns is the category and the timing. The app can render its own copy from the -// same data payload if it wants better wording; this is the fallback that goes over the wire. - -export function renderTitle(n: Notification): string { - switch (n.type) { - case 'job': - return n.ok === false ? 'Job failed' : 'Job finished'; - case 'mail': - return n.count && n.count > 1 ? `${n.count} new emails` : 'New email'; - case 'agent': - return 'Agent finished'; - case 'download': - return 'Download complete'; - case 'test': - return 'Officer'; - } -} - -/** - * Deliberately empty for most categories. A banner with a title and no body is the least we can send - * while still being a usable notification, and every word added is a word Apple and Google get to read. - */ -export function renderBody(n: Notification): string { - return n.type === 'test' ? 'Test notification' : ''; -} diff --git a/src/servers/sidecar/notify/types.ts b/src/servers/sidecar/notify/types.ts index e59b5517..55f8134a 100644 --- a/src/servers/sidecar/notify/types.ts +++ b/src/servers/sidecar/notify/types.ts @@ -1,28 +1,40 @@ +import type { Catalogue } from '../../notifications/catalogue'; + // The one shape every producer sends and every channel receives. // -// A notification is a DOORBELL, not a message. Apple and Google can read anything we hand them, so the -// payload carries a category and an id — never a subject line, a sender, a filename or an error string. -// The device composes the visible text from `type` and fetches the real content over the tailnet when -// tapped. See docs/push-notifications.md. - -/** What happened. Deliberately coarse — the categories a device needs to route and render, nothing more. */ -export type NotifyType = 'job' | 'mail' | 'agent' | 'download' | 'test'; +// A notification is a CODE and some integers. What the code MEANS lives in the catalogue +// (`src/servers/notifications/catalogue.ts`), which is synced to the device — so a new kind of +// notification is a server change and never an app build. See docs/push-notifications.md. +// +// Content does not travel. Apple and Google see the code and the numbers, never a subject line, a +// sender, a filename, an error string or a path. Anything readable is fetched over the tailnet by id. export type Notification = { - type: NotifyType; + /** + * Namespaced — `officerdev/music:12.3`, or `officer/core:job` for the platform's own. + * + * A code the catalogue does not know is still sent: the device has a defined fallback for it, and + * refusing here would drop a real event over a mapping that had not caught up yet. + */ + code: string; /** Who to notify. Resolved by ./resolve-user.ts — a proxied header beats anything a body claims. */ userId: number; - /** - * The thing this is about, if there is one — a job id, a session id. The device fetches by it. - * Never a human-readable string. - */ + /** The number the catalogue's template interpolates as `{n}` — a count, never a measurement of content. */ + n?: number; + /** The thing this is about. The device fetches by it; never a human-readable string. */ id?: string | number; - /** How many, where a count is the whole story ("3 new emails"). */ - count?: number; - /** Outcome, where there is one. */ - ok?: boolean; - /** Restrict to one app: `mobile` | `music` | `read-aloud`. Absent = every registered app. */ - appSlug?: string; +}; + +/** What every channel is handed alongside the notification. */ +export type SendContext = { + /** + * Loaded ONCE per dispatch, not per channel and not per device. + * + * `version` travels with the push as `mv`, and every copy of one notification must carry the same one — + * two channels reading disk either side of a plugin install would otherwise disagree about what the + * device should be holding. + */ + catalogue: Catalogue; }; /** What a channel reports back. Never throws — a failed notification must not break its producer. */ @@ -38,5 +50,5 @@ export type Channel = { name: string; /** False when unconfigured — the sidecar skips it rather than erroring. */ isConfigured: () => boolean; - send: (n: Notification) => Promise; + send: (n: Notification, ctx: SendContext) => Promise; };