Refactor me.* procedures with code review fixes
- Fix silent failures: add 404 NOT_FOUND for invalid resources in passkeysRename, revokeSession, trustDevice, untrustDevice - Fix race condition in passkeysDelete using transaction - Extract helper functions: requireDeviceFingerprint, defaultDeviceName - Improve type safety in updateProfile with Kysely's Updateable<Users> - Extract me.* procedures to separate files under procedures/me/ - Standardize naming to verb-first: listPasskeys, renamePasskey, deletePasskey Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
95
apps/api-server/src/procedures/me/passkeys.ts
Normal file
95
apps/api-server/src/procedures/me/passkeys.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Passkey management procedures - list, rename, delete passkeys
|
||||
*/
|
||||
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||
import { authMiddleware, os } from "../base.js";
|
||||
|
||||
/**
|
||||
* List passkeys handler
|
||||
* - Requires authentication
|
||||
* - Returns all passkeys for the current user
|
||||
*/
|
||||
export const listPasskeys = os.me.passkeys.list
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ context }) => {
|
||||
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
||||
|
||||
return passkeys.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
createdAt: p.createdAt,
|
||||
lastUsedAt: p.lastUsedAt,
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Rename passkey handler
|
||||
* - Requires authentication
|
||||
* - Updates passkey name
|
||||
* @throws NOT_FOUND if passkey doesn't exist
|
||||
*/
|
||||
export const renamePasskey = os.me.passkeys.rename
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { passkeyId, name } = input;
|
||||
|
||||
const result = await context.db
|
||||
.updateTable("passkeys")
|
||||
.set({ name })
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Passkey not found" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete passkey handler
|
||||
* - Requires authentication
|
||||
* - Prevents deleting last passkey if user has no password
|
||||
* - Uses transaction to prevent race conditions
|
||||
* @throws NOT_FOUND if passkey doesn't exist
|
||||
* @throws BAD_REQUEST if trying to delete last passkey without password
|
||||
*/
|
||||
export const deletePasskey = os.me.passkeys.delete
|
||||
.use(authMiddleware)
|
||||
.handler(async ({ input, context }) => {
|
||||
const { passkeyId } = input;
|
||||
|
||||
// Use transaction to prevent race condition when checking last passkey
|
||||
await context.db.transaction().execute(async (trx) => {
|
||||
// Check if this is the last passkey and user has no password
|
||||
const user = await trx
|
||||
.selectFrom("users")
|
||||
.select(["password_hash"])
|
||||
.where("id", "=", context.user.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const passkeyCount = await trx
|
||||
.selectFrom("passkeys")
|
||||
.select(trx.fn.countAll().as("count"))
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!user.password_hash && Number(passkeyCount?.count ?? 0) <= 1) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message:
|
||||
"Cannot delete the last passkey when you have no password set",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await trx
|
||||
.deleteFrom("passkeys")
|
||||
.where("id", "=", String(passkeyId))
|
||||
.where("user_id", "=", context.user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "Passkey not found" });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user