saito200
saito20011mo ago

Auth with chained actions and mutations?

I'm using a mutation that schedules an action. This action runs a bunch of actions one after the other. The first action runs a public mutation which has an authentication guard (a functiont that throws if the user is not auth). This function is throwing an error even if the user is logged in
7 Replies
saito200
saito200OP11mo ago
This is an example of pseudocode:
// 1. client calls this mutation
export const processRecords = mutation({
args: {
rawInput: v.string()
},
handler: async (ctx, { rawInput }) => {
await ctx.scheduler.runAfter(0, internal.service.processRaw, { rawInput })
}
})

// 2. Mutation calls this action, which needs to run a bunch of actions and mutations in order. I'm not using the scheduler here because each needs to wait for the previous to finish (is this correct?)
export const processRaw = internalAction({
args: {
rawInput: v.string(),
},
handler: async (ctx, { rawInput }) => {
const parsedInputIds = await ctx.runAction(internal.service.parseRaw, { rawInput })
// ... other actions that need to run after the above is done
}
})

// 3. this action gets some API data and runs a mutation directly
export const parseRaw = internalAction({
args: {
rawInput: v.string(),
},
handler: async (ctx, { rawInput }) => {
const response = await fetchSomeApi(rawInput)

const newIds = await Promise.all(response.records.map((body) => {
return ctx.runMutation(api.service.create, { body })
}))

return newIds
}
})

// 4. this public mutation is called and errors out
export const create = mutation({
args: {
body: v.string(),
},
handler: async (ctx, { body }) => {
const user = await getAuthedUser(ctx)
return await ctx.db.insert("ideas", {
body,
userId: user._id
})
},
});

export async function authGuard(ctx: QueryCtx | ActionCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (identity === null) {
throw new Error("Unauthenticated server call"); // this error is triggering, for some reason (but the user is authed)
}

return identity;
}
// 1. client calls this mutation
export const processRecords = mutation({
args: {
rawInput: v.string()
},
handler: async (ctx, { rawInput }) => {
await ctx.scheduler.runAfter(0, internal.service.processRaw, { rawInput })
}
})

// 2. Mutation calls this action, which needs to run a bunch of actions and mutations in order. I'm not using the scheduler here because each needs to wait for the previous to finish (is this correct?)
export const processRaw = internalAction({
args: {
rawInput: v.string(),
},
handler: async (ctx, { rawInput }) => {
const parsedInputIds = await ctx.runAction(internal.service.parseRaw, { rawInput })
// ... other actions that need to run after the above is done
}
})

// 3. this action gets some API data and runs a mutation directly
export const parseRaw = internalAction({
args: {
rawInput: v.string(),
},
handler: async (ctx, { rawInput }) => {
const response = await fetchSomeApi(rawInput)

const newIds = await Promise.all(response.records.map((body) => {
return ctx.runMutation(api.service.create, { body })
}))

return newIds
}
})

// 4. this public mutation is called and errors out
export const create = mutation({
args: {
body: v.string(),
},
handler: async (ctx, { body }) => {
const user = await getAuthedUser(ctx)
return await ctx.db.insert("ideas", {
body,
userId: user._id
})
},
});

export async function authGuard(ctx: QueryCtx | ActionCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (identity === null) {
throw new Error("Unauthenticated server call"); // this error is triggering, for some reason (but the user is authed)
}

return identity;
}
(continue)
export async function getAuthedUser(ctx: QueryCtx | MutationCtx) {
const { tokenIdentifier } = await authGuard(ctx);

const user = await ctx.db
.query("users")
.withIndex("byClerkId", (q) => q.eq("clerkId", tokenIdentifier))
.unique();

if (user === null) {
throw new Error("User not found");
}

return user;
}
export async function getAuthedUser(ctx: QueryCtx | MutationCtx) {
const { tokenIdentifier } = await authGuard(ctx);

const user = await ctx.db
.query("users")
.withIndex("byClerkId", (q) => q.eq("clerkId", tokenIdentifier))
.unique();

if (user === null) {
throw new Error("User not found");
}

return user;
}
I find if I simply call the processRaw action from the client, everything works. Is it that when using the scheduler, the user auth is lost somehow?
jamwt
jamwt11mo ago
@saito200 hey! yeah, the auth is tied to the session cookie. if you want to perist the identity in a scheduled function, you can just toss the UserIdentity object into the argument list to your scheduled function https://docs.convex.dev/api/interfaces/server.UserIdentity
ian
ian11mo ago
Is there a reason for not authenticating before scheduling the first action?
saito200
saito200OP11mo ago
@ian reusability. For example if I am reusing a query that gets the current user to filter records by user id
ian
ian11mo ago
Gotcha. Yeah once you're out of the user request flow (e.g. in a scheduled function) you no longer have access to the user request metadata, like the logged in user. This is in part b/c if your auth credentials expire, the client could re-authenticate and retry transparently, but a scheduled function would just fail. In these cases I would recommend having an argument of the userId and having it be an internalQuery / internalMutation that is only called by trusted code.
saito200
saito200OP11mo ago
I am finding it much simpler if I just do not use the scheduler at all and run actions directly from the client tbh, and only go out of the way to use the scheduler if I expect lots of reads and writes
ian
ian11mo ago
I can see that. Note that actions won't get automatically retried the way mutations will, since we don't know if they're idempotent. So if your client -> server network flakes, it'll be up to you to decide if/when to do retries, and how to evaluate if the action started even if the network didn't get the result. This is one reason we suggest folks do the write -> schedule -> write back flow - you can know the state at each step. But just a suggestion. I also have had times where it's easier to just hit an action. This is the world most other backends provide. A few other thoughts: - You can pass a whole user into an action to avoid having to do a query off the bat. Just note the data can be more stale than reading from the action. I have a Table helper which is useful for these situations. - A scheduled action can still fail but you have the ID for it and can check the status with a cron or scheduled timeout mutation. And it's not going to fail at the networking layer, since the scheduler commits atomically with the other data. Scheduled mutations have automatic retries in case of database contention.
Argument Validation without Repetition
In the first post in this series, the Types and Validators cookbook, we shared several basic patterns & bes...

Did you find this page helpful?