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:
RevIQ
2026-01-09 16:24:10 +08:00
parent 1ebcf12cb9
commit 9b898678c7
10 changed files with 555 additions and 125 deletions

View 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" });
}
});
});