1.1.3
This commit is contained in:
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -8,9 +8,11 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as audit from "../audit.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as authActions from "../authActions.js";
|
||||
import type * as authGuard from "../authGuard.js";
|
||||
import type * as bans from "../bans.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as channelKeys from "../channelKeys.js";
|
||||
import type * as channels from "../channels.js";
|
||||
@@ -43,9 +45,11 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
audit: typeof audit;
|
||||
auth: typeof auth;
|
||||
authActions: typeof authActions;
|
||||
authGuard: typeof authGuard;
|
||||
bans: typeof bans;
|
||||
categories: typeof categories;
|
||||
channelKeys: typeof channelKeys;
|
||||
channels: typeof channels;
|
||||
|
||||
127
convex/audit.ts
Normal file
127
convex/audit.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import type {
|
||||
GenericMutationCtx,
|
||||
GenericQueryCtx,
|
||||
} from "convex/server";
|
||||
import type { DataModel, Id } from "./_generated/dataModel";
|
||||
import { hasPermission } from "./roles";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
|
||||
/**
|
||||
* Known audit actions. Client-facing label mapping lives in the
|
||||
* settings UI; the backend just stores strings so future actions
|
||||
* don't require a schema change.
|
||||
*/
|
||||
export const AUDIT_ACTIONS = {
|
||||
CHANNEL_CREATE: "channel.create",
|
||||
CHANNEL_DELETE: "channel.delete",
|
||||
CHANNEL_RENAME: "channel.rename",
|
||||
CHANNEL_UPDATE_TOPIC: "channel.update_topic",
|
||||
ROLE_CREATE: "role.create",
|
||||
ROLE_DELETE: "role.delete",
|
||||
ROLE_UPDATE: "role.update",
|
||||
ROLE_ASSIGN: "role.assign",
|
||||
ROLE_UNASSIGN: "role.unassign",
|
||||
SERVER_SETTINGS_UPDATE: "server.settings_update",
|
||||
BAN_ADD: "ban.add",
|
||||
BAN_REMOVE: "ban.remove",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Internal helper — call from mutations that mutate server state.
|
||||
* Silent on failure: an audit-write that throws would roll back the
|
||||
* real mutation, which is worse than a missing log entry.
|
||||
*/
|
||||
export async function logAudit(
|
||||
ctx: GenericMutationCtx<DataModel>,
|
||||
args: {
|
||||
actorId: Id<"userProfiles">;
|
||||
action: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
targetName?: string;
|
||||
metadata?: unknown;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ctx.db.insert("auditLog", {
|
||||
actorId: args.actorId,
|
||||
action: args.action,
|
||||
targetType: args.targetType,
|
||||
targetId: args.targetId,
|
||||
targetName: args.targetName,
|
||||
metadata: args.metadata,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Audit is best-effort. Don't block the caller.
|
||||
}
|
||||
}
|
||||
|
||||
// Any moderator-adjacent permission is enough to view the log. We
|
||||
// don't want to leak the log to @everyone but equally don't want to
|
||||
// hide it behind a narrow permission no role has by default.
|
||||
async function canViewAuditLog(
|
||||
ctx: GenericQueryCtx<DataModel>,
|
||||
userId: Id<"userProfiles">,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await hasPermission(ctx, userId, "ban_members")) ||
|
||||
(await hasPermission(ctx, userId, "manage_channels")) ||
|
||||
(await hasPermission(ctx, userId, "manage_roles")) ||
|
||||
(await hasPermission(ctx, userId, "manage_messages"))
|
||||
);
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
returns: v.array(v.any()),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await canViewAuditLog(ctx, args.actorId))) {
|
||||
throw new Error("Not authorized to view the audit log.");
|
||||
}
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 1), 500);
|
||||
const rows = await ctx.db
|
||||
.query("auditLog")
|
||||
.withIndex("by_created_at")
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
|
||||
// Walk rows once, de-duping actors via the map itself. Iterating
|
||||
// a Set widens the element type and breaks `ctx.db.get`'s
|
||||
// narrowing — using the map as its own seen-set avoids that.
|
||||
const actors = new Map<
|
||||
string,
|
||||
{ username: string; displayName?: string; avatarUrl: string | null }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
if (actors.has(r.actorId)) continue;
|
||||
const user = await ctx.db.get(r.actorId);
|
||||
if (!user) continue;
|
||||
let avatarUrl: string | null = null;
|
||||
if (user.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
|
||||
}
|
||||
actors.set(r.actorId, {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
_id: r._id,
|
||||
action: r.action,
|
||||
targetType: r.targetType,
|
||||
targetId: r.targetId,
|
||||
targetName: r.targetName,
|
||||
metadata: r.metadata,
|
||||
createdAt: r.createdAt,
|
||||
actor: actors.get(r.actorId) ?? { username: "unknown", avatarUrl: null },
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { query, mutation, internalQuery, internalMutation } from "./_generated/s
|
||||
import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
import { getRolesForUser } from "./roles";
|
||||
import { isBanned } from "./bans";
|
||||
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const buffer = await crypto.subtle.digest(
|
||||
@@ -62,6 +63,9 @@ export const verifyUser = mutation({
|
||||
const hashedDAK = await sha256Hex(args.dak);
|
||||
|
||||
if (hashedDAK === user.hashedAuthKey) {
|
||||
if (await isBanned(ctx, user._id)) {
|
||||
return { error: "You've been banned from this server." };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
userId: user._id,
|
||||
@@ -166,6 +170,7 @@ export const createUserWithProfile = mutation({
|
||||
create_invite: true,
|
||||
embed_links: true,
|
||||
attach_files: true,
|
||||
ban_members: true,
|
||||
},
|
||||
isHoist: true,
|
||||
});
|
||||
@@ -205,6 +210,7 @@ export const getPublicKeys = query({
|
||||
customStatus: v.optional(v.string()),
|
||||
joinSoundUrl: v.optional(v.union(v.string(), v.null())),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerUrl: v.optional(v.union(v.string(), v.null())),
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
@@ -219,6 +225,10 @@ export const getPublicKeys = query({
|
||||
if (u.joinSoundStorageId) {
|
||||
joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId);
|
||||
}
|
||||
let bannerUrl: string | null = null;
|
||||
if (u.bannerStorageId) {
|
||||
bannerUrl = await getPublicStorageUrl(ctx, u.bannerStorageId);
|
||||
}
|
||||
results.push({
|
||||
id: u._id,
|
||||
username: u.username,
|
||||
@@ -230,6 +240,7 @@ export const getPublicKeys = query({
|
||||
customStatus: u.customStatus,
|
||||
joinSoundUrl,
|
||||
accentColor: u.accentColor,
|
||||
bannerUrl,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
@@ -251,6 +262,8 @@ export const updateProfileInternal = internalMutation({
|
||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||
removeJoinSound: v.optional(v.boolean()),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerStorageId: v.optional(v.id("_storage")),
|
||||
removeBanner: v.optional(v.boolean()),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -262,6 +275,17 @@ export const updateProfileInternal = internalMutation({
|
||||
if (args.joinSoundStorageId !== undefined) patch.joinSoundStorageId = args.joinSoundStorageId;
|
||||
if (args.removeJoinSound) patch.joinSoundStorageId = undefined;
|
||||
if (args.accentColor !== undefined) patch.accentColor = args.accentColor;
|
||||
if (args.bannerStorageId !== undefined) patch.bannerStorageId = args.bannerStorageId;
|
||||
if (args.removeBanner) {
|
||||
// Drop the blob from storage too so orphaned banners don't
|
||||
// accumulate. Matches how the rest of this file treats one-off
|
||||
// user uploads.
|
||||
const existing = await ctx.db.get(args.userId);
|
||||
if (existing?.bannerStorageId) {
|
||||
try { await ctx.storage.delete(existing.bannerStorageId); } catch {}
|
||||
}
|
||||
patch.bannerStorageId = undefined;
|
||||
}
|
||||
await ctx.db.patch(args.userId, patch);
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ export const updateProfile = action({
|
||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||
removeJoinSound: v.optional(v.boolean()),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerStorageId: v.optional(v.id("_storage")),
|
||||
removeBanner: v.optional(v.boolean()),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
@@ -36,6 +38,8 @@ export const updateProfile = action({
|
||||
joinSoundStorageId: args.joinSoundStorageId,
|
||||
removeJoinSound: args.removeJoinSound,
|
||||
accentColor: args.accentColor,
|
||||
bannerStorageId: args.bannerStorageId,
|
||||
removeBanner: args.removeBanner,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
|
||||
160
convex/bans.ts
Normal file
160
convex/bans.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import type { GenericQueryCtx } from "convex/server";
|
||||
import type { DataModel, Id } from "./_generated/dataModel";
|
||||
import { hasPermission, getRolesForUser } from "./roles";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
|
||||
/**
|
||||
* Returns true if the user is currently banned. Callers should
|
||||
* surface this as an explicit `{ error: "Banned" }` at auth time or
|
||||
* throw from sensitive mutations like `messages.send`.
|
||||
*/
|
||||
export async function isBanned(
|
||||
ctx: GenericQueryCtx<DataModel>,
|
||||
userId: Id<"userProfiles">,
|
||||
): Promise<boolean> {
|
||||
const row = await ctx.db
|
||||
.query("bans")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.first();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export const isUserBanned = query({
|
||||
args: { userId: v.id("userProfiles") },
|
||||
returns: v.boolean(),
|
||||
handler: async (ctx, args) => isBanned(ctx, args.userId),
|
||||
});
|
||||
|
||||
// List all bans with actor + target enrichment for the Bans tab.
|
||||
export const list = query({
|
||||
args: { actorId: v.id("userProfiles") },
|
||||
returns: v.array(v.any()),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
||||
throw new Error("You don't have permission to view bans.");
|
||||
}
|
||||
const bans = await ctx.db.query("bans").collect();
|
||||
const out = [];
|
||||
for (const b of bans) {
|
||||
const user = await ctx.db.get(b.userId);
|
||||
const actor = await ctx.db.get(b.bannedBy);
|
||||
let avatarUrl: string | null = null;
|
||||
if (user?.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
|
||||
}
|
||||
out.push({
|
||||
_id: b._id,
|
||||
userId: b.userId,
|
||||
bannedBy: b.bannedBy,
|
||||
reason: b.reason ?? null,
|
||||
createdAt: b.createdAt,
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl,
|
||||
}
|
||||
: null,
|
||||
actor: actor
|
||||
? { username: actor.username, displayName: actor.displayName }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
||||
export const ban = mutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
userId: v.id("userProfiles"),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({ success: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
||||
throw new Error("You don't have permission to ban members.");
|
||||
}
|
||||
if (args.actorId === args.userId) {
|
||||
throw new Error("You can't ban yourself.");
|
||||
}
|
||||
const target = await ctx.db.get(args.userId);
|
||||
if (!target) throw new Error("User not found.");
|
||||
|
||||
// Refuse to ban isAdmin or Owner-role bearers. Single-server
|
||||
// deployment can't afford to lock itself out of administration.
|
||||
if (target.isAdmin) {
|
||||
throw new Error("Server admins can't be banned.");
|
||||
}
|
||||
const targetRoles = await getRolesForUser(ctx, args.userId);
|
||||
if (targetRoles.some((r) => r.name === "Owner")) {
|
||||
throw new Error("The Owner can't be banned.");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("bans")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.first();
|
||||
if (existing) {
|
||||
// Update reason + actor if re-banning.
|
||||
await ctx.db.patch(existing._id, {
|
||||
bannedBy: args.actorId,
|
||||
reason: args.reason,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("bans", {
|
||||
userId: args.userId,
|
||||
bannedBy: args.actorId,
|
||||
reason: args.reason,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.BAN_ADD,
|
||||
targetType: "user",
|
||||
targetId: args.userId,
|
||||
targetName: target.displayName ?? target.username,
|
||||
metadata: args.reason ? { reason: args.reason } : undefined,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const unban = mutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
userId: v.id("userProfiles"),
|
||||
},
|
||||
returns: v.object({ success: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
||||
throw new Error("You don't have permission to unban members.");
|
||||
}
|
||||
const row = await ctx.db
|
||||
.query("bans")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.first();
|
||||
if (!row) return { success: true };
|
||||
|
||||
const target = await ctx.db.get(args.userId);
|
||||
await ctx.db.delete(row._id);
|
||||
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.BAN_REMOVE,
|
||||
targetType: "user",
|
||||
targetId: args.userId,
|
||||
targetName: target?.displayName ?? target?.username,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { v } from "convex/values";
|
||||
import { GenericMutationCtx } from "convex/server";
|
||||
import { DataModel, Id } from "./_generated/dataModel";
|
||||
import { internal } from "./_generated/api";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
|
||||
type TableWithChannelIndex =
|
||||
| "channelKeys"
|
||||
@@ -74,6 +75,7 @@ export const create = mutation({
|
||||
categoryId: v.optional(v.id("categories")),
|
||||
topic: v.optional(v.string()),
|
||||
position: v.optional(v.number()),
|
||||
actorId: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.object({ id: v.id("channels") }),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -112,6 +114,17 @@ export const create = mutation({
|
||||
position,
|
||||
});
|
||||
|
||||
if (args.actorId) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.CHANNEL_CREATE,
|
||||
targetType: "channel",
|
||||
targetId: id,
|
||||
targetName: args.name,
|
||||
metadata: { type: args.type || "text" },
|
||||
});
|
||||
}
|
||||
|
||||
return { id };
|
||||
},
|
||||
});
|
||||
@@ -136,6 +149,7 @@ export const rename = mutation({
|
||||
args: {
|
||||
id: v.id("channels"),
|
||||
name: v.string(),
|
||||
actorId: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.object({
|
||||
_id: v.id("channels"),
|
||||
@@ -156,7 +170,20 @@ export const rename = mutation({
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
|
||||
const oldName = channel.name;
|
||||
await ctx.db.patch(args.id, { name: args.name });
|
||||
|
||||
if (args.actorId && oldName !== args.name) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.CHANNEL_RENAME,
|
||||
targetType: "channel",
|
||||
targetId: args.id,
|
||||
targetName: args.name,
|
||||
metadata: { from: oldName, to: args.name },
|
||||
});
|
||||
}
|
||||
|
||||
return { ...channel, name: args.name };
|
||||
},
|
||||
});
|
||||
@@ -205,7 +232,10 @@ export const reorderChannels = mutation({
|
||||
|
||||
// Delete channel + cascade messages and keys
|
||||
export const remove = mutation({
|
||||
args: { id: v.id("channels") },
|
||||
args: {
|
||||
id: v.id("channels"),
|
||||
actorId: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.object({ success: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
const channel = await ctx.db.get(args.id);
|
||||
@@ -213,6 +243,9 @@ export const remove = mutation({
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
|
||||
const deletedName = channel.name;
|
||||
const deletedType = channel.type;
|
||||
|
||||
// Delete reactions for all messages in this channel
|
||||
const messages = await ctx.db
|
||||
.query("messages")
|
||||
@@ -240,6 +273,17 @@ export const remove = mutation({
|
||||
|
||||
await ctx.db.delete(args.id);
|
||||
|
||||
if (args.actorId) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.CHANNEL_DELETE,
|
||||
targetType: "channel",
|
||||
targetId: args.id,
|
||||
targetName: deletedName,
|
||||
metadata: { type: deletedType },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { paginationOptsValidator } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
import { getRolesForUser } from "./roles";
|
||||
import { isBanned } from "./bans";
|
||||
|
||||
const DEFAULT_ROLE_COLOR = "#99aab5";
|
||||
|
||||
@@ -252,6 +253,9 @@ export const sendInternal = internalMutation({
|
||||
if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) {
|
||||
throw new Error("Message too large");
|
||||
}
|
||||
if (await isBanned(ctx, args.senderId)) {
|
||||
throw new Error("You've been banned from this server.");
|
||||
}
|
||||
const id = await ctx.db.insert("messages", {
|
||||
channelId: args.channelId,
|
||||
senderId: args.senderId,
|
||||
|
||||
@@ -82,7 +82,11 @@ export const getAllReadStates = query({
|
||||
},
|
||||
});
|
||||
|
||||
// Get the latest message timestamp per channel (used by Sidebar)
|
||||
// Get the latest message timestamp per channel (used by Sidebar).
|
||||
// Also surfaces `messageId` and `senderId` so the notification
|
||||
// observer can detect genuinely new messages (not just timestamp
|
||||
// ticks from edits) and suppress notifications for the user's own
|
||||
// sends without an extra round-trip.
|
||||
export const getLatestMessageTimestamps = query({
|
||||
args: {
|
||||
channelIds: v.array(v.id("channels")),
|
||||
@@ -91,6 +95,8 @@ export const getLatestMessageTimestamps = query({
|
||||
v.object({
|
||||
channelId: v.id("channels"),
|
||||
latestTimestamp: v.number(),
|
||||
messageId: v.optional(v.id("messages")),
|
||||
senderId: v.optional(v.id("userProfiles")),
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -105,6 +111,8 @@ export const getLatestMessageTimestamps = query({
|
||||
results.push({
|
||||
channelId,
|
||||
latestTimestamp: Math.floor(latestMsg._creationTime),
|
||||
messageId: latestMsg._id,
|
||||
senderId: latestMsg.senderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const PERMISSION_KEYS = [
|
||||
"move_members",
|
||||
"mute_members",
|
||||
"manage_nicknames",
|
||||
"ban_members",
|
||||
] as const;
|
||||
|
||||
export async function getRolesForUser(
|
||||
@@ -30,6 +31,31 @@ export async function getRolesForUser(
|
||||
return roles.filter((r): r is Doc<"roles"> => r !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side permission check. Use before any mutation that's
|
||||
* supposed to be gated — the client-side `getMyPermissions` hides
|
||||
* UI, but a crafted client can still call the mutation.
|
||||
*
|
||||
* Treats `isAdmin` bootstrap flag and the "Owner" role as granting
|
||||
* every permission, including ones that don't yet exist on the role
|
||||
* row. That keeps future permission additions working for the
|
||||
* original server owner without requiring a migration pass.
|
||||
*/
|
||||
export async function hasPermission(
|
||||
ctx: GenericQueryCtx<DataModel>,
|
||||
userId: Id<"userProfiles">,
|
||||
key: (typeof PERMISSION_KEYS)[number],
|
||||
): Promise<boolean> {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) return false;
|
||||
if (user.isAdmin) return true;
|
||||
const roles = await getRolesForUser(ctx, userId);
|
||||
if (roles.some((r) => r.name === "Owner")) return true;
|
||||
return roles.some(
|
||||
(r) => (r.permissions as Record<string, boolean> | undefined)?.[key] === true,
|
||||
);
|
||||
}
|
||||
|
||||
// List all roles
|
||||
export const list = query({
|
||||
args: {},
|
||||
@@ -249,15 +275,22 @@ export const getMyPermissions = query({
|
||||
move_members: v.boolean(),
|
||||
mute_members: v.boolean(),
|
||||
manage_nicknames: v.boolean(),
|
||||
ban_members: v.boolean(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
const roles = await getRolesForUser(ctx, args.userId);
|
||||
// isAdmin or Owner-role bearers get everything — same logic as
|
||||
// `hasPermission`. Keeps UI and server enforcement consistent.
|
||||
const isSuper = !!user?.isAdmin || roles.some((r) => r.name === "Owner");
|
||||
|
||||
const finalPerms: Record<string, boolean> = {};
|
||||
for (const key of PERMISSION_KEYS) {
|
||||
finalPerms[key] = roles.some(
|
||||
(role) => (role.permissions as Record<string, boolean>)?.[key]
|
||||
);
|
||||
finalPerms[key] =
|
||||
isSuper ||
|
||||
roles.some(
|
||||
(role) => (role.permissions as Record<string, boolean>)?.[key],
|
||||
);
|
||||
}
|
||||
|
||||
return finalPerms as {
|
||||
@@ -270,6 +303,7 @@ export const getMyPermissions = query({
|
||||
move_members: boolean;
|
||||
mute_members: boolean;
|
||||
manage_nicknames: boolean;
|
||||
ban_members: boolean;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export default defineSchema({
|
||||
customStatus: v.optional(v.string()),
|
||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerStorageId: v.optional(v.id("_storage")),
|
||||
}).index("by_username", ["username"]),
|
||||
|
||||
categories: defineTable({
|
||||
@@ -191,6 +192,31 @@ export default defineSchema({
|
||||
.index("by_poll_user_emoji", ["pollId", "userId", "emoji"])
|
||||
.index("by_user", ["userId"]),
|
||||
|
||||
// Banned users — presence of a row = login + send blocked for that
|
||||
// user. Unique by `userId` (enforced by the only caller path —
|
||||
// `bans.ban` does an upsert, not raw insert).
|
||||
bans: defineTable({
|
||||
userId: v.id("userProfiles"),
|
||||
bannedBy: v.id("userProfiles"),
|
||||
reason: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
}).index("by_user", ["userId"]),
|
||||
|
||||
// Append-only log of admin-ish actions: channel / role edits, bans,
|
||||
// server settings changes. Target fields are stringly-typed so
|
||||
// entries can point at any table without a discriminated union.
|
||||
auditLog: defineTable({
|
||||
actorId: v.id("userProfiles"),
|
||||
action: v.string(),
|
||||
targetType: v.optional(v.string()),
|
||||
targetId: v.optional(v.string()),
|
||||
targetName: v.optional(v.string()),
|
||||
metadata: v.optional(v.any()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_created_at", ["createdAt"])
|
||||
.index("by_actor", ["actorId"]),
|
||||
|
||||
savedMedia: defineTable({
|
||||
userId: v.id("userProfiles"),
|
||||
// Convex storage URL — also the dedupe key for a single user.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getRolesForUser } from "./roles";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
|
||||
export const get = query({
|
||||
args: {},
|
||||
@@ -58,6 +59,13 @@ export const update = mutation({
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit(ctx, {
|
||||
actorId: args.userId,
|
||||
action: AUDIT_ACTIONS.SERVER_SETTINGS_UPDATE,
|
||||
targetType: "server",
|
||||
metadata: { afkChannelId: args.afkChannelId, afkTimeout: args.afkTimeout },
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -85,6 +93,7 @@ export const updateName = mutation({
|
||||
}
|
||||
|
||||
const existing = await ctx.db.query("serverSettings").first();
|
||||
const oldName = existing?.serverName;
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { serverName: name });
|
||||
} else {
|
||||
@@ -94,6 +103,16 @@ export const updateName = mutation({
|
||||
});
|
||||
}
|
||||
|
||||
if (oldName !== name) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.userId,
|
||||
action: AUDIT_ACTIONS.SERVER_SETTINGS_UPDATE,
|
||||
targetType: "server",
|
||||
targetName: name,
|
||||
metadata: { field: "serverName", from: oldName, to: name },
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user