"use node"; import { action } from "./_generated/server"; import { internal } from "./_generated/api"; import { v } from "convex/values"; import { requireAuth } from "./authGuard"; /** * Signed-import actions. * * The same `requireAuth` pattern used for `messageActions.send`: * the admin signs a canonical string with their Ed25519 key so * the server can prove the caller controls `actorId` before any * ghost is created, any message is attributed to someone, or any * ghost is merged into a real user. Without these signatures an * attacker who knows the admin's userId could spoof `actorId` and * piggy-back on the admin's `manage_channels` permission. */ export const prepareGhostsAction = action({ args: { actorId: v.id("userProfiles"), authors: v.array( v.object({ discordId: v.string(), username: v.string(), displayName: v.optional(v.string()), avatarUrl: v.optional(v.string()), }), ), authTimestamp: v.number(), authSignature: v.string(), }, returns: v.array( v.object({ discordId: v.string(), userId: v.id("userProfiles"), created: v.boolean(), isGhost: v.boolean(), }), ), handler: async (ctx, args): Promise => { // Signature covers the actor + count so a replayed sig can't be // redirected at a larger author list. const canonical = `prepareGhosts:${args.actorId}:${args.authors.length}:${args.authTimestamp}`; await requireAuth( ctx, args.actorId, args.authTimestamp, args.authSignature, canonical, ); return await ctx.runMutation(internal.importer.ensureGhostsInternal, { authors: args.authors, }); }, }); export const importBatchAction = action({ args: { actorId: v.id("userProfiles"), channelId: v.id("channels"), messages: v.array( v.object({ senderId: v.id("userProfiles"), ciphertext: v.string(), nonce: v.string(), signature: v.string(), keyVersion: v.number(), replyTo: v.optional(v.id("messages")), importedCreatedAt: v.number(), discordMessageId: v.string(), }), ), authTimestamp: v.number(), authSignature: v.string(), }, returns: v.object({ inserted: v.number(), skipped: v.number(), resolved: v.array( v.object({ discordMessageId: v.string(), messageId: v.id("messages"), }), ), }), handler: async (ctx, args): Promise => { const canonical = `importBatch:${args.actorId}:${args.channelId}:${args.messages.length}:${args.authTimestamp}`; await requireAuth( ctx, args.actorId, args.authTimestamp, args.authSignature, canonical, ); return await ctx.runMutation(internal.importer.importBatchInternal, { actorId: args.actorId, channelId: args.channelId, messages: args.messages, }); }, }); /** * Merge a ghost into a real user. Paged so any ghost size is * supported — the client polls this in a loop until `done: true`, * accumulating the rewritten count, then calls `finalizeMerge`. */ export const mergeGhostPageAction = action({ args: { actorId: v.id("userProfiles"), ghostUserId: v.id("userProfiles"), targetUserId: v.id("userProfiles"), authTimestamp: v.number(), authSignature: v.string(), }, returns: v.object({ rewritten: v.number(), done: v.boolean() }), handler: async (ctx, args): Promise => { const canonical = `mergeGhostPage:${args.actorId}:${args.ghostUserId}:${args.targetUserId}:${args.authTimestamp}`; await requireAuth( ctx, args.actorId, args.authTimestamp, args.authSignature, canonical, ); return await ctx.runMutation(internal.importer.mergeGhostPageInternal, { actorId: args.actorId, ghostUserId: args.ghostUserId, targetUserId: args.targetUserId, }); }, }); /** * Repair-mode helper: delete a specific set of imported messages * by Discord snowflake. The runner calls this right before it * re-inserts the same rows (with their attachments) under normal * import flow. Capped at 100 ids per call to match the internal * mutation's `DELETE_BY_DISCORD_PAGE`. */ export const deleteImportedByDiscordIdsAction = action({ args: { actorId: v.id("userProfiles"), channelId: v.id("channels"), discordMessageIds: v.array(v.string()), authTimestamp: v.number(), authSignature: v.string(), }, returns: v.object({ deleted: v.number() }), handler: async (ctx, args): Promise<{ deleted: number }> => { const canonical = `deleteImportedByDiscordIds:${args.actorId}:${args.channelId}:${args.discordMessageIds.length}:${args.authTimestamp}`; await requireAuth( ctx, args.actorId, args.authTimestamp, args.authSignature, canonical, ); return await ctx.runMutation( internal.importer.deleteImportedByDiscordIdsInternal, { actorId: args.actorId, channelId: args.channelId, discordMessageIds: args.discordMessageIds, }, ); }, }); /** * Wipe every imported message in a channel. Paged so any volume * succeeds — the action loops over `clearChannelImportsPageInternal` * until it reports `done`, then writes a single audit entry. Used * by the Import tab's per-channel "Clear imports" button so a * partial earlier run can be wiped + re-imported cleanly. */ export const clearChannelImportsAction = action({ args: { actorId: v.id("userProfiles"), channelId: v.id("channels"), authTimestamp: v.number(), authSignature: v.string(), }, returns: v.object({ deleted: v.number() }), handler: async (ctx, args): Promise<{ deleted: number }> => { const canonical = `clearChannelImports:${args.actorId}:${args.channelId}:${args.authTimestamp}`; await requireAuth( ctx, args.actorId, args.authTimestamp, args.authSignature, canonical, ); let total = 0; for (;;) { const page: { deleted: number; done: boolean } = await ctx.runMutation( internal.importer.clearChannelImportsPageInternal, { actorId: args.actorId, channelId: args.channelId }, ); total += page.deleted; if (page.done) break; } await ctx.runMutation(internal.importer.finalizeClearImportsInternal, { actorId: args.actorId, channelId: args.channelId, totalDeleted: total, }); return { deleted: total }; }, }); export const finalizeMergeAction = action({ args: { actorId: v.id("userProfiles"), ghostUserId: v.id("userProfiles"), targetUserId: v.id("userProfiles"), totalRewritten: v.number(), authTimestamp: v.number(), authSignature: v.string(), }, returns: v.null(), handler: async (ctx, args): Promise => { const canonical = `finalizeMerge:${args.actorId}:${args.ghostUserId}:${args.targetUserId}:${args.authTimestamp}`; await requireAuth( ctx, args.actorId, args.authTimestamp, args.authSignature, canonical, ); await ctx.runMutation(internal.importer.finalizeMergeInternal, { actorId: args.actorId, ghostUserId: args.ghostUserId, targetUserId: args.targetUserId, totalRewritten: args.totalRewritten, }); return null; }, });