notify: codes replace the type enum, and android goes data-only

the sidecar now sends what the design describes.

producer contract: { code, userId, n?, id? }. validated on the SHAPE of the
code, never against the catalogue — a producer firing a code the mapping has
not caught up with is sending a real event, and dropping it here would trade a
degraded notification for none when the device already has a defined fallback.
the shape check still catches a typo at the producer instead of on a phone.

`ok` is gone. a failed job is its own code, officer/core:job-failed, which is
the thing the five-value enum could not express without a boolean bolted on.

apns: mutable-content: 1, alert.title fixed to the generic, and officer:{c,n,id,mv}
beside it. deliberately NOT the catalogue's title — sending the real text would
hand apple the category vocabulary in plaintext, which is what the code exists
to withhold.

fcm: data-only, 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. priority high is load-bearing — a normal-priority data message
can sit in doze indefinitely.

discord renders server-side from the catalogue, because it has no app to
resolve anything. safe: catalogue titles are templates and carry no user
content.

the catalogue is loaded ONCE per dispatch, not per channel or per device, so
every copy of one notification carries the same mv. two channels reading disk
either side of a plugin install would otherwise tell two devices different
things about the same event.

40 tests. verified live: a malformed code is refused, officer/core:test is
accepted and dispatches to zero configured channels.
This commit is contained in:
2026-08-19 19:37:05 +00:00
parent 55d6eba511
commit e598a5dcba
12 changed files with 268 additions and 168 deletions
+34 -20
View File
@@ -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;
}
+46 -28
View File
@@ -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);
});
});
+12 -6
View File
@@ -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<SendOutcome> {
function sendOne(
host: string,
token: string,
bundleId: string,
n: Notification,
ctx: SendContext,
): Promise<SendOutcome> {
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<DeliveryResult> {
async send(n: Notification, ctx: SendContext): Promise<DeliveryResult> {
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++;
+8 -4
View File
@@ -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, {
+7 -1
View File
@@ -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<DeliveryResult[]> {
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.
+26 -14
View File
@@ -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');
});
+28 -32
View File
@@ -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<string, string>;
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<string, string> = { 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<string, string> = { 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<SendOutcome> {
async function sendOne(
accessToken: string,
projectId: string,
token: string,
n: Notification,
ctx: SendContext,
): Promise<SendOutcome> {
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<DeliveryResult> {
async send(n: Notification, ctx: SendContext): Promise<DeliveryResult> {
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++;
+19 -7
View File
@@ -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'];
/**
* `<publisher>/<appName>:<key>` — 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 <publisher>/<appName>:<key>, 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.
+48
View File
@@ -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;
}
-32
View File
@@ -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' : '';
}
+31 -19
View File
@@ -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<DeliveryResult>;
send: (n: Notification, ctx: SendContext) => Promise<DeliveryResult>;
};