// ── The push DeliveryChannel: addressFor + deliver ───────────────────────── // // ENGAGEMENT.md Phase 7. Push is the channel that has existed longest and had a // `deliver` last, because until this phase there was nothing for a tickle to // point AT: `{ stream, ref }` carries no content by design, so a rule firing on // push before the inbox existed would have woken a phone to pull a screen that // had nothing on it. // // **The tickle invariant is the whole of this file's security posture.** What // leaves the server is the stream id and an opaque ref, never a title, never a // body, never the payload — `carriesContent: false` on the registration is the // declaration and this is the implementation. ntfy is treated as an untrusted // relay, so a leaked topic must reveal nothing but that *something* happened; // the app then pulls the real item over the authenticated, ownership-checked // inbox API. Every claim in that paragraph is one `pushDispatch` already makes, // which is why delivery here is a call into it rather than a second publisher. // // **`ref` points at the inbox row when there is one, and is null otherwise.** // A rule spanning `inapp` and `push` enqueues both, and `liveChannels` orders // `inapp` first precisely so the row exists by the time this runs — but that is // an optimisation, not a guarantee: the two rows are independent, either can be // retried, and a push-only rule has no inbox row at all. So the ref is a HINT. // The app's contract (docs/android/PLAN.md §11, Phase 8) is wake-and-pull; a // client that renders the ref instead of pulling is a client that will show // nothing the first time a retry reorders these two rows. const inbox = require('../model/userNotifications/userNotifications.db') const recipients = require('../model/engagement/engagementRecipients.db') const pushDispatch = require('../utils/pushDispatch') const log = require('../utils/logger')('engagement') /** * Can this channel reach `userId`? * * Active account only, the same re-check `emailChannel` and `inappChannel` make * for the same reason (a row can sit through a `delay_seconds` window). It does * NOT check for a registered device: whether any endpoint is subscribed is the * question `publishToUsers` answers in its own query, and asking it twice would * mean two different definitions of "reachable" that could disagree. */ const addressFor = async (userId) => { const active = await recipients.filterActive([userId]) return active.length ? { address: String(active[0]) } : null } /** * Deliver one claimed outbox row. * * @returns {Promise<{ok: boolean, retry?: boolean, transport?: string, detail?: string}>} */ async function deliver(row) { try { if (!(await addressFor(row.user_id))) { return { ok: false, detail: 'this user can no longer be reached' } } // Best effort, and it fails to null rather than to an error: no dedupe key, // no in-app row for it, or an inapp row this rule never enqueued all mean // the same thing to the app — wake up and pull. let ref = null try { const item = await inbox.findByDedupe(row.user_id, row.dedupe_key) if (item) ref = `notification:${item.id}` } catch (err) { log.debug('could not resolve a push ref', { outbox: row.id, message: err.message }) } // The stream id IS the trigger id — §7.2's one namespace, settled in Phase 2. // A push stream and an event trigger share a name space, so the app's // existing `{ stream }` switch keeps working for an engagement rule without // learning a second vocabulary. await pushDispatch.publishToUsers(row.trigger_id, { ref, userIds: [row.user_id] }) // **Success here means "handed to the relay", and the send log must not // claim more than that.** `publishToUsers` resolves whether it found a // subscribed device or none at all, and a tickle is fire-and-forget over // HTTP to a relay that owes us no receipt. Retrying on "we are not sure" // would mean five wakeups for one event, which is worse than one uncertain // log line — so this is the one channel whose 'sent' is weaker than email's, // and saying so in the detail is how an operator reading G15 finds that out. return { ok: true, transport: 'unifiedpush', detail: 'tickle published' } } catch (err) { // pushDispatch never throws, so reaching here is a programming error rather // than a relay being down. Terminal for that reason: retrying a bug is five // identical rows in the send log. log.error('push delivery failed', { outbox: row.id, message: err.message }) return { ok: false, detail: `delivery error: ${err.message}` } } } module.exports = { addressFor, deliver }