This commit is contained in:
6
convex/_generated/api.d.ts
vendored
6
convex/_generated/api.d.ts
vendored
@@ -9,6 +9,8 @@
|
||||
*/
|
||||
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as authActions from "../authActions.js";
|
||||
import type * as authGuard from "../authGuard.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as channelKeys from "../channelKeys.js";
|
||||
import type * as channels from "../channels.js";
|
||||
@@ -19,6 +21,7 @@ import type * as gifs from "../gifs.js";
|
||||
import type * as invites from "../invites.js";
|
||||
import type * as links from "../links.js";
|
||||
import type * as members from "../members.js";
|
||||
import type * as messageActions from "../messageActions.js";
|
||||
import type * as messages from "../messages.js";
|
||||
import type * as polls from "../polls.js";
|
||||
import type * as presence from "../presence.js";
|
||||
@@ -41,6 +44,8 @@ import type {
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
auth: typeof auth;
|
||||
authActions: typeof authActions;
|
||||
authGuard: typeof authGuard;
|
||||
categories: typeof categories;
|
||||
channelKeys: typeof channelKeys;
|
||||
channels: typeof channels;
|
||||
@@ -51,6 +56,7 @@ declare const fullApi: ApiFromModules<{
|
||||
invites: typeof invites;
|
||||
links: typeof links;
|
||||
members: typeof members;
|
||||
messageActions: typeof messageActions;
|
||||
messages: typeof messages;
|
||||
polls: typeof polls;
|
||||
presence: typeof presence;
|
||||
|
||||
@@ -123,11 +123,7 @@ export const createUserWithProfile = mutation({
|
||||
return { error: "Invite expired" };
|
||||
}
|
||||
|
||||
if (
|
||||
invite.maxUses !== undefined &&
|
||||
invite.maxUses !== null &&
|
||||
invite.uses >= invite.maxUses
|
||||
) {
|
||||
if (invite.maxUses !== undefined && invite.uses >= invite.maxUses) {
|
||||
return { error: "Invite max uses reached" };
|
||||
}
|
||||
|
||||
@@ -240,8 +236,12 @@ export const getPublicKeys = query({
|
||||
},
|
||||
});
|
||||
|
||||
// Update user profile (aboutMe, avatar, customStatus)
|
||||
export const updateProfile = mutation({
|
||||
// Internal writer. Public entry point: `authActions.updateProfile`. The
|
||||
// action layer verifies the caller controls `userId` via Ed25519
|
||||
// signature before this runs; calling this from another mutation
|
||||
// without that check would reintroduce the "anyone can change anyone's
|
||||
// profile" vulnerability.
|
||||
export const updateProfileInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id("userProfiles"),
|
||||
displayName: v.optional(v.string()),
|
||||
@@ -278,8 +278,8 @@ export const getMyJoinSoundUrl = query({
|
||||
},
|
||||
});
|
||||
|
||||
// Update user status
|
||||
export const updateStatus = mutation({
|
||||
// Internal writer. Public entry point: `authActions.updateStatus`.
|
||||
export const updateStatusInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id("userProfiles"),
|
||||
status: v.string(),
|
||||
@@ -343,8 +343,51 @@ export const getUserForRecovery = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
// Set nickname (displayName) for a user
|
||||
export const setNickname = mutation({
|
||||
// Internal: resolve a userId to the fields needed by the voice-token action
|
||||
// (server-side signature verification + LiveKit identity).
|
||||
export const getUserForVoiceToken = internalQuery({
|
||||
args: { userId: v.id("userProfiles") },
|
||||
returns: v.union(
|
||||
v.object({
|
||||
userId: v.id("userProfiles"),
|
||||
username: v.string(),
|
||||
publicSigningKey: v.string(),
|
||||
}),
|
||||
v.null()
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
if (!user) return null;
|
||||
return {
|
||||
userId: user._id,
|
||||
username: user.username,
|
||||
publicSigningKey: user.publicSigningKey,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Internal: fetch a channel for the voice-token action so it can confirm the
|
||||
// target channel exists and is actually a voice/dm room.
|
||||
export const getChannelForVoiceToken = internalQuery({
|
||||
args: { channelId: v.id("channels") },
|
||||
returns: v.union(
|
||||
v.object({
|
||||
channelId: v.id("channels"),
|
||||
type: v.string(),
|
||||
}),
|
||||
v.null()
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const channel = await ctx.db.get(args.channelId);
|
||||
if (!channel) return null;
|
||||
return { channelId: channel._id, type: channel.type };
|
||||
},
|
||||
});
|
||||
|
||||
// Internal writer. Public entry point: `authActions.setNickname`. The
|
||||
// action layer verifies the caller controls `actorUserId` via
|
||||
// signature; the existing self-or-manage_nicknames gate stays here.
|
||||
export const setNicknameInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("userProfiles"),
|
||||
targetUserId: v.id("userProfiles"),
|
||||
@@ -371,8 +414,10 @@ export const setNickname = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
// Delete a user and all their associated data (admin only)
|
||||
export const deleteUser = mutation({
|
||||
// Internal writer. Public entry point: `authActions.deleteUser`. Both
|
||||
// the isAdmin check and the destructive delete live here; the action
|
||||
// layer verifies the caller controls `requestingUserId`.
|
||||
export const deleteUserInternal = internalMutation({
|
||||
args: {
|
||||
requestingUserId: v.id("userProfiles"),
|
||||
targetUserId: v.id("userProfiles"),
|
||||
|
||||
109
convex/authActions.ts
Normal file
109
convex/authActions.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
"use node";
|
||||
|
||||
import { action } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { v } from "convex/values";
|
||||
import { requireAuth } from "./authGuard";
|
||||
|
||||
/**
|
||||
* Signed profile update. The canonical message binds userId + timestamp;
|
||||
* the 5-minute replay window limits damage if a signature leaks. See
|
||||
* `authGuard.requireAuth` for the full verification flow.
|
||||
*/
|
||||
export const updateProfile = action({
|
||||
args: {
|
||||
userId: v.id("userProfiles"),
|
||||
displayName: v.optional(v.string()),
|
||||
aboutMe: v.optional(v.string()),
|
||||
avatarStorageId: v.optional(v.id("_storage")),
|
||||
customStatus: v.optional(v.string()),
|
||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||
removeJoinSound: v.optional(v.boolean()),
|
||||
accentColor: v.optional(v.string()),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const canonical = `updateProfile:${args.userId}:${args.authTimestamp}`;
|
||||
await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical);
|
||||
await ctx.runMutation(internal.auth.updateProfileInternal, {
|
||||
userId: args.userId,
|
||||
displayName: args.displayName,
|
||||
aboutMe: args.aboutMe,
|
||||
avatarStorageId: args.avatarStorageId,
|
||||
customStatus: args.customStatus,
|
||||
joinSoundStorageId: args.joinSoundStorageId,
|
||||
removeJoinSound: args.removeJoinSound,
|
||||
accentColor: args.accentColor,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const updateStatus = action({
|
||||
args: {
|
||||
userId: v.id("userProfiles"),
|
||||
status: v.string(),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const canonical = `updateStatus:${args.userId}:${args.status}:${args.authTimestamp}`;
|
||||
await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical);
|
||||
await ctx.runMutation(internal.auth.updateStatusInternal, {
|
||||
userId: args.userId,
|
||||
status: args.status,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const setNickname = action({
|
||||
args: {
|
||||
actorUserId: v.id("userProfiles"),
|
||||
targetUserId: v.id("userProfiles"),
|
||||
displayName: v.string(),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const canonical = `setNickname:${args.actorUserId}:${args.targetUserId}:${args.authTimestamp}`;
|
||||
await requireAuth(ctx, args.actorUserId, args.authTimestamp, args.authSignature, canonical);
|
||||
await ctx.runMutation(internal.auth.setNicknameInternal, {
|
||||
actorUserId: args.actorUserId,
|
||||
targetUserId: args.targetUserId,
|
||||
displayName: args.displayName,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteUser = action({
|
||||
args: {
|
||||
requestingUserId: v.id("userProfiles"),
|
||||
targetUserId: v.id("userProfiles"),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.object({ success: v.boolean(), error: v.optional(v.string()) }),
|
||||
handler: async (
|
||||
ctx,
|
||||
args,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
const canonical = `deleteUser:${args.requestingUserId}:${args.targetUserId}:${args.authTimestamp}`;
|
||||
await requireAuth(
|
||||
ctx,
|
||||
args.requestingUserId,
|
||||
args.authTimestamp,
|
||||
args.authSignature,
|
||||
canonical,
|
||||
);
|
||||
return await ctx.runMutation(internal.auth.deleteUserInternal, {
|
||||
requestingUserId: args.requestingUserId,
|
||||
targetUserId: args.targetUserId,
|
||||
});
|
||||
},
|
||||
});
|
||||
63
convex/authGuard.ts
Normal file
63
convex/authGuard.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
"use node";
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Detached Ed25519 check: caller must sign `canonicalMessage` with the
|
||||
* Ed25519 private key that pairs with the userProfile's stored
|
||||
* `publicSigningKey`. Matches `voice.getToken` and
|
||||
* `recovery.resetPasswordAction` — same window, same SPKI/PEM encoding,
|
||||
* same hex signature format — so client-side signing code looks
|
||||
* identical across every sensitive call.
|
||||
*
|
||||
* Throws on failure. Sensitive mutations that previously trusted a raw
|
||||
* `userId` arg should now route through a "use node" action that calls
|
||||
* this helper before running the internal mutation.
|
||||
*/
|
||||
export async function requireAuth(
|
||||
ctx: ActionCtx,
|
||||
userId: Id<"userProfiles">,
|
||||
timestamp: number,
|
||||
signature: string,
|
||||
canonicalMessage: string,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!Number.isFinite(timestamp) ||
|
||||
Math.abs(Date.now() - timestamp) > MAX_CLOCK_SKEW_MS
|
||||
) {
|
||||
throw new Error("Request expired. Please try again.");
|
||||
}
|
||||
|
||||
const user = await ctx.runQuery(internal.auth.getUserForVoiceToken, {
|
||||
userId,
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
try {
|
||||
const publicKeyObj = crypto.createPublicKey({
|
||||
key: user.publicSigningKey,
|
||||
format: "pem",
|
||||
type: "spki",
|
||||
});
|
||||
isValid = crypto.verify(
|
||||
null,
|
||||
Buffer.from(canonicalMessage),
|
||||
publicKeyObj,
|
||||
Buffer.from(signature, "hex"),
|
||||
);
|
||||
} catch {
|
||||
throw new Error("Signature verification failed");
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("Invalid signature");
|
||||
}
|
||||
}
|
||||
@@ -51,11 +51,7 @@ export const use = query({
|
||||
return { error: "Invite expired" };
|
||||
}
|
||||
|
||||
if (
|
||||
invite.maxUses !== undefined &&
|
||||
invite.maxUses !== null &&
|
||||
invite.uses >= invite.maxUses
|
||||
) {
|
||||
if (invite.maxUses !== undefined && invite.uses >= invite.maxUses) {
|
||||
return { error: "Invite max uses reached" };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ export const fetchPreview = action({
|
||||
description: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
siteName: v.optional(v.string()),
|
||||
// Image dimensions sourced from og:image:width/height or
|
||||
// twitter:image:width/height when the page emits them. The client
|
||||
// uses these to reserve the preview card's image slot *before* the
|
||||
// image decodes, eliminating the height shift that used to expand
|
||||
// the card on first paint.
|
||||
imageWidth: v.optional(v.number()),
|
||||
imageHeight: v.optional(v.number()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -89,6 +96,26 @@ export const fetchPreview = action({
|
||||
const siteName =
|
||||
pick(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i);
|
||||
|
||||
const pickNum = (re: RegExp): number | undefined => {
|
||||
const raw = pick(re);
|
||||
if (!raw) return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
|
||||
};
|
||||
// og: and twitter: tags both publish the intended image dimensions
|
||||
// in their own namespaces — use whichever is available. The attr
|
||||
// order in HTML varies (some sites emit `content="w"` before
|
||||
// `property=...`), so fall back to a second regex with the
|
||||
// attributes swapped.
|
||||
const imageWidth =
|
||||
pickNum(/<meta[^>]+property=["']og:image:width["'][^>]+content=["']([^"']+)["']/i) ??
|
||||
pickNum(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:width["']/i) ??
|
||||
pickNum(/<meta[^>]+name=["']twitter:image:width["'][^>]+content=["']([^"']+)["']/i);
|
||||
const imageHeight =
|
||||
pickNum(/<meta[^>]+property=["']og:image:height["'][^>]+content=["']([^"']+)["']/i) ??
|
||||
pickNum(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:height["']/i) ??
|
||||
pickNum(/<meta[^>]+name=["']twitter:image:height["'][^>]+content=["']([^"']+)["']/i);
|
||||
|
||||
// Resolve relative image URLs
|
||||
if (image) {
|
||||
try {
|
||||
@@ -97,7 +124,15 @@ export const fetchPreview = action({
|
||||
}
|
||||
|
||||
if (!title && !description && !image) return null;
|
||||
return { url: u.toString(), title, description, image, siteName };
|
||||
return {
|
||||
url: u.toString(),
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
siteName,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
109
convex/messageActions.ts
Normal file
109
convex/messageActions.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
"use node";
|
||||
|
||||
import { action } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { v } from "convex/values";
|
||||
import { requireAuth } from "./authGuard";
|
||||
|
||||
/**
|
||||
* Signed-send: the client signs `send:${channelId}:${senderId}:${timestamp}`
|
||||
* with their Ed25519 key so the server can prove the caller controls
|
||||
* `senderId`. Prevents the "anyone posts as anyone" bypass that existed
|
||||
* when `messages.send` was a plain mutation trusting the client-supplied
|
||||
* `senderId`.
|
||||
*
|
||||
* The per-message `signature` over the ciphertext is a separate,
|
||||
* recipient-verified integrity check and is unchanged.
|
||||
*/
|
||||
export const send = action({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
senderId: v.id("userProfiles"),
|
||||
ciphertext: v.string(),
|
||||
nonce: v.string(),
|
||||
signature: v.string(),
|
||||
keyVersion: v.number(),
|
||||
replyTo: v.optional(v.id("messages")),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.object({ id: v.id("messages") }),
|
||||
handler: async (ctx, args): Promise<{ id: any }> => {
|
||||
const canonical = `send:${args.channelId}:${args.senderId}:${args.authTimestamp}`;
|
||||
await requireAuth(ctx, args.senderId, args.authTimestamp, args.authSignature, canonical);
|
||||
return await ctx.runMutation(internal.messages.sendInternal, {
|
||||
channelId: args.channelId,
|
||||
senderId: args.senderId,
|
||||
ciphertext: args.ciphertext,
|
||||
nonce: args.nonce,
|
||||
signature: args.signature,
|
||||
keyVersion: args.keyVersion,
|
||||
replyTo: args.replyTo,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const edit = action({
|
||||
args: {
|
||||
id: v.id("messages"),
|
||||
userId: v.id("userProfiles"),
|
||||
ciphertext: v.string(),
|
||||
nonce: v.string(),
|
||||
signature: v.string(),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const canonical = `edit:${args.id}:${args.userId}:${args.authTimestamp}`;
|
||||
await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical);
|
||||
await ctx.runMutation(internal.messages.editInternal, {
|
||||
id: args.id,
|
||||
userId: args.userId,
|
||||
ciphertext: args.ciphertext,
|
||||
nonce: args.nonce,
|
||||
signature: args.signature,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const pin = action({
|
||||
args: {
|
||||
id: v.id("messages"),
|
||||
userId: v.id("userProfiles"),
|
||||
pinned: v.boolean(),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const canonical = `pin:${args.id}:${args.userId}:${args.pinned}:${args.authTimestamp}`;
|
||||
await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical);
|
||||
await ctx.runMutation(internal.messages.pinInternal, {
|
||||
id: args.id,
|
||||
userId: args.userId,
|
||||
pinned: args.pinned,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = action({
|
||||
args: {
|
||||
id: v.id("messages"),
|
||||
userId: v.id("userProfiles"),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args): Promise<null> => {
|
||||
const canonical = `remove:${args.id}:${args.userId}:${args.authTimestamp}`;
|
||||
await requireAuth(ctx, args.userId, args.authTimestamp, args.authSignature, canonical);
|
||||
await ctx.runMutation(internal.messages.removeInternal, {
|
||||
id: args.id,
|
||||
userId: args.userId,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { query, internalMutation } from "./_generated/server";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
@@ -225,7 +225,19 @@ export const searchScan = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const send = mutation({
|
||||
// Plain text caps out at 4000 chars in the composer, which encodes to
|
||||
// well under 8KB of AES-GCM output. Attachments ride a separate
|
||||
// metadata-only JSON path, so 64KB leaves ~8× headroom for future
|
||||
// structured content without letting a rogue client write multi-MB
|
||||
// rows and bloat the database.
|
||||
const MAX_CIPHERTEXT_CHARS = 64 * 1024;
|
||||
|
||||
// Internal write. The public API is `messageActions.send`, which verifies
|
||||
// the caller's Ed25519 signature over (channelId, senderId, timestamp)
|
||||
// before invoking this. Never call this from a public mutation — it
|
||||
// trusts `senderId` completely and doing so would reintroduce the
|
||||
// spoofing vulnerability.
|
||||
export const sendInternal = internalMutation({
|
||||
args: {
|
||||
channelId: v.id("channels"),
|
||||
senderId: v.id("userProfiles"),
|
||||
@@ -237,6 +249,9 @@ export const send = mutation({
|
||||
},
|
||||
returns: v.object({ id: v.id("messages") }),
|
||||
handler: async (ctx, args) => {
|
||||
if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) {
|
||||
throw new Error("Message too large");
|
||||
}
|
||||
const id = await ctx.db.insert("messages", {
|
||||
channelId: args.channelId,
|
||||
senderId: args.senderId,
|
||||
@@ -250,7 +265,11 @@ export const send = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const sendBatch = mutation({
|
||||
// Internal-only: there's no public caller and no way to verify every
|
||||
// senderId in a batch without a signature per message, which defeats
|
||||
// the point of batching. Kept as internalMutation so internal seed
|
||||
// scripts / migrations can still use it.
|
||||
export const sendBatchInternal = internalMutation({
|
||||
args: {
|
||||
messages: v.array(v.object({
|
||||
channelId: v.id("channels"),
|
||||
@@ -270,15 +289,24 @@ export const sendBatch = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const edit = mutation({
|
||||
// Internal write — public entry point is `messageActions.edit`.
|
||||
// Enforces that only the original sender can edit. The action layer
|
||||
// has already verified the caller controls `userId`.
|
||||
export const editInternal = internalMutation({
|
||||
args: {
|
||||
id: v.id("messages"),
|
||||
userId: v.id("userProfiles"),
|
||||
ciphertext: v.string(),
|
||||
nonce: v.string(),
|
||||
signature: v.string(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const msg = await ctx.db.get(args.id);
|
||||
if (!msg) throw new Error("Message not found");
|
||||
if (msg.senderId !== args.userId) {
|
||||
throw new Error("Only the author can edit this message");
|
||||
}
|
||||
await ctx.db.patch(args.id, {
|
||||
ciphertext: args.ciphertext,
|
||||
nonce: args.nonce,
|
||||
@@ -289,13 +317,24 @@ export const edit = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const pin = mutation({
|
||||
// Internal write — public entry point is `messageActions.pin`. Only
|
||||
// `manage_messages` role-holders can pin/unpin; the action layer has
|
||||
// already verified the caller controls `userId`.
|
||||
export const pinInternal = internalMutation({
|
||||
args: {
|
||||
id: v.id("messages"),
|
||||
userId: v.id("userProfiles"),
|
||||
pinned: v.boolean(),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const roles = await getRolesForUser(ctx, args.userId);
|
||||
const canManage = roles.some(
|
||||
(role) => (role.permissions as Record<string, boolean>)?.manage_messages,
|
||||
);
|
||||
if (!canManage) {
|
||||
throw new Error("Not authorized to pin messages");
|
||||
}
|
||||
await ctx.db.patch(args.id, { pinned: args.pinned });
|
||||
return null;
|
||||
},
|
||||
@@ -460,7 +499,11 @@ export const listAfter = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
// Internal write — public entry point is `messageActions.remove`. The
|
||||
// existing isSender-or-manage_messages check stays here; the action
|
||||
// layer verifies the caller actually controls `userId` before we trust
|
||||
// that arg.
|
||||
export const removeInternal = internalMutation({
|
||||
args: { id: v.id("messages"), userId: v.id("userProfiles") },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -107,7 +107,8 @@ export default defineSchema({
|
||||
username: v.string(),
|
||||
expiresAt: v.number(), // timestamp
|
||||
}).index("by_channel", ["channelId"])
|
||||
.index("by_user", ["userId"]),
|
||||
.index("by_user", ["userId"])
|
||||
.index("by_expires_at", ["expiresAt"]),
|
||||
|
||||
voiceStates: defineTable({
|
||||
channelId: v.id("channels"),
|
||||
|
||||
@@ -22,6 +22,11 @@ export const startTyping = mutation({
|
||||
const userTyping = existing.find((t) => t.userId === args.userId);
|
||||
|
||||
if (userTyping) {
|
||||
// Refreshing an existing row — the cleanup scheduled from the
|
||||
// original insert is still pending, so don't pile another copy
|
||||
// onto the scheduler. The audit flagged the old code (schedule
|
||||
// on every heartbeat) as spamming cleanExpired tasks that all
|
||||
// did the same work.
|
||||
await ctx.db.patch(userTyping._id, { expiresAt });
|
||||
} else {
|
||||
await ctx.db.insert("typingIndicators", {
|
||||
@@ -30,9 +35,9 @@ export const startTyping = mutation({
|
||||
username: args.username,
|
||||
expiresAt,
|
||||
});
|
||||
await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {});
|
||||
}
|
||||
|
||||
await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -93,11 +98,15 @@ export const cleanExpired = internalMutation({
|
||||
returns: v.null(),
|
||||
handler: async (ctx) => {
|
||||
const now = Date.now();
|
||||
const expired = await ctx.db.query("typingIndicators").collect();
|
||||
// Range-scan the by_expires_at index instead of collecting the whole
|
||||
// table, so cleanup cost scales with number of *expired* rows rather
|
||||
// than total typing activity across every channel.
|
||||
const expired = await ctx.db
|
||||
.query("typingIndicators")
|
||||
.withIndex("by_expires_at", (q) => q.lte("expiresAt", now))
|
||||
.collect();
|
||||
for (const t of expired) {
|
||||
if (t.expiresAt <= now) {
|
||||
await ctx.db.delete(t._id);
|
||||
}
|
||||
await ctx.db.delete(t._id);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
122
convex/voice.ts
122
convex/voice.ts
@@ -1,12 +1,48 @@
|
||||
"use node";
|
||||
|
||||
import { action } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { v } from "convex/values";
|
||||
import { AccessToken, RoomServiceClient } from "livekit-server-sdk";
|
||||
import crypto from "crypto";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
|
||||
// Shapes of the internal queries below, spelled out so voice.ts can typecheck
|
||||
// without depending on `internal.auth.*` inference — otherwise the codegen
|
||||
// cycle (voice exports -> _generated/api -> internal.auth typing -> voice
|
||||
// usage) drops back to `any` and TS7022/TS7023 fire on the whole action.
|
||||
type VoiceTokenUser = {
|
||||
userId: Id<"userProfiles">;
|
||||
username: string;
|
||||
publicSigningKey: string;
|
||||
};
|
||||
type VoiceTokenChannel = {
|
||||
channelId: Id<"channels">;
|
||||
type: string;
|
||||
};
|
||||
type VoiceTokenResult = { token: string } | { error: string };
|
||||
|
||||
// Signed message the client must sign with their Ed25519 private signing key
|
||||
// to prove they control the userId they're asking a token for. `timestamp`
|
||||
// defeats replay; the channelId binds the signature to a specific room.
|
||||
function buildVoiceTokenMessage(
|
||||
userId: string,
|
||||
channelId: string,
|
||||
timestamp: number,
|
||||
): string {
|
||||
return `voice-token:${userId}:${channelId}:${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a LiveKit join token for a voice channel.
|
||||
*
|
||||
* Authorization: caller signs `voice-token:userId:channelId:timestamp` with
|
||||
* their Ed25519 signing key. The server verifies against the userProfile's
|
||||
* `publicSigningKey`, rejects stale timestamps, and confirms the channel
|
||||
* exists and is a voice/dm room. The LiveKit identity is pinned to the
|
||||
* server-resolved user so clients can't impersonate each other even with a
|
||||
* valid signature for their own account.
|
||||
*
|
||||
* LiveKit servers run with `room.auto_create: false` reject joins for
|
||||
* rooms that don't already exist — the client gets a 404 "requested
|
||||
* room does not exist" back from the /rtc/v1/validate endpoint. To
|
||||
@@ -22,12 +58,65 @@ import { AccessToken, RoomServiceClient } from "livekit-server-sdk";
|
||||
*/
|
||||
export const getToken = action({
|
||||
args: {
|
||||
channelId: v.string(),
|
||||
userId: v.string(),
|
||||
username: v.string(),
|
||||
channelId: v.id("channels"),
|
||||
userId: v.id("userProfiles"),
|
||||
timestamp: v.number(),
|
||||
signature: v.string(),
|
||||
},
|
||||
returns: v.object({ token: v.string() }),
|
||||
handler: async (_ctx, args) => {
|
||||
returns: v.union(
|
||||
v.object({ token: v.string() }),
|
||||
v.object({ error: v.string() }),
|
||||
),
|
||||
handler: async (ctx, args): Promise<VoiceTokenResult> => {
|
||||
// Reject timestamps outside a 5-minute window on either side of the
|
||||
// server clock. Matches the recovery action's window and keeps replay
|
||||
// attempts short-lived even if a signature leaks.
|
||||
const now = Date.now();
|
||||
if (!Number.isFinite(args.timestamp) || Math.abs(now - args.timestamp) > 5 * 60 * 1000) {
|
||||
return { error: "Request expired. Please try again." };
|
||||
}
|
||||
|
||||
const user: VoiceTokenUser | null = await ctx.runQuery(
|
||||
internal.auth.getUserForVoiceToken,
|
||||
{ userId: args.userId },
|
||||
);
|
||||
if (!user) {
|
||||
return { error: "User not found" };
|
||||
}
|
||||
|
||||
const channel: VoiceTokenChannel | null = await ctx.runQuery(
|
||||
internal.auth.getChannelForVoiceToken,
|
||||
{ channelId: args.channelId },
|
||||
);
|
||||
if (!channel) {
|
||||
return { error: "Channel not found" };
|
||||
}
|
||||
if (channel.type !== "voice" && channel.type !== "dm") {
|
||||
return { error: "Not a voice channel" };
|
||||
}
|
||||
|
||||
// Verify the caller actually controls `userId` by checking their
|
||||
// signature over (userId, channelId, timestamp).
|
||||
const message = buildVoiceTokenMessage(user.userId, channel.channelId, args.timestamp);
|
||||
try {
|
||||
const publicKeyObj = crypto.createPublicKey({
|
||||
key: user.publicSigningKey,
|
||||
format: "pem",
|
||||
type: "spki",
|
||||
});
|
||||
const isValid = crypto.verify(
|
||||
null,
|
||||
Buffer.from(message),
|
||||
publicKeyObj,
|
||||
Buffer.from(args.signature, "hex"),
|
||||
);
|
||||
if (!isValid) {
|
||||
return { error: "Invalid signature" };
|
||||
}
|
||||
} catch {
|
||||
return { error: "Signature verification failed" };
|
||||
}
|
||||
|
||||
const apiKey = process.env.LIVEKIT_API_KEY || "devkey";
|
||||
const apiSecret = process.env.LIVEKIT_API_SECRET || "secret";
|
||||
const livekitUrl =
|
||||
@@ -43,7 +132,7 @@ export const getToken = action({
|
||||
try {
|
||||
const roomService = new RoomServiceClient(httpUrl, apiKey, apiSecret);
|
||||
await roomService.createRoom({
|
||||
name: args.channelId,
|
||||
name: channel.channelId,
|
||||
// Empty rooms auto-destroy after 5 minutes with no participants,
|
||||
// matching LiveKit's own default so stale rooms from a crashed
|
||||
// client don't pile up forever.
|
||||
@@ -56,36 +145,39 @@ export const getToken = action({
|
||||
} catch (err: any) {
|
||||
// 409 / "already exists" is expected when a room has already
|
||||
// been created by an earlier join — swallow it and continue.
|
||||
const message = String(err?.message ?? err ?? "");
|
||||
const errMsg = String(err?.message ?? err ?? "");
|
||||
const status = err?.status ?? err?.statusCode;
|
||||
const alreadyExists =
|
||||
status === 409 ||
|
||||
/already exists/i.test(message) ||
|
||||
/AlreadyExists/i.test(message);
|
||||
/already exists/i.test(errMsg) ||
|
||||
/AlreadyExists/i.test(errMsg);
|
||||
if (!alreadyExists) {
|
||||
// Non-fatal: log and fall through to token generation. If the
|
||||
// real issue was misconfiguration the client will surface the
|
||||
// 404 it already does.
|
||||
console.warn("LiveKit createRoom failed:", message);
|
||||
console.warn("LiveKit createRoom failed:", errMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const at = new AccessToken(apiKey, apiSecret, {
|
||||
identity: args.userId,
|
||||
name: args.username,
|
||||
// Pin identity + name to the server-resolved user. A forged `username`
|
||||
// or `userId` in the args would have already been rejected by the
|
||||
// signature check, but using the DB values is defence-in-depth.
|
||||
const at: AccessToken = new AccessToken(apiKey, apiSecret, {
|
||||
identity: user.userId,
|
||||
name: user.username,
|
||||
ttl: "24h",
|
||||
});
|
||||
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: args.channelId,
|
||||
room: channel.channelId,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
|
||||
const token = await at.toJwt();
|
||||
const token: string = await at.toJwt();
|
||||
return { token };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user