feat: add auth to convex fns

This commit is contained in:
2025-09-15 21:44:41 +00:00
parent f06064fc81
commit 8916142e04
10 changed files with 166 additions and 33 deletions

View File

@@ -21,6 +21,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.27.0",
"convex-helpers": "^0.1.104",
"jotai": "^2.14.0",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
@@ -453,6 +454,8 @@
"convex": ["convex@1.27.0", "", { "dependencies": { "esbuild": "0.25.4", "jwt-decode": "^4.0.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-IHkqZX3GtY4nKFPTAR4mvWHHhDiQX9PM7EjpEv0pJWoMoq0On6oOL3iZ7Xz4Ls96dF7WJd4AjfitJsg2hUnLSQ=="],
"convex-helpers": ["convex-helpers@0.1.104", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.24.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.22.4 || ^4.0.15" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-7CYvx7T3K6n+McDTK4ZQaQNNGBzq5aWezpjzsKbOxPXx7oNcTP9wrpef3JxeXWFzkByJv5hRCjseh9B7eNJ7Ig=="],
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -523,7 +526,7 @@
"jotai": ["jotai@2.14.0", "", { "peerDependencies": { "@babel/core": ">=7.0.0", "@babel/template": ">=7.0.0", "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@babel/core", "@babel/template", "@types/react", "react"] }, "sha512-JQkNkTnqjk1BlSUjHfXi+pGG/573bVN104gp6CymhrWDseZGDReTNniWrLhJ+zXbM6pH+82+UNJ2vwYQUkQMWQ=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -679,8 +682,6 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -695,6 +696,8 @@
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"tsdown/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],

View File

@@ -14,8 +14,10 @@ import type {
FunctionReference,
} from "convex/server";
import type * as files from "../files.js";
import type * as functions from "../functions.js";
import type * as model_directories from "../model/directories.js";
import type * as model_error from "../model/error.js";
import type * as model_user from "../model/user.js";
import type * as users from "../users.js";
/**
@@ -28,8 +30,10 @@ import type * as users from "../users.js";
*/
declare const fullApi: ApiFromModules<{
files: typeof files;
functions: typeof functions;
"model/directories": typeof model_directories;
"model/error": typeof model_error;
"model/user": typeof model_user;
users: typeof users;
}>;
export declare const api: FilterApi<

View File

@@ -1,16 +1,17 @@
import { v } from "convex/values"
import type { Id } from "./_generated/dataModel"
import { mutation, query } from "./_generated/server"
import { authenticatedMutation, authenticatedQuery } from "./functions"
import type { DirectoryItem } from "./model/directories"
import * as Directories from "./model/directories"
export const generateUploadUrl = mutation({
export const generateUploadUrl = authenticatedMutation({
handler: async (ctx) => {
// ctx.user and ctx.identity are automatically available
return await ctx.storage.generateUploadUrl()
},
})
export const fetchFiles = query({
export const fetchFiles = authenticatedQuery({
args: {
directoryId: v.optional(v.id("directories")),
},
@@ -18,49 +19,51 @@ export const fetchFiles = query({
return await ctx.db
.query("files")
.withIndex("byDirectoryId", (q) => q.eq("directoryId", directoryId))
.filter((q) => q.eq(q.field("userId"), ctx.user._id))
.collect()
},
})
export const fetchDirectoryContent = query({
export const fetchDirectoryContent = authenticatedQuery({
args: {
directoryId: v.optional(v.id("directories")),
},
handler: async (ctx, { directoryId }): Promise<DirectoryItem[]> => {
return await Directories.fetchContent(ctx, directoryId)
return await Directories.fetchContent(ctx, directoryId, ctx.user._id)
},
})
export const createDirectory = mutation({
export const createDirectory = authenticatedMutation({
args: {
name: v.string(),
directoryId: v.optional(v.id("directories")),
},
handler: async (ctx, { name, directoryId }): Promise<Id<"directories">> => {
return await Directories.create(ctx, { name, parentId: directoryId })
return await Directories.create(ctx, {
name,
parentId: directoryId,
userId: ctx.user._id,
})
},
})
export const saveFile = mutation({
export const saveFile = authenticatedMutation({
args: {
name: v.string(),
size: v.number(),
directoryId: v.optional(v.id("directories")),
storageId: v.id("_storage"),
userId: v.id("users"),
mimeType: v.optional(v.string()),
},
handler: async (
ctx,
{ name, storageId, directoryId, userId, size, mimeType },
) => {
handler: async (ctx, { name, storageId, directoryId, size, mimeType }) => {
const now = new Date().toISOString()
await ctx.db.insert("files", {
name,
size,
storageId,
directoryId,
userId,
userId: ctx.user._id,
mimeType,
createdAt: now,
updatedAt: now,
@@ -68,25 +71,37 @@ export const saveFile = mutation({
},
})
export const moveToTrash = mutation({
export const moveToTrash = authenticatedMutation({
args: {
kind: v.union(v.literal("file"), v.literal("directory")),
itemId: v.union(v.id("files"), v.id("directories")),
},
handler: async (ctx, { itemId, kind }) => {
// Verify ownership before allowing deletion
switch (kind) {
case "file":
case "file": {
const file = await ctx.db.get(itemId as Id<"files">)
if (!file || file.userId !== ctx.user._id) {
throw new Error("File not found or access denied")
}
await ctx.db.patch(itemId, {
deletedAt: new Date().toISOString(),
})
break
case "directory":
}
case "directory": {
const directory = await ctx.db.get(itemId as Id<"directories">)
if (!directory || directory.userId !== ctx.user._id) {
throw new Error("Directory not found or access denied")
}
await Directories.moveToTrashRecursive(
ctx,
itemId as Id<"directories">,
ctx.user._id,
)
break
}
}
return itemId
},

39
convex/functions.ts Normal file
View File

@@ -0,0 +1,39 @@
import {
customMutation,
customQuery,
} from "convex-helpers/server/customFunctions"
import { mutation, query } from "./_generated/server"
import { userIdentityOrThrow, userOrThrow } from "./model/user"
/**
* Custom query that automatically provides authenticated user context
* Throws an error if the user is not authenticated
*/
export const authenticatedQuery = customQuery(query, {
args: {},
input: async (ctx, args) => {
const user = await userOrThrow(ctx)
const identity = await userIdentityOrThrow(ctx)
return {
ctx: { ...ctx, user, identity },
args,
}
},
})
/**
* Custom mutation that automatically provides authenticated user context
* Throws an error if the user is not authenticated
*/
export const authenticatedMutation = customMutation(mutation, {
args: {},
input: async (ctx, args) => {
const user = await userOrThrow(ctx)
const identity = await userIdentityOrThrow(ctx)
return {
ctx: { ...ctx, user, identity },
args,
}
},
})

View File

@@ -18,6 +18,7 @@ export type DirectoryItemKind = DirectoryItem["kind"]
export async function fetchContent(
ctx: QueryCtx,
directoryId?: Id<"directories">,
userId?: Id<"users">,
): Promise<DirectoryItem[]> {
const [files, directories] = await Promise.all([
ctx.db
@@ -25,11 +26,13 @@ export async function fetchContent(
.withIndex("byDirectoryId", (q) =>
q.eq("directoryId", directoryId).eq("deletedAt", undefined),
)
.filter((q) => userId ? q.eq(q.field("userId"), userId) : q.neq(q.field("userId"), null))
.collect(),
ctx.db
.query("directories")
.withIndex("byParentId", (q) => q.eq("parentId", directoryId))
.filter((q) => q.eq(q.field("deletedAt"), undefined))
.filter((q) => userId ? q.eq(q.field("userId"), userId) : q.neq(q.field("userId"), null))
.collect(),
])
@@ -46,13 +49,25 @@ export async function fetchContent(
export async function create(
ctx: MutationCtx,
{ name, parentId }: { name: string; parentId?: Id<"directories"> },
{ name, parentId, userId }: { name: string; parentId?: Id<"directories">; userId: Id<"users"> },
): Promise<Id<"directories">> {
// Check if parent directory exists and belongs to user
if (parentId) {
const parentDir = await ctx.db.get(parentId)
if (!parentDir || parentDir.userId !== userId) {
throw Err.create(
Err.Code.DirectoryExists,
"Parent directory not found or access denied",
)
}
}
const existing = await ctx.db
.query("directories")
.withIndex("uniqueDirectoryInDirectory", (q) =>
q.eq("parentId", parentId).eq("name", name),
)
.filter((q) => q.eq(q.field("userId"), userId))
.first()
if (existing) {
@@ -66,6 +81,7 @@ export async function create(
return await ctx.db.insert("directories", {
name,
parentId,
userId,
createdAt: now,
updatedAt: now,
})
@@ -74,6 +90,7 @@ export async function create(
export async function moveToTrashRecursive(
ctx: MutationCtx,
directoryId: Id<"directories">,
userId: Id<"users">,
): Promise<void> {
const now = new Date().toISOString()
@@ -93,6 +110,7 @@ export async function moveToTrashRecursive(
.eq("directoryId", currentDirectoryId)
.eq("deletedAt", undefined),
)
.filter((q) => q.eq(q.field("userId"), userId))
.collect()
for (const file of files) {
@@ -104,6 +122,7 @@ export async function moveToTrashRecursive(
.withIndex("byParentId", (q) =>
q.eq("parentId", currentDirectoryId).eq("deletedAt", undefined),
)
.filter((q) => q.eq(q.field("userId"), userId))
.collect()
for (const subdirectory of subdirectories) {

View File

@@ -4,6 +4,7 @@ export enum Code {
DirectoryExists = "DirectoryExists",
FileExists = "FileExists",
Internal = "Internal",
Unauthenticated = "Unauthenticated",
}
export type ApplicationError = ConvexError<{ code: Code; message: string }>

38
convex/model/user.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { MutationCtx, QueryCtx } from "../_generated/server"
import * as Err from "./error"
/**
* Get the current authenticated user identity
* Throws an error if the user is not authenticated */
export async function userIdentityOrThrow(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity()
if (!identity) {
throw Err.create(Err.Code.Unauthenticated, "Not authenticated")
}
return identity
}
/**
* Get internal user document from JWT authentication
* Throws an error if the user is not authenticated
*/
export async function userOrThrow(ctx: QueryCtx | MutationCtx) {
const identity = await userIdentityOrThrow(ctx)
// Look for existing user by JWT subject
const user = await ctx.db
.query("users")
.withIndex("byJwtSubject", (q) => q.eq("jwtSubject", identity.subject))
.first()
if (!user) {
throw Err.create(
Err.Code.Unauthenticated,
"User not found - please sync user first",
)
}
return user
}

View File

@@ -3,11 +3,8 @@ import { v } from "convex/values"
const schema = defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
createdAt: v.string(),
updatedAt: v.string(),
}),
jwtSubject: v.optional(v.string()), // JWT subject from identity provider (optional for migration)
}).index("byJwtSubject", ["jwtSubject"]), // Unique index for JWT subject lookup
files: defineTable({
storageId: v.id("_storage"),
userId: v.id("users"),
@@ -20,6 +17,7 @@ const schema = defineSchema({
deletedAt: v.optional(v.string()),
})
.index("byDirectoryId", ["directoryId", "deletedAt"])
.index("byUserId", ["userId", "deletedAt"])
.index("byDeletedAt", ["deletedAt"])
.index("uniqueFileInDirectory", ["directoryId", "name", "deletedAt"]),
directories: defineTable({
@@ -30,7 +28,7 @@ const schema = defineSchema({
updatedAt: v.string(),
deletedAt: v.optional(v.string()),
})
.index("byUserId", ["userId"])
.index("byUserId", ["userId", "deletedAt"])
.index("byParentId", ["parentId", "deletedAt"])
.index("uniqueDirectoryInDirectory", ["parentId", "name", "deletedAt"]),
})

View File

@@ -1,10 +1,25 @@
import type { Id } from "./_generated/dataModel"
import { query } from "./_generated/server"
import { mutation } from "./_generated/server"
import { authenticatedQuery } from "./functions"
import { getOrCreateUser, userIdentityOrThrow } from "./model/user"
export const getCurrentUser = query({
export const getCurrentUser = authenticatedQuery({
handler: async (ctx) => {
return await ctx.db.get(
"j574n657f521n19v1stnr88ysd7qhbs1" as Id<"users">,
)
// ctx.user is the internal Convex user document
return ctx.user
},
})
export const syncUser = mutation({
handler: async (ctx) => {
// This function creates or updates the internal user from identity provider
const userId = await getOrCreateUser(ctx)
const identity = await userIdentityOrThrow(ctx)
return {
userId,
jwtSubject: identity.subject,
name: identity.name,
email: identity.email,
}
},
})

View File

@@ -26,6 +26,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.27.0",
"convex-helpers": "^0.1.104",
"jotai": "^2.14.0",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",