Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- Web: show pending-scan skills to owners without 404 (thanks @orlyjamie, #136).
- Users: backfill empty handles from name/email in ensure (thanks @adlai88, #158).
- Web: update footer branding to OpenClaw (thanks @jontsai, #122).
- Auth: restore soft-deleted users on reauth, block banned users (thanks @mkrokosz, #106).

## 0.5.1 - TBD

Expand Down
69 changes: 69 additions & 0 deletions convex/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from 'vitest'
import type { Id } from './_generated/dataModel'
import { BANNED_REAUTH_MESSAGE, handleSoftDeletedUserReauth } from './auth'

function makeCtx({
user,
banRecord,
}: {
user: { deletedAt?: number } | null
banRecord?: Record<string, unknown> | null
}) {
const query = {
withIndex: vi.fn().mockReturnValue({
filter: vi.fn().mockReturnValue({
first: vi.fn().mockResolvedValue(banRecord ?? null),
}),
}),
}
const ctx = {
db: {
get: vi.fn().mockResolvedValue(user),
patch: vi.fn().mockResolvedValue(null),
query: vi.fn().mockReturnValue(query),
},
}
return { ctx, query }
}

describe('handleSoftDeletedUserReauth', () => {
const userId = 'users:1' as Id<'users'>

it('skips when no existing user', async () => {
const { ctx } = makeCtx({ user: null })

await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: null })

expect(ctx.db.get).not.toHaveBeenCalled()
})

it('skips active users', async () => {
const { ctx } = makeCtx({ user: { deletedAt: undefined } })

await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: userId })

expect(ctx.db.query).not.toHaveBeenCalled()
expect(ctx.db.patch).not.toHaveBeenCalled()
})

it('restores soft-deleted users when not banned', async () => {
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecord: null })

await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: userId })

expect(ctx.db.patch).toHaveBeenCalledWith(userId, {
deletedAt: undefined,
updatedAt: expect.any(Number),
})
})

it('blocks banned users with a custom message', async () => {
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecord: { action: 'user.ban' } })

await expect(
handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: userId }),
).rejects.toThrow(BANNED_REAUTH_MESSAGE)

expect(ctx.db.patch).not.toHaveBeenCalled()
})
})
44 changes: 44 additions & 0 deletions convex/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
import GitHub from '@auth/core/providers/github'
import { convexAuth } from '@convex-dev/auth/server'
import type { GenericMutationCtx } from 'convex/server'
import { ConvexError } from 'convex/values'
import type { DataModel, Id } from './_generated/dataModel'

export const BANNED_REAUTH_MESSAGE = 'Your account has been suspended.'

export async function handleSoftDeletedUserReauth(
ctx: GenericMutationCtx<DataModel>,
args: { userId: Id<'users'>; existingUserId: Id<'users'> | null },
) {
if (!args.existingUserId) return

const user = await ctx.db.get(args.userId)
if (!user?.deletedAt) return

const userId = args.userId
const banRecord = await ctx.db
.query('auditLogs')
.withIndex('by_target', (q) => q.eq('targetType', 'user').eq('targetId', userId.toString()))
.filter((q) => q.eq(q.field('action'), 'user.ban'))
.first()

if (banRecord) {
throw new ConvexError(BANNED_REAUTH_MESSAGE)
}

await ctx.db.patch(userId, {
deletedAt: undefined,
updatedAt: Date.now(),
})
}

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Expand All @@ -16,4 +47,17 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
},
}),
],
callbacks: {
/**
* Handle re-authentication of soft-deleted users.
*
* Performance note: This callback runs on every OAuth sign-in, but the
* audit log query ONLY executes when a soft-deleted user attempts to
* sign in (user.deletedAt is set). For normal active users, this is
* just a single `if` check on an already-loaded field - no extra queries.
*/
async afterUserCreatedOrUpdated(ctx, args) {
await handleSoftDeletedUserReauth(ctx, args)
},
},
})