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:
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user