- 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>
100 lines
2.9 KiB
TypeScript
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 };
|
|
});
|