// ── What can happen, as core's engagement engine is told it ─────────────── // // The payload contracts behind every notification this module can cause // (MODULE_API.md §2.4, `registerEventTriggers`; PLAN.md §25). A trigger says // what an event IS, what a template may interpolate, and — the part that is a // security boundary — the widest audience a rule on it may EVER be given. // // ── The ceiling is containment, not size ────────────────────────────────── // // `owner` is not a small `staff`, and `staff` does not permit `owner`. For the // raid alert "one person" is the person whose base it was; for a ban it is // nobody outside the staff room. Each ceiling below is chosen against that // lattice and not against a ladder, and core refuses a rule that widens one. // // ── What no variable here carries, on purpose ───────────────────────────── // // • An IP address. The login and ban frames carry one; the triggers do not, // so no template an operator writes can put an address in a mail. The // admin feed still shows it, to staff, where it is useful. // • The raider (D66). The raid alert says what was destroyed, where and // when. Who did it is gameplay intelligence the game does not hand the // victim, and a variable that is not declared cannot be interpolated. // • A Steam id other than the subject's own. // // ── Why `subjectKey` is what it is ──────────────────────────────────────── // // Core's cooldown is per (rule, user, subject, channel). So the subject is the // thing a recipient should hear about once per cooldown: a BUILDING for a raid // (however many walls fall), a SERVER for a broadcast (however often it // bounces), a CLAN for a membership change. A subject that changed every firing // — a boot id, a timestamp — would make every cooldown a no-op. // // ── `version` ────────────────────────────────────────────────────────────── // // The prop-schema version a template records it was authored against. Bump one // on a rename or a type change, never for a label. const ID = 'rust' /** Site-relative paths, built the way the client registers them. */ const PATHS = { servers: `/${ID}`, account: `/player/${ID}`, } // A server id is VARCHAR(64) of the operator's choosing, and a clan's // `externalId` is `::`. Core validates a `url` // variable against a character class with no `:` in it, so every id that goes // into a path is percent-encoded — without it the clan link would be dropped at // emit in production, silently, for every clan there is. const serverPath = (serverId) => `/${ID}/servers/${encodeURIComponent(serverId)}` const leaderboardPath = (serverId) => `${serverPath(serverId)}?tab=leaderboard` const clanPath = (externalId) => `/${ID}/clans/${encodeURIComponent(externalId)}` const V1 = 1 // ── Shared variables ─────────────────────────────────────────────────────── // **Every trigger carries its own headline.** Most rules here point at core's // generic `notify.event` / `inapp.event`, and core's structural projection fills // `title` and `intro` from the trigger's LABEL and DESCRIPTION only when the // payload does not define them — "the payload wins, the projection fills gaps" // (ENGAGEMENT.md §4.6.1). Without these two, the phase-10 walk rendered a // multi-server site's notice as "A server came online. A server's game // started…" — true, and useless, because it never said which. The emitter // writes the sentence (`emit.js` `headline`); an operator's own template can // still ignore it and interpolate the parts. const HEADLINE = [ { name: 'title', type: 'string', required: false, example: 'Main is back online', description: 'A one-line headline naming what happened and where. Core generic bodies use it as the title.' }, { name: 'intro', type: 'string', required: false, example: 'Main is back up and taking players.', description: 'One sentence of detail. Core generic bodies use it as the body.' }, ] const SERVER = [ { name: 'serverId', type: 'string', required: true, example: 'main', description: 'The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts.' }, { name: 'server', type: 'string', required: true, example: 'Runic Gateway | Main', description: 'The server\'s display name.' }, { name: 'serverUrl', type: 'url', required: false, example: '/rust/servers/main', description: 'Site-relative path to the server\'s page.' }, ] const CLAN = [ { name: 'clanKey', type: 'string', required: true, example: 'main:12:1790142840000', description: 'The clan\'s stable identity. The cooldown subject; not meant for display.' }, { name: 'clan', type: 'string', required: true, example: 'The Rust Belt', description: 'The clan\'s name.' }, { name: 'clanUrl', type: 'url', required: false, example: '/rust/clans/main%3A12%3A1790142840000', description: 'Site-relative path to the clan\'s page.' }, ] // ── The raid alert ───────────────────────────────────────────────────────── const RAID = { id: 'rust.base.destroyed', label: 'Your base was raided', description: 'Part of a base you are authorised on was destroyed by another player: a wall, a door, ' + 'an external wall or gate, or the tool cupboard.', kind: 'event', // One per base per cooldown, however many walls fall. The building is the // tool cupboard's id — the game's own answer to "which base is this". subjectKey: 'building', // One emit per authorised, linked person, each with `ownerUserId` set (D59). // `owner` is the ceiling AND the default: there is nobody else this may reach. audience: 'owner', ceiling: 'owner', version: V1, variables: [ ...SERVER, ...HEADLINE, { name: 'building', type: 'string', required: true, example: '8113', description: 'The base, as the id of its tool cupboard. The cooldown subject.' }, { name: 'structure', type: 'string', required: true, example: 'door', description: 'What was destroyed: "building block", "door", "external wall" or "tool cupboard".' }, { name: 'grid', type: 'string', required: false, example: 'H7', description: 'The map grid square. Absent when the server could not work one out.' }, // A FRAGMENT, for use inside a sentence. An unset optional interpolates to // the empty string, so "your door in {{grid}} was destroyed" reads "your // door in was destroyed" when the grid is unknown; this carries its own // leading space and vanishes cleanly instead. { name: 'atGrid', type: 'string', required: false, example: ' in H7', description: 'Sentence fragment: " in H7" with its own leading space, or nothing when the grid is unknown.' }, { name: 'ownerOnline', type: 'boolean', required: true, example: false, description: 'Whether YOU were online when it happened. The seeded rule alerts only when this is false.' }, ], } // ── Server lifecycle ─────────────────────────────────────────────────────── // // `everyone` because a server being up is what a server page already says to // anyone. The DEFAULT is `subscribers` — the people who asked — and an operator // widens deliberately. const BROADCASTS = [ { id: 'rust.wipe.started', label: 'A server wiped', description: 'A server started a new wipe: a fresh map, and everything built on the old one gone.', kind: 'event', subjectKey: 'serverId', audience: 'subscribers', ceiling: 'everyone', version: V1, variables: [ ...SERVER, ...HEADLINE, { name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234', description: 'The new wipe\'s identity.' }, ], }, { id: 'rust.server.online', label: 'A server came online', description: 'A server\'s game started, or came back after being unreachable.', kind: 'event', subjectKey: 'serverId', audience: 'subscribers', ceiling: 'everyone', version: V1, variables: [...SERVER, ...HEADLINE], }, { id: 'rust.server.offline', label: 'A server went offline', description: 'A server\'s game stopped, crashed, or stopped talking to the website.', kind: 'event', subjectKey: 'serverId', audience: 'subscribers', ceiling: 'everyone', version: V1, variables: [...SERVER, ...HEADLINE], }, { id: 'rust.leaderboard.topped', label: 'A new kills leader', description: 'Somebody new leads the current wipe\'s kills on a server.', kind: 'event', subjectKey: 'serverId', audience: 'subscribers', ceiling: 'everyone', version: V1, variables: [ ...SERVER, ...HEADLINE, { name: 'leader', type: 'string', required: true, example: 'Marisol', description: 'The new leader\'s in-game name.' }, { name: 'kills', type: 'int', required: true, example: 42, description: 'Their kills this wipe.' }, { name: 'leaderboardUrl', type: 'url', required: false, example: '/rust/servers/main?tab=leaderboard', description: 'Site-relative path to the server\'s leaderboard.' }, ], }, ] // ── The player's own account ─────────────────────────────────────────────── const ACCOUNT = { id: 'rust.player.linked', label: 'A Steam account was linked', description: 'A Steam account was linked to your website account with an in-game code.', kind: 'event', subjectKey: 'steamId', // PLAN.md §10 said `self`; core has no such ceiling (§25.1). `owner` with the // linking user as `ownerUserId` is the value that exists and means the same. audience: 'owner', ceiling: 'owner', version: V1, variables: [ ...HEADLINE, { name: 'steamId', type: 'string', required: true, example: '76561198000000001', description: 'The Steam account that was linked. Also the cooldown subject.' }, { name: 'player', type: 'string', required: false, example: 'Marisol', description: 'The in-game name the game reported when it was linked.' }, { name: 'accountUrl', type: 'url', required: false, example: '/player/rust', description: 'Site-relative path to your Rust account page.' }, ], } // ── Clans ────────────────────────────────────────────────────────────────── // // `members` ceiling — clan membership is the clan's business (D49). Recipients // travel on the envelope as `recipientUserIds`, because "the clan this was // about" is a different answer every firing and cannot be a saved audience. // // No `rust.clan.member.added`: core already fires `team.member.joined` for our // clans through the Team sync, and a second trigger would notify twice (D64). const CLANS = [ { id: 'rust.clan.member.left', label: 'Someone left your clan', description: 'A member left a clan you are in.', kind: 'event', subjectKey: 'clanKey', audience: 'members', ceiling: 'members', version: V1, variables: [ ...CLAN, ...SERVER, ...HEADLINE, { name: 'member', type: 'string', required: false, example: 'Darrow', description: 'Who left.' }, ], }, { id: 'rust.clan.member.kicked', label: 'Someone was removed from your clan', description: 'A member was removed from a clan you are in — or you were.', kind: 'event', subjectKey: 'clanKey', audience: 'members', ceiling: 'members', version: V1, variables: [ ...CLAN, ...SERVER, ...HEADLINE, { name: 'member', type: 'string', required: false, example: 'Darrow', description: 'Who was removed.' }, { name: 'by', type: 'string', required: false, example: 'Marisol', description: 'Who removed them.' }, ], }, { id: 'rust.clan.disbanded', label: 'Your clan was disbanded', description: 'A clan you were in was disbanded.', kind: 'event', subjectKey: 'clanKey', audience: 'members', ceiling: 'members', version: V1, variables: [ ...CLAN, ...SERVER, ...HEADLINE, { name: 'by', type: 'string', required: false, example: 'Marisol', description: 'Who disbanded it.' }, ], }, ] // ── Moderation — staff, and never wider ──────────────────────────────────── const MODERATION = [ { id: 'rust.player.reported', label: 'A player was reported', description: 'A player filed an in-game report against another.', kind: 'event', subjectKey: 'steamId', audience: 'staff', ceiling: 'staff', version: V1, variables: [ ...SERVER, ...HEADLINE, { name: 'steamId', type: 'string', required: true, example: '76561198000000002', description: 'The reported player\'s Steam id. The cooldown subject.' }, { name: 'player', type: 'string', required: false, example: 'Darrow', description: 'The reported player\'s name.' }, { name: 'reporter', type: 'string', required: false, example: 'Marisol', description: 'Who filed the report.' }, { name: 'reportType', type: 'string', required: false, example: 'cheat', description: 'The category the reporter chose.' }, { name: 'topic', type: 'string', required: false, example: 'Aimbot at the dome', description: 'The report\'s subject line.' }, { name: 'message', type: 'string', required: false, example: 'Headshots through two walls.', description: 'The report\'s text.' }, ], }, { id: 'rust.player.banned', label: 'A player was banned', description: 'A player was banned on a server.', kind: 'event', subjectKey: 'steamId', audience: 'staff', ceiling: 'staff', version: V1, variables: [ ...SERVER, ...HEADLINE, { name: 'steamId', type: 'string', required: true, example: '76561198000000002', description: 'The banned player\'s Steam id. The cooldown subject.' }, { name: 'player', type: 'string', required: false, example: 'Darrow', description: 'The banned player\'s name.' }, { name: 'reason', type: 'string', required: false, example: 'Cheating', description: 'The reason given.' }, ], }, { id: 'rust.player.unbanned', label: 'A player was unbanned', description: 'A ban on a server was lifted.', kind: 'event', subjectKey: 'steamId', audience: 'staff', ceiling: 'staff', version: V1, variables: [ ...SERVER, ...HEADLINE, { name: 'steamId', type: 'string', required: true, example: '76561198000000002', description: 'The player\'s Steam id. The cooldown subject.' }, { name: 'player', type: 'string', required: false, example: 'Darrow', description: 'The player\'s name.' }, ], }, { id: 'rust.login.denied', label: 'A login was not approved', description: 'Somebody tried to join a server and was not let in within a minute: a ban, a failed ' + 'authentication, or a player who gave up while connecting.', kind: 'event', subjectKey: 'steamId', audience: 'staff', ceiling: 'staff', version: V1, variables: [ ...SERVER, ...HEADLINE, { name: 'steamId', type: 'string', required: true, example: '76561198000000002', description: 'The Steam id that tried to connect. The cooldown subject.' }, { name: 'player', type: 'string', required: false, example: 'Darrow', description: 'The name it connected with.' }, { name: 'attemptedAt', type: 'datetime', required: true, example: '2026-09-23T03:10:00Z', description: 'When the attempt was made.' }, ], }, ] const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, ...CLANS, ...MODERATION]) const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id]))) module.exports = { TRIGGERS, TRIGGER_IDS, PATHS, serverPath, leaderboardPath, clanPath }