feat(teams): the notification core — four streams, and a recipient set
Phase 6's foundation: the fan-out shape the existing pipeline could not express. `pushDispatch.publish` answers "everyone subscribed to a stream" and "this one owner". Team notifications need "these N users", because Team scoping cannot live in a stream id: the catalog is a static registration validated at boot against a namespaced pattern, so a stream per Team is unexpressible, and stream ids are stored in `notification_subscriptions` rows that would need collecting every time a Team archived. So there are FOUR fixed core streams and the Team lives entirely in the recipient set. `team_notification_prefs` is opt-out for push and opt-IN for email — the two sinks default opposite ways, and the asymmetry lives in the column defaults so no condition anywhere has to remember it. One recipient query serves all four streams, because §6.2's two populations are the same set written twice: "active members with a user_id plus active grants" IS "everyone with resolved forum access". Mutes are subtracted in SQL rather than by the caller — there is no function here that returns an unfiltered set. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -108,4 +108,40 @@ async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
module.exports = { publish, isAllowedEndpoint }
|
||||
// `Number.isInteger` alone is not enough: `Number(null)` is 0 and 0 is an
|
||||
// integer, so a null slipping into a caller's list would become user id 0 and
|
||||
// ride into an IN clause. No row has id 0, so it is harmless today — which is
|
||||
// exactly why it would never be noticed.
|
||||
const isUserId = (n) => Number.isInteger(n) && n > 0
|
||||
|
||||
/**
|
||||
* Publish one content-free tickle to a COMPUTED SET of users (TEAMS.md §6.2).
|
||||
*
|
||||
* The third fan-out shape. `publish` answers "everyone subscribed" and "this one
|
||||
* owner"; Team notifications need "these N users", because the four `team.*`
|
||||
* streams are global and which Team an event belongs to is expressed by who is in
|
||||
* the set. Nothing about the tickle changes — same `{ stream, ref }`, same
|
||||
* untrusted-relay assumption, same SSRF gate on every endpoint.
|
||||
*
|
||||
* The set arrives already resolved: the caller has asked the access resolver who
|
||||
* may read this Team and subtracted the per-Team mutes. What this function still
|
||||
* enforces is each recipient's own stream subscription, in the query. Never
|
||||
* throws — a notification failing must not fail the write that produced it.
|
||||
*/
|
||||
async function publishToUsers(streamId, { ref, userIds } = {}, deps = {}) {
|
||||
const devices = deps.pushDevices || pushDevicesModel
|
||||
const ids = [...new Set((userIds || []).map(Number).filter(isUserId))]
|
||||
if (ids.length === 0) return
|
||||
let rows
|
||||
try {
|
||||
rows = await devices.endpointsForUsersStream(ids, streamId)
|
||||
} catch (err) {
|
||||
log.warn('push endpoint lookup failed', { streamId, message: err.message })
|
||||
return
|
||||
}
|
||||
if (!rows || rows.length === 0) return
|
||||
const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null })
|
||||
await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
|
||||
}
|
||||
|
||||
module.exports = { publish, publishToUsers, isAllowedEndpoint }
|
||||
|
||||
Reference in New Issue
Block a user