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:
50
apps/api-server/src/procedures/me/delete.ts
Normal file
50
apps/api-server/src/procedures/me/delete.ts
Normal file
@@ -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);
|
||||
});
|
||||
131
apps/api-server/src/procedures/me/devices.ts
Normal file
131
apps/api-server/src/procedures/me/devices.ts
Normal file
@@ -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();
|
||||
});
|
||||
40
apps/api-server/src/procedures/me/helpers.ts
Normal file
40
apps/api-server/src/procedures/me/helpers.ts
Normal file
@@ -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<Users>,
|
||||
"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";
|
||||
};
|
||||
20
apps/api-server/src/procedures/me/index.ts
Normal file
20
apps/api-server/src/procedures/me/index.ts
Normal file
@@ -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";
|
||||
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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
86
apps/api-server/src/procedures/me/sessions.ts
Normal file
86
apps/api-server/src/procedures/me/sessions.ts
Normal file
@@ -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();
|
||||
});
|
||||
61
apps/api-server/src/procedures/me/set-password.ts
Normal file
61
apps/api-server/src/procedures/me/set-password.ts
Normal file
@@ -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();
|
||||
});
|
||||
39
apps/api-server/src/procedures/me/update-profile.ts
Normal file
39
apps/api-server/src/procedures/me/update-profile.ts
Normal file
@@ -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<ProfileUpdate> = {};
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user