// ── The integration bridge's configuration and its one precondition ──────── // // TEAMS.md §7.2, phase 8. An operator says "send these Team events to this // Discord channel", globally or for one Team, and this file is where that // sentence is validated, stored and resolved. // // **Two of the four streams can never be public, and that is the whole reason // this file is more than a settings row.** §7.2 gates bridging on "the event's // visibility is public, or the destination channel is configured for a // members-only Team context". Neither half exists in the tree and neither can: // the four `team.*` streams carry no visibility (only `team_activity` rows do, // and a notification is not an activity row), forum threads have no public/ // members column because a forum is members-only by construction — everything in // it sits behind `team_forum_grants` — and core cannot see a Discord channel's // permissions to know what it is. // // Only the operator can see that. So the gate becomes an ATTRIBUTED // ACKNOWLEDGEMENT: enabling a members-only event requires an explicit tick that // the destination is restricted to that Team's members, recorded with who gave it // and when, in the same shape `teams_forum_uploads_ack` records the image-policy // one. It is a precondition, not a preference — `assertEnableable` refuses the // save rather than quietly dropping the event at delivery time, because a config // that silently does less than it says is worse than one that will not save. // // **Changing the channel clears the acknowledgement.** An acknowledgement is // about a destination; it cannot survive the destination changing underneath it, // or an operator would tick "this channel is private", then repoint the row at a // public one and keep the permission they were granted for a different place. // // **Every read fails closed**, like `teamForumSettings`: a DB fault reports no // bridge configured, because the cost of failing closed is a Discord channel that // stays quiet for a minute and the cost of failing open is members-only text in a // room the operator never approved. const db = require('./teamIntegration.db') const log = require('../../utils/logger')('team-integration') // The only platform phase 8 knows. Deliberately a value rather than a hardcoded // literal at every call site: phase 10 turns this into a lookup against the // declared-capability registry, and the fewer places that spell 'discord' the // smaller that change is. const DISCORD = 'discord' const PLATFORMS = [DISCORD] // The four §6.2 streams, and which of them can reach a channel core cannot vet. // // A stream is members-only if the CONTENT behind it is: `team.forum.post` and // `team.announcement` both name a thread nobody outside the Team may read. The // roster pair is public — Team pages and rosters are public by §1's projection // rules — so bridging those asserts nothing and needs no tick. const BRIDGEABLE = [ 'team.member.joined', 'team.leadership.changed', 'team.forum.post', 'team.announcement', ] const MEMBERS_ONLY = new Set(['team.forum.post', 'team.announcement']) // Discord snowflakes are 17-20 digits today and the format is not promised. The // check is only that a channel ref is plausibly one and cannot smuggle anything — // core treats it as opaque and the bot is what resolves it. const CHANNEL_RE = /^[0-9]{5,32}$/ const isMembersOnly = (streamId) => MEMBERS_ONLY.has(streamId) /** Does this event list contain anything that would publish members-only text? */ const needsAck = (events) => (events || []).some(isMembersOnly) /** * Normalise an operator-supplied event list. * * Unknown ids are REJECTED rather than dropped. A silently-dropped event is a * config screen that shows you saved something you did not, and the set is small * and fixed enough that a typo is a mistake worth reporting. */ function normaliseEvents(events) { if (!Array.isArray(events)) { const err = new Error('events must be an array') err.status = 400 throw err } const seen = [] for (const raw of events) { const id = String(raw || '').trim() if (!BRIDGEABLE.includes(id)) { const err = new Error(`unknown event: ${id}`) err.status = 400 throw err } if (!seen.includes(id)) seen.push(id) } return seen } function normaliseChannel(channelRef) { const value = String(channelRef || '').trim() if (!value) return null if (!CHANNEL_RE.test(value)) { const err = new Error('channel must be a numeric channel id') err.status = 400 throw err } return value } /** * The gate, as a throw. * * Order matters to the message an operator reads: an enabled row with no channel * is a different mistake from one with an unacknowledged channel, and reporting * the second when the first is true would send them to tick a box that would not * have helped. */ function assertEnableable({ enabled, events, channelRef, membersAck }) { if (!enabled) return if (!channelRef) { const err = new Error('a destination channel is required to enable this bridge') err.status = 422 throw err } if (events.length === 0) { const err = new Error('at least one event is required to enable this bridge') err.status = 422 throw err } if (needsAck(events) && !membersAck) { const err = new Error( 'forum posts and announcements are visible only to a Team’s members — confirm the destination channel is restricted to them before enabling', ) err.status = 422 err.code = 'members_ack_required' throw err } } /** Rows for the admin panel, `events` already parsed. */ async function list(platform = DISCORD) { const rows = await db.listForPlatform(platform) return rows.map(shape) } /** * Parse the stored JSON once, here. * * `mariadb` hands a JSON column back as a string on some server versions and as a * parsed value on others, which is a difference nobody wants to rediscover in a * controller. Anything unreadable becomes an empty list rather than a throw: a * row with a corrupt event list should render as a row that bridges nothing, not * take the whole admin page down. */ function shape(row) { if (!row) return null let events = row.events if (typeof events === 'string') { try { events = JSON.parse(events) } catch { events = [] } } return { ...row, events: Array.isArray(events) ? events : [], enabled: !!row.enabled, members_ack: !!row.members_ack } } /** * The row that governs `teamId` — the override if there is one, otherwise the * deployment default — filtered down to what may actually be delivered. * * **The acknowledgement is checked HERE as well as at the save.** A row saved * with the tick can lose it later: an admin repoints the channel, or a future * change to what counts as members-only reclassifies a stream a row already * carries. Re-asking at delivery is what makes the tick a live property of the * row rather than a note about a save that happened once. */ async function resolve(teamId, platform = DISCORD) { try { const row = shape(await db.resolveFor(platform, teamId)) if (!row || !row.enabled || !row.channel_ref) return null const events = row.events.filter((id) => (isMembersOnly(id) ? row.members_ack : true)) if (events.length === 0) return null return { ...row, events } } catch (err) { log.warn('bridge config lookup failed — treating as unconfigured', { teamId, platform, message: err.message, }) return null } } /** Is `streamId` bridged for this Team? The delivery path's whole question. */ async function destinationFor(teamId, streamId, platform = DISCORD) { const row = await resolve(teamId, platform) if (!row || !row.events.includes(streamId)) return null return { channelRef: row.channel_ref, membersOnly: isMembersOnly(streamId), platform } } /** * Create or replace the row for (platform, team). * * `actorId` is the admin doing the saving, and it is what lands in * `members_ack_by` — the acknowledgement names a person, so it cannot be written * by a path that does not know who they are. */ async function save({ platform = DISCORD, teamId = null, events, channelRef, enabled, membersAck }, actorId) { if (!PLATFORMS.includes(platform)) { const err = new Error(`unknown platform: ${platform}`) err.status = 400 throw err } const nextEvents = normaliseEvents(events) const nextChannel = normaliseChannel(channelRef) const existing = shape(await db.getForTeam(platform, teamId)) // An acknowledgement survives an ordinary edit and dies with the channel it was // given for. `membersAck === false` from the client is an explicit withdrawal // and is honoured; `undefined` means "leave it", which is what a save that only // toggled an event should do. const channelChanged = !!existing && existing.channel_ref !== nextChannel let ack = existing ? existing.members_ack : false if (membersAck === false) ack = false else if (membersAck === true) ack = true if (channelChanged) ack = membersAck === true const nextEnabled = !!enabled assertEnableable({ enabled: nextEnabled, events: nextEvents, channelRef: nextChannel, membersAck: ack }) // Re-stamp only when the acknowledgement is newly given, so an unrelated save // does not rewrite the date on a decision nobody revisited. // // `channelChanged` belongs in this condition and it is easy to leave out: an // acknowledgement given alongside a NEW channel is a new acknowledgement even // though the column was already 1, and without it the row keeps naming whoever // vetted the PREVIOUS destination. That attribution is the whole audit value of // the column — it has to name the person who looked at the channel the row now // points at. const freshlyAcked = ack && (channelChanged || !(existing && existing.members_ack)) const row = await db.upsert({ platform, teamId, events: nextEvents, channelRef: nextChannel, enabled: nextEnabled, membersAck: ack, membersAckBy: ack ? (freshlyAcked ? actorId : existing.members_ack_by) : null, membersAckAt: ack ? (freshlyAcked ? new Date() : existing.members_ack_at) : null, }) return shape(row) } async function remove(platform, teamId) { return db.remove(platform, teamId) } module.exports = { DISCORD, PLATFORMS, BRIDGEABLE, MEMBERS_ONLY, isMembersOnly, needsAck, normaliseEvents, normaliseChannel, assertEnableable, list, resolve, destinationFor, save, remove, }