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>
131 lines
5.4 KiB
TypeScript
131 lines
5.4 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import crypto from 'crypto';
|
|
|
|
/**
|
|
* Ensure data invariants after schema migration. Idempotent — safe to run
|
|
* on every boot. Uses raw better-sqlite3 handle (not Drizzle ORM).
|
|
*/
|
|
export function ensureDefaults(db: Database.Database): void {
|
|
// 1. Ensure the single-row instance_settings row exists
|
|
const row = db.prepare('SELECT id FROM instance_settings WHERE id = 1').get();
|
|
if (!row) {
|
|
db.prepare(
|
|
`INSERT OR IGNORE INTO instance_settings
|
|
(id, max_bitrate_kbps, min_bitrate_kbps, bitrate_step_kbps,
|
|
allowed_resolutions, allowed_framerates, max_resolution, max_framerate, updated_at)
|
|
VALUES (1, 20000, 500, 500, ?, ?, 1080, 60, ?)`
|
|
).run('540,720,1080', '30,45,60', Date.now());
|
|
console.log('[defaults] Inserted default instance_settings row');
|
|
}
|
|
|
|
// 2. Ensure a unique Snowflake worker ID is persisted (0-1023)
|
|
const settings = db.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as
|
|
{ worker_id: number | null } | undefined;
|
|
if (!settings || settings.worker_id === null) {
|
|
const workerId = crypto.randomInt(0, 1024);
|
|
db.prepare('UPDATE instance_settings SET worker_id = ? WHERE id = 1').run(workerId);
|
|
console.log(`[defaults] Generated Snowflake worker ID: ${workerId}`);
|
|
}
|
|
|
|
// 2b. Ensure a persistent instance epoch (incarnation UUID) exists. A fresh
|
|
// DB mints a new one — this is the discriminator for detecting resets.
|
|
// The id=1 row is guaranteed by step 1's INSERT OR IGNORE above.
|
|
const epochRow = db.prepare('SELECT instance_id FROM instance_settings WHERE id = 1').get() as
|
|
{ instance_id: string | null } | undefined;
|
|
if (!epochRow || epochRow.instance_id === null) {
|
|
const instanceId = crypto.randomUUID();
|
|
const res = db.prepare('UPDATE instance_settings SET instance_id = ? WHERE id = 1').run(instanceId);
|
|
if (res.changes !== 1) throw new Error('ensureDefaults: instance_settings id=1 row missing — cannot mint epoch');
|
|
console.log('[defaults] Generated instance epoch');
|
|
}
|
|
|
|
// 3. Ensure at least one admin exists (promote earliest registered user)
|
|
const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get();
|
|
if (!anyAdmin) {
|
|
const firstUser = db.prepare(
|
|
'SELECT id FROM users ORDER BY created_at ASC LIMIT 1'
|
|
).get() as { id: string } | undefined;
|
|
if (firstUser) {
|
|
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(firstUser.id);
|
|
console.log(`[defaults] Promoted first user ${firstUser.id} to admin`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One-time, idempotent recovery of 1-on-1 DM threads broken before the DM
|
|
* tombstone fix: those had the deleted partner's dm_members row removed, making
|
|
* the thread UI-unreachable. For each ownerId-NULL channel with exactly one
|
|
* member, re-insert membership for any distinct dm_messages author that is
|
|
* missing from dm_members and still exists in users. Safe to run every boot.
|
|
*/
|
|
export function backfillOneOnOneDmMembership(db: Database.Database): void {
|
|
const broken = db.prepare(`
|
|
SELECT dc.id AS channelId
|
|
FROM dm_channels dc
|
|
WHERE dc.owner_id IS NULL
|
|
AND (SELECT COUNT(*) FROM dm_members dm WHERE dm.dm_channel_id = dc.id) = 1
|
|
`).all() as { channelId: string }[];
|
|
if (broken.length === 0) return;
|
|
|
|
const missingAuthors = db.prepare(`
|
|
SELECT DISTINCT msg.user_id AS userId
|
|
FROM dm_messages msg
|
|
JOIN users u ON u.id = msg.user_id
|
|
WHERE msg.dm_channel_id = ?
|
|
AND msg.user_id NOT IN (SELECT user_id FROM dm_members WHERE dm_channel_id = ?)
|
|
`);
|
|
const insertMember = db.prepare('INSERT INTO dm_members (dm_channel_id, user_id, closed) VALUES (?, ?, 0)');
|
|
|
|
let restored = 0;
|
|
const run = db.transaction(() => {
|
|
for (const { channelId } of broken) {
|
|
const authors = missingAuthors.all(channelId, channelId) as { userId: string }[];
|
|
for (const { userId } of authors) { insertMember.run(channelId, userId); restored++; }
|
|
}
|
|
});
|
|
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)`);
|
|
}
|
|
}
|