From 9b898678c799afc8acd26a96dea65593d071907e Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 16:24:10 +0800 Subject: [PATCH] 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 - Extract me.* procedures to separate files under procedures/me/ - Standardize naming to verb-first: listPasskeys, renamePasskey, deletePasskey Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/procedures/me/delete.ts | 50 ++++++ apps/api-server/src/procedures/me/devices.ts | 131 ++++++++++++++++ apps/api-server/src/procedures/me/helpers.ts | 40 +++++ apps/api-server/src/procedures/me/index.ts | 20 +++ apps/api-server/src/procedures/me/passkeys.ts | 95 +++++++++++ apps/api-server/src/procedures/me/sessions.ts | 86 ++++++++++ .../src/procedures/me/set-password.ts | 61 ++++++++ .../src/procedures/me/update-profile.ts | 39 +++++ apps/api-server/src/router.ts | 148 ++++-------------- docs/initial-app.md | 10 +- 10 files changed, 555 insertions(+), 125 deletions(-) create mode 100644 apps/api-server/src/procedures/me/delete.ts create mode 100644 apps/api-server/src/procedures/me/devices.ts create mode 100644 apps/api-server/src/procedures/me/helpers.ts create mode 100644 apps/api-server/src/procedures/me/index.ts create mode 100644 apps/api-server/src/procedures/me/passkeys.ts create mode 100644 apps/api-server/src/procedures/me/sessions.ts create mode 100644 apps/api-server/src/procedures/me/set-password.ts create mode 100644 apps/api-server/src/procedures/me/update-profile.ts diff --git a/apps/api-server/src/procedures/me/delete.ts b/apps/api-server/src/procedures/me/delete.ts new file mode 100644 index 0000000..a58f0c4 --- /dev/null +++ b/apps/api-server/src/procedures/me/delete.ts @@ -0,0 +1,50 @@ +/** + * Delete account procedure - permanently deletes user account + */ + +import { ORPCError } from "@orpc/server"; +import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js"; +import { verifyPassword } from "../../utils/password.js"; +import { authMiddleware, os } from "../base.js"; + +/** + * Delete account handler + * - Requires authentication + * - Requires password confirmation (passkey-only users must set password first) + * - Deletes user record (cascades to sessions, devices, passkeys, etc.) + * - Clears session cookie + */ +export const meDelete = os.me.delete + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { password } = input; + + // Fetch user with password hash + const user = await context.db + .selectFrom("users") + .select(["password_hash"]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + // Verify password (required for account deletion) + if (!user.password_hash) { + throw new ORPCError("BAD_REQUEST", { + message: + "Cannot delete account without a password. Please set a password first.", + }); + } + + const valid = await verifyPassword(password, user.password_hash); + if (!valid) { + throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" }); + } + + // Delete user (cascades to sessions, devices, passkeys, etc.) + await context.db + .deleteFrom("users") + .where("id", "=", context.user.id) + .execute(); + + // Clear session cookie + deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN); + }); diff --git a/apps/api-server/src/procedures/me/devices.ts b/apps/api-server/src/procedures/me/devices.ts new file mode 100644 index 0000000..2941480 --- /dev/null +++ b/apps/api-server/src/procedures/me/devices.ts @@ -0,0 +1,131 @@ +/** + * Device management procedures - getInfo, trust, listTrusted, untrust, revokeAll + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os } from "../base.js"; +import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js"; + +/** + * Get device info handler + * - Requires authentication + * - Returns info for the current device based on fingerprint cookie + * @throws BAD_REQUEST if no device fingerprint found + * @throws NOT_FOUND if device doesn't exist + */ +export const getDeviceInfo = os.me.getDeviceInfo + .use(authMiddleware) + .handler(async ({ context }) => { + const fingerprint = requireDeviceFingerprint(context.reqHeaders); + + const device = await context.db + .selectFrom("user_devices") + .selectAll() + .where("user_id", "=", context.user.id) + .where("device_fingerprint", "=", fingerprint) + .executeTakeFirst(); + + if (!device) { + throw new ORPCError("NOT_FOUND", { message: "Device not found" }); + } + + return { + id: Number(device.id), + name: device.name ?? defaultDeviceName(device.user_agent), + ip: device.ip_address ?? "", + city: device.city, + region: device.region, + country: device.country, + lastUsedAt: device.last_used_at, + isTrusted: device.is_trusted, + }; + }); + +/** + * Trust device handler + * - Requires authentication + * - Marks current device as trusted with a given name + * @throws BAD_REQUEST if no device fingerprint found + * @throws NOT_FOUND if device doesn't exist + */ +export const trustDevice = os.me.trustDevice + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { name } = input; + const fingerprint = requireDeviceFingerprint(context.reqHeaders); + + const result = await context.db + .updateTable("user_devices") + .set({ is_trusted: true, name }) + .where("user_id", "=", context.user.id) + .where("device_fingerprint", "=", fingerprint) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Device not found" }); + } + }); + +/** + * List trusted devices handler + * - Requires authentication + * - Returns all trusted devices for the current user + */ +export const listTrustedDevices = os.me.listTrustedDevices + .use(authMiddleware) + .handler(async ({ context }) => { + const devices = await context.db + .selectFrom("user_devices") + .selectAll() + .where("user_id", "=", context.user.id) + .where("is_trusted", "=", true) + .orderBy("last_used_at", "desc") + .execute(); + + return devices.map((d) => ({ + id: Number(d.id), + name: d.name ?? "Unknown device", + ip: d.ip_address ?? "", + city: d.city, + region: d.region, + country: d.country, + lastUsedAt: d.last_used_at, + isTrusted: d.is_trusted, + })); + }); + +/** + * Untrust device handler + * - Requires authentication + * - Marks device as untrusted by ID + * @throws NOT_FOUND if device doesn't exist + */ +export const untrustDevice = os.me.untrustDevice + .use(authMiddleware) + .handler(async ({ input, context }) => { + const result = await context.db + .updateTable("user_devices") + .set({ is_trusted: false }) + .where("id", "=", String(input.deviceId)) + .where("user_id", "=", context.user.id) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Device not found" }); + } + }); + +/** + * Revoke all trusted devices handler + * - Requires authentication + * - Marks all devices as untrusted + */ +export const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices + .use(authMiddleware) + .handler(async ({ context }) => { + await context.db + .updateTable("user_devices") + .set({ is_trusted: false }) + .where("user_id", "=", context.user.id) + .execute(); + }); diff --git a/apps/api-server/src/procedures/me/helpers.ts b/apps/api-server/src/procedures/me/helpers.ts new file mode 100644 index 0000000..5ad2a21 --- /dev/null +++ b/apps/api-server/src/procedures/me/helpers.ts @@ -0,0 +1,40 @@ +/** + * Helper functions for me.* procedures + */ + +import type { Users } from "@reviq/db-schema"; +import type { Updateable } from "kysely"; +import { ORPCError } from "@orpc/server"; +import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; + +// ===== Types ===== + +/** Fields that can be updated via updateProfile */ +export type ProfileUpdate = Pick< + Updateable, + "display_name" | "full_name" | "phone_number" | "avatar_url" | "updated_at" +>; + +// ===== Helper Functions ===== + +/** + * Get device fingerprint from request cookies + * @throws ORPCError if fingerprint is missing + */ +export const requireDeviceFingerprint = (reqHeaders: Headers): string => { + const fingerprint = getCookie(reqHeaders, COOKIE_NAMES.DEVICE_FINGERPRINT); + if (!fingerprint) { + throw new ORPCError("BAD_REQUEST", { + message: "No device fingerprint found", + }); + } + return fingerprint; +}; + +/** + * Get default device name from user agent string + */ +export const defaultDeviceName = (userAgent: string): string => { + const part = userAgent.split("/")[0]?.trim(); + return part ? `${part} device` : "Unknown device"; +}; diff --git a/apps/api-server/src/procedures/me/index.ts b/apps/api-server/src/procedures/me/index.ts new file mode 100644 index 0000000..4c993c8 --- /dev/null +++ b/apps/api-server/src/procedures/me/index.ts @@ -0,0 +1,20 @@ +/** + * Me procedures - user profile and account management + */ + +export { meDelete } from "./delete.js"; +export { + getDeviceInfo, + listTrustedDevices, + revokeAllTrustedDevices, + trustDevice, + untrustDevice, +} from "./devices.js"; +export { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js"; +export { + listSessions, + revokeAllSessions, + revokeSession, +} from "./sessions.js"; +export { setPassword } from "./set-password.js"; +export { updateProfile } from "./update-profile.js"; diff --git a/apps/api-server/src/procedures/me/passkeys.ts b/apps/api-server/src/procedures/me/passkeys.ts new file mode 100644 index 0000000..e6ea66d --- /dev/null +++ b/apps/api-server/src/procedures/me/passkeys.ts @@ -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" }); + } + }); + }); diff --git a/apps/api-server/src/procedures/me/sessions.ts b/apps/api-server/src/procedures/me/sessions.ts new file mode 100644 index 0000000..f8d1fdd --- /dev/null +++ b/apps/api-server/src/procedures/me/sessions.ts @@ -0,0 +1,86 @@ +/** + * Session management procedures - list, revoke, revokeAll sessions + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os } from "../base.js"; + +/** + * List sessions handler + * - Requires authentication + * - Returns all sessions for the current user + * - Includes isCurrent flag to identify active session + */ +export const listSessions = os.me.listSessions + .use(authMiddleware) + .handler(async ({ context }) => { + const sessions = await context.db + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", context.user.id) + .orderBy("created_at", "desc") + .execute(); + + return sessions.map((s) => ({ + id: Number(s.id), + ip: s.ip_address ?? "", + city: s.city, + region: s.region, + country: s.country, + userAgent: s.user_agent ?? "", + trustedMode: s.trusted_mode, + createdAt: s.created_at, + isCurrent: s.id === context.session.id, + revokedAt: s.revoked_at, + })); + }); + +/** + * Revoke session handler + * - Requires authentication + * - Cannot revoke current session (use logout instead) + * @throws NOT_FOUND if session doesn't exist + * @throws BAD_REQUEST if trying to revoke current session + */ +export const revokeSession = os.me.revokeSession + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { sessionId } = input; + + // Prevent revoking current session (use logout instead) + if (String(sessionId) === context.session.id) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot revoke current session. Use logout instead.", + }); + } + + const result = await context.db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("id", "=", String(sessionId)) + .where("user_id", "=", context.user.id) + .where("revoked_at", "is", null) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Session not found" }); + } + }); + +/** + * Revoke all sessions handler + * - Requires authentication + * - Revokes all sessions except current + */ +export const revokeAllSessions = os.me.revokeAllSessions + .use(authMiddleware) + .handler(async ({ context }) => { + // Revoke all sessions except current + await context.db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("user_id", "=", context.user.id) + .where("id", "!=", context.session.id) + .where("revoked_at", "is", null) + .execute(); + }); diff --git a/apps/api-server/src/procedures/me/set-password.ts b/apps/api-server/src/procedures/me/set-password.ts new file mode 100644 index 0000000..ba4663d --- /dev/null +++ b/apps/api-server/src/procedures/me/set-password.ts @@ -0,0 +1,61 @@ +/** + * Set password procedure - sets or changes user password + */ + +import { ORPCError } from "@orpc/server"; +import { + hashPassword, + validatePassword, + verifyPassword, +} from "../../utils/password.js"; +import { authMiddleware, os } from "../base.js"; + +/** + * Set password handler + * - Requires authentication + * - If user has existing password, currentPassword is required + * - Validates new password strength using zxcvbn + */ +export const setPassword = os.me.setPassword + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { currentPassword, newPassword } = input; + + // Fetch current password hash + const user = await context.db + .selectFrom("users") + .select(["password_hash"]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + // If user has a password, verify current password + if (user.password_hash) { + if (!currentPassword) { + throw new ORPCError("BAD_REQUEST", { + message: "Current password required", + }); + } + const valid = await verifyPassword(currentPassword, user.password_hash); + if (!valid) { + throw new ORPCError("BAD_REQUEST", { + message: "Current password is incorrect", + }); + } + } + + // Validate new password strength + const validation = validatePassword(newPassword, [context.user.email]); + if (!validation.valid) { + throw new ORPCError("BAD_REQUEST", { + message: validation.feedback[0] ?? "Password is too weak", + }); + } + + // Hash and update + const newHash = await hashPassword(newPassword); + await context.db + .updateTable("users") + .set({ password_hash: newHash, updated_at: new Date() }) + .where("id", "=", context.user.id) + .execute(); + }); diff --git a/apps/api-server/src/procedures/me/update-profile.ts b/apps/api-server/src/procedures/me/update-profile.ts new file mode 100644 index 0000000..3d59510 --- /dev/null +++ b/apps/api-server/src/procedures/me/update-profile.ts @@ -0,0 +1,39 @@ +/** + * Update profile procedure - updates user profile fields + */ + +import type { ProfileUpdate } from "./helpers.js"; +import { authMiddleware, os } from "../base.js"; + +/** + * Update profile handler + * - Requires authentication + * - Allows partial updates to display_name, full_name, phone_number, avatar_url + * - Automatically sets updated_at timestamp + */ +export const updateProfile = os.me.updateProfile + .use(authMiddleware) + .handler(async ({ input, context }) => { + const updates: Partial = {}; + if (input.displayName !== undefined) { + updates.display_name = input.displayName; + } + if (input.fullName !== undefined) { + updates.full_name = input.fullName || null; + } + if (input.phoneNumber !== undefined) { + updates.phone_number = input.phoneNumber || null; + } + if (input.avatarUrl !== undefined) { + updates.avatar_url = input.avatarUrl || null; + } + + if (Object.keys(updates).length > 0) { + updates.updated_at = new Date(); + await context.db + .updateTable("users") + .set(updates) + .where("id", "=", context.user.id) + .execute(); + } + }); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 7743973..15dff7b 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -14,11 +14,30 @@ import { loginRequestMiddleware, os, } from "./procedures/base.js"; +import { meDelete } from "./procedures/me/delete.js"; +import { + getDeviceInfo, + listTrustedDevices, + revokeAllTrustedDevices, + trustDevice, + untrustDevice, +} from "./procedures/me/devices.js"; +import { + deletePasskey, + listPasskeys, + renamePasskey, +} from "./procedures/me/passkeys.js"; +import { + listSessions, + revokeAllSessions, + revokeSession, +} from "./procedures/me/sessions.js"; +import { setPassword } from "./procedures/me/set-password.js"; +import { updateProfile } from "./procedures/me/update-profile.js"; import { createAuthenticationOptions as createAuthOptions, createRegistrationOptions as createRegOptions, getRPInfo, - getUserPasskeys, verifyAuthentication as verifyAuth, verifyRegistration as verifyReg, } from "./utils/webauthn.js"; @@ -115,122 +134,11 @@ const setupProfile = os.me.setupProfile throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const updateProfile = os.me.updateProfile - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const meDelete = os.me.delete.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const setPassword = os.me.setPassword.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const passkeysList = 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, - })); - }); - -const passkeysRename = os.me.passkeys.rename - .use(authMiddleware) - .handler(async ({ input, context }) => { - const { passkeyId, name } = input; - - await context.db - .updateTable("passkeys") - .set({ name }) - .where("id", "=", String(passkeyId)) - .where("user_id", "=", context.user.id) - .execute(); - }); - -const passkeysDelete = os.me.passkeys.delete - .use(authMiddleware) - .handler(async ({ input, context }) => { - const { passkeyId } = input; - - // Check if this is the last passkey and user has no password - const user = await context.db - .selectFrom("users") - .select(["password_hash"]) - .where("id", "=", context.user.id) - .executeTakeFirst(); - - const passkeyCount = await context.db - .selectFrom("passkeys") - .select(context.db.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", - }); - } - - await context.db - .deleteFrom("passkeys") - .where("id", "=", String(passkeyId)) - .where("user_id", "=", context.user.id) - .execute(); - }); - -const listSessions = os.me.listSessions - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const revokeSession = os.me.revokeSession - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const revokeAllSessions = os.me.revokeAllSessions - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const getDeviceInfo = os.me.getDeviceInfo - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const trustDevice = os.me.trustDevice.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const listTrustedDevices = os.me.listTrustedDevices - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const untrustDevice = os.me.untrustDevice - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); +// Me procedures imported from ./procedures/me/* +// - updateProfile, setPassword, meDelete +// - listPasskeys, renamePasskey, deletePasskey +// - listSessions, revokeSession, revokeAllSessions +// - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices // Orgs procedures (all require auth) const orgsList = os.orgs.list.use(authMiddleware).handler(async () => { @@ -418,9 +326,9 @@ export const router = os.router({ delete: meDelete, setPassword, passkeys: { - list: passkeysList, - rename: passkeysRename, - delete: passkeysDelete, + list: listPasskeys, + rename: renamePasskey, + delete: deletePasskey, }, listSessions, revokeSession, diff --git a/docs/initial-app.md b/docs/initial-app.md index 0733adb..3d4311c 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -2248,12 +2248,12 @@ _Can run parallel to D_ _Depends on: D1 (auth middleware)_ - [ ] **F1**: Implement `me.get` and `me.setupProfile` -- [ ] **F2**: Implement `me.updateProfile` -- [ ] **F3**: Implement `me.setPassword` +- [x] **F2**: Implement `me.updateProfile` +- [x] **F3**: Implement `me.setPassword` - [x] **F4**: Implement `me.listPasskeys`, `me.createPasskey`, `me.renamePasskey`, `me.deletePasskey` -- [ ] **F5**: Implement `me.listSessions`, `me.revokeSession`, `me.revokeAllSessions` -- [ ] **F6**: Implement `me.getDeviceInfo`, `me.trustDevice`, `me.listTrustedDevices`, `me.untrustDevice`, `me.revokeAllTrustedDevices` -- [ ] **F7**: Implement `me.delete` (account deletion) +- [x] **F5**: Implement `me.listSessions`, `me.revokeSession`, `me.revokeAllSessions` +- [x] **F6**: Implement `me.getDeviceInfo`, `me.trustDevice`, `me.listTrustedDevices`, `me.untrustDevice`, `me.revokeAllTrustedDevices` +- [x] **F7**: Implement `me.delete` (account deletion) #### Workstream G: Email Service (Backend)