feat(chat): @everyone mention
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s

Rides the existing `<@id>` token: `everyone` already matches the parser's
`[a-zA-Z0-9_-]+`, so the markdown pre-processor, the inline renderer and the
badge pick it up with no second syntax. User ids are snowflakes, so the
literal `everyone` cannot collide with one.

Gated on a new MENTION_EVERYONE permission rather than being open to all —
otherwise a role can never take it away. Granted to @everyone by default: on
a small instance the ping is a convenience, not a weapon, and an owner who
disagrees can deny it and grant it to a role instead.

Two things that would each have made the gate a no-op:

  - DEFAULT_EVERYONE_PERMISSIONS only applies at space creation, so spaces
    that predate the bit would have had every @everyone mention silently
    stripped. A boot-time backfill ORs the bit into @everyone roles only
    (`id = space_id`), leaving other roles alone so a later denial is not
    overruled on the next restart. Verified against a copy of the production
    database: three roles moved by exactly 2^15, non-@everyone roles
    untouched, second pass a no-op.
  - Enforcement covers edit as well as create; without that, posting a plain
    message and editing it is the way around the permission.

A message that lacks the permission is not rejected — losing what someone
wrote over one token is worse than posting it. The token is demoted to the
literal `@everyone` text they typed: the message reads the same and rings
nobody.

Code spans and fences never ping. People paste payloads and snippets into
chat, and a paste should not ring everyone's client. The same rule decides
the row highlight and the notification sound, so the three cannot disagree.

Verified: web build, server typecheck, 504 web tests, 804 server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 22:10:56 -03:00
co-authored by Claude Opus 5
parent 1ec5d88fa2
commit a41a944742
16 changed files with 364 additions and 29 deletions
+3 -1
View File
@@ -16,7 +16,9 @@
"./src/constants": "./src/constants.ts",
"./src/constants.js": "./src/constants.ts",
"./src/audit": "./src/audit.ts",
"./src/audit.js": "./src/audit.ts"
"./src/audit.js": "./src/audit.ts",
"./src/mentions": "./src/mentions.ts",
"./src/mentions.js": "./src/mentions.ts"
},
"scripts": {
"build": "tsc",
+43
View File
@@ -0,0 +1,43 @@
/**
* The @everyone mention.
*
* It rides the existing `<@id>` token shape — `everyone` matches the same
* `[a-zA-Z0-9_-]+` the user-mention parser already accepts — so the markdown
* pre-processor, the inline renderer and the badge all pick it up without a
* second syntax. User ids are snowflakes (digits only), so the literal
* `everyone` can never collide with a real one.
*/
export const EVERYONE_MENTION_ID = 'everyone';
export const EVERYONE_MENTION_TOKEN = `<@${EVERYONE_MENTION_ID}>`;
/** Matches code spans and fences first, so their contents are never a ping. */
const CODE_OR_EVERYONE = /(```[\s\S]*?```|`[^`\n]*`)|<@everyone>/g;
/**
* Whether a message pings everyone.
*
* A token inside a code span or fenced block does not count: people paste
* message payloads and snippets into chat, and a paste should not ring
* everybody's client.
*/
export function mentionsEveryone(content: string | null | undefined): boolean {
if (!content) return false;
CODE_OR_EVERYONE.lastIndex = 0;
for (let m = CODE_OR_EVERYONE.exec(content); m; m = CODE_OR_EVERYONE.exec(content)) {
if (!m[1]) return true; // matched the token itself, not a code span
}
return false;
}
/**
* Neutralises the token into plain text, leaving code spans untouched.
*
* The server applies this when the author lacks MENTION_EVERYONE. Rejecting the
* message instead would lose what they wrote over a formatting detail; this way
* the message still posts, and reads as the literal text they typed.
*/
export function stripEveryoneMention(content: string): string {
return content.replace(CODE_OR_EVERYONE, (match, codeBlock: string | undefined) =>
codeBlock ? codeBlock : '@everyone',
);
}
+5
View File
@@ -16,6 +16,7 @@ export const PermissionBits = {
ATTACH_FILES: 1n << 12n,
READ_MESSAGE_HISTORY: 1n << 13n,
ADD_REACTIONS: 1n << 14n,
MENTION_EVERYONE: 1n << 15n,
CONNECT: 1n << 20n,
SPEAK: 1n << 21n,
MUTE_MEMBERS: 1n << 22n,
@@ -38,6 +39,10 @@ export const DEFAULT_EVERYONE_PERMISSIONS =
PermissionBits.ATTACH_FILES |
PermissionBits.READ_MESSAGE_HISTORY |
PermissionBits.ADD_REACTIONS |
// Granted by default: on a small instance the ping is a convenience, not a
// weapon. An owner who disagrees can deny it on @everyone and grant it to a
// role instead — which is why it is a bit rather than being ungated.
PermissionBits.MENTION_EVERYONE |
PermissionBits.STREAM;
// ─── Helpers ────────────────────────────────────────────────────────────────