Files
publisher-dashboard/apps/api-server/src/procedures/me/passkeys.ts
igm 2baf10b0cd Replace String() calls with .toString()/.toLocaleString() per ast-grep rule
- Add formatError() helper in CLI to safely handle unknown error types
- Add uniqueTestId() helper for generating unique test identifiers
- Replace String(id) with id.toString() for database ID conversions
- Replace String(n) with n.toLocaleString() for user-facing number formatting
- Fix TypeScript errors in test files (undefined checks, unused variables)
- Update lint commands to include ast-grep scanning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 15:02:46 +08:00

100 lines
2.9 KiB
TypeScript

/**
* 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", "=", passkeyId.toString())
.where("user_id", "=", context.user.id)
.executeTakeFirst();
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Passkey not found" });
}
return { success: true };
});
/**
* 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", "=", passkeyId.toString())
.where("user_id", "=", context.user.id)
.executeTakeFirst();
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Passkey not found" });
}
});
return { success: true };
});