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
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:
@@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { config } from '../config.js';
|
||||
import * as schema from './schema.js';
|
||||
import { ensureDefaults, backfillOneOnOneDmMembership } from './migrate.js';
|
||||
import { ensureDefaults, backfillOneOnOneDmMembership, grantMentionEveryoneToLegacyRoles } from './migrate.js';
|
||||
import { setWorkerId } from '../utils/snowflake.js';
|
||||
import { createSnapshot } from '../utils/backup.js';
|
||||
import { hasPendingMigrations } from './pendingMigrations.js';
|
||||
@@ -53,6 +53,7 @@ export function initDatabase() {
|
||||
// Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row
|
||||
// lost before the tombstone fix). Idempotent — safe no-op on every later boot.
|
||||
backfillOneOnOneDmMembership(sqlite);
|
||||
grantMentionEveryoneToLegacyRoles(sqlite);
|
||||
|
||||
// Initialize Snowflake worker ID from persisted value
|
||||
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
|
||||
|
||||
@@ -87,3 +87,44 @@ export function backfillOneOnOneDmMembership(db: Database.Database): void {
|
||||
run();
|
||||
if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant MENTION_EVERYONE on @everyone roles that predate the bit.
|
||||
*
|
||||
* `DEFAULT_EVERYONE_PERMISSIONS` only applies at space creation, so spaces
|
||||
* created before the bit existed carry a stored value without it — and would
|
||||
* silently have @everyone mentions stripped for every member. This ORs the bit
|
||||
* in once.
|
||||
*
|
||||
* Idempotent by construction: the OR is a no-op when the bit is already set,
|
||||
* and the WHERE clause skips those rows anyway. Deliberately scoped to the
|
||||
* @everyone role (`id = space_id`), so an owner who later denies the
|
||||
* permission is not overruled on the next boot — their denial lives on a
|
||||
* different row.
|
||||
*/
|
||||
export function grantMentionEveryoneToLegacyRoles(db: Database.Database): void {
|
||||
const BIT = 1n << 15n;
|
||||
|
||||
const rows = db.prepare(
|
||||
`SELECT id, permissions FROM roles WHERE id = space_id`
|
||||
).all() as { id: string; permissions: string | null }[];
|
||||
|
||||
const update = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
|
||||
let changed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
let current: bigint;
|
||||
try {
|
||||
current = BigInt(row.permissions ?? '0');
|
||||
} catch {
|
||||
continue; // unparseable value — leave it for a human, do not guess
|
||||
}
|
||||
if ((current & BIT) !== 0n) continue;
|
||||
update.run((current | BIT).toString(), row.id);
|
||||
changed++;
|
||||
}
|
||||
|
||||
if (changed > 0) {
|
||||
console.log(`[defaults] Granted MENTION_EVERYONE to ${changed} @everyone role(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
import { mentionsEveryone, stripEveryoneMention } from '@backspace/shared/src/mentions.js';
|
||||
import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
|
||||
|
||||
/**
|
||||
@@ -276,7 +277,8 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
|
||||
const { attachments: attachmentIds, replyToId, stickerId } = request.body;
|
||||
let content = request.body.content;
|
||||
|
||||
const spaceId = getChannelSpaceId(id);
|
||||
if (!spaceId) {
|
||||
@@ -295,6 +297,18 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
const db0 = getDb();
|
||||
const db = db0;
|
||||
|
||||
// @everyone is a permission, not a formatting choice. Rather than reject
|
||||
// the message — which loses what the author wrote over one token — the
|
||||
// token is demoted to the plain text they typed, so it posts and reads the
|
||||
// same but rings nobody.
|
||||
if (
|
||||
typeof content === 'string' &&
|
||||
mentionsEveryone(content) &&
|
||||
!hasPermission(request.userId, spaceId, PermissionBits.MENTION_EVERYONE, id)
|
||||
) {
|
||||
content = stripEveryoneMention(content);
|
||||
}
|
||||
|
||||
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
||||
|
||||
@@ -423,9 +437,21 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Same gate as on create — otherwise editing a message is the way around it.
|
||||
let edited = content;
|
||||
if (mentionsEveryone(edited)) {
|
||||
const editSpaceId = getChannelSpaceId(message.channelId);
|
||||
if (
|
||||
!editSpaceId ||
|
||||
!hasPermission(request.userId, editSpaceId, PermissionBits.MENTION_EVERYONE, message.channelId)
|
||||
) {
|
||||
edited = stripEveryoneMention(edited);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
db.update(schema.messages)
|
||||
.set({ content: content.trim(), editedAt: now })
|
||||
.set({ content: edited.trim(), editedAt: now })
|
||||
.where(eq(schema.messages.id, id))
|
||||
.run();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user