Merge branch 'master' into workstream-h-v2

Resolve conflicts in router.ts by adopting master's modular architecture
for me.* procedures while keeping meGet and setupProfile inline.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 16:36:37 +08:00
24 changed files with 775 additions and 265 deletions

View File

@@ -11,6 +11,7 @@
"clean": "rm -rf dist .eslintcache"
},
"dependencies": {
"@formatjs/intl-durationformat": "^0.9.2",
"@noble/hashes": "^2.0.1",
"@orpc/server": "^1.13.2",
"@reviq/api-contract": "workspace:*",

View File

@@ -36,13 +36,13 @@ export const createAuthMiddleware = () => {
let tokenHash: string | undefined;
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
if (sessionToken) {
tokenHash = hashToken(sessionToken);
tokenHash = await hashToken(sessionToken);
}
// Fall back to API key header (for CLI)
const apiKey = reqHeaders.get("x-api-key");
if (!tokenHash && apiKey) {
tokenHash = hashToken(apiKey);
tokenHash = await hashToken(apiKey);
}
if (!tokenHash) {

View File

@@ -34,13 +34,13 @@ export const authMiddleware = os.middleware(async ({ context, next }) => {
let tokenHash: string | undefined;
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
if (sessionToken) {
tokenHash = hashToken(sessionToken);
tokenHash = await hashToken(sessionToken);
}
// Fall back to API key header (for CLI)
const apiKey = reqHeaders.get("x-api-key");
if (!tokenHash && apiKey) {
tokenHash = hashToken(apiKey);
tokenHash = await hashToken(apiKey);
}
if (!tokenHash) {

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

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

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

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

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

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

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

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

View File

@@ -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";
@@ -108,7 +127,6 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.where("id", "=", context.user.id)
.select([
"id",
"email",
@@ -119,6 +137,7 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
"email_verified_at",
"is_superuser",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return {
@@ -128,8 +147,8 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: !!user.email_verified_at,
needsSetup: !user.display_name,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
};
});
@@ -137,164 +156,25 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
const setupProfile = os.me.setupProfile
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { displayName, fullName, phoneNumber } = input;
await context.db
.updateTable("users")
.set({
display_name: input.displayName,
full_name: input.fullName ?? null,
phone_number: input.phoneNumber ?? null,
display_name: displayName,
full_name: fullName ?? null,
phone_number: phoneNumber ?? null,
updated_at: new Date(),
})
.where("id", "=", context.user.id)
.execute();
});
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 ({ context }) => {
const session = await context.db
.selectFrom("sessions")
.where("id", "=", context.session.id)
.select([
"ip_address",
"city",
"region",
"country",
"user_agent",
])
.executeTakeFirstOrThrow();
return {
id: 0,
name: "Unknown Device",
ip: session.ip_address ?? "Unknown",
city: session.city,
region: session.region,
country: session.country,
lastUsedAt: new Date(),
isTrusted: context.session.trustedMode,
};
});
const trustDevice = os.me.trustDevice
.use(authMiddleware)
.handler(async ({ input, context }) => {
// Note: Sessions table doesn't have a device_name field
// The name parameter is accepted by the contract but not stored
await context.db
.updateTable("sessions")
.set({
trusted_mode: true,
})
.where("id", "=", context.session.id)
.execute();
});
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 () => {
@@ -482,9 +362,9 @@ export const router = os.router({
delete: meDelete,
setPassword,
passkeys: {
list: passkeysList,
rename: passkeysRename,
delete: passkeysDelete,
list: listPasskeys,
rename: renamePasskey,
delete: deletePasskey,
},
listSessions,
revokeSession,

View File

@@ -4,7 +4,7 @@
import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import { sha256 } from "@noble/hashes/sha2.js";
import { hashToken } from "./crypto.js";
export interface AuthenticatedUser {
id: number;
@@ -12,13 +12,6 @@ export interface AuthenticatedUser {
isSuperuser: boolean;
}
/**
* Hash a token using SHA-256
*/
export const hashToken = (token: string): string => {
return Buffer.from(sha256(Buffer.from(token))).toString("hex");
};
/**
* Authenticate a request using session token or API key
* Returns the authenticated user or null if not authenticated
@@ -34,7 +27,7 @@ export const authenticateRequest = async (
return null;
}
const tokenHash = hashToken(token);
const tokenHash = await hashToken(token);
// Check sessions table
const session = await db

View File

@@ -1,11 +1,16 @@
import { createHash, randomBytes } from "node:crypto";
/**
* Hash a token with SHA-256 for storage in database
* Never store raw tokens - always hash first
* Uses Web Crypto API for Cloudflare Workers compatibility
*/
export const hashToken = (token: string): string => {
return createHash("sha256").update(token).digest("hex");
export const hashToken = async (token: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(token);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = new Uint8Array(hashBuffer);
return Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/**
@@ -25,9 +30,14 @@ export const generateDeviceFingerprint = (): string => {
/**
* Generate a secure random token for email verification, password reset, etc.
* Uses 32 bytes (256 bits) of entropy
* Uses Web Crypto API for Cloudflare Workers compatibility
*/
export const generateSecureToken = (): string => {
return randomBytes(32).toString("hex");
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/**

View File

@@ -4,6 +4,7 @@
*/
import type { OrgRole } from "@reviq/db-schema";
import { DurationFormat } from "@formatjs/intl-durationformat";
import { ServerClient } from "postmark";
import {
BASE_URL,
@@ -113,37 +114,24 @@ const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
// ===== Template Helpers =====
const formatExpiryHours = (hours: number): string => {
if (hours === 1) {
return "1 hour";
}
return `${hours} hours`;
const durationFormatter = new DurationFormat("en", { style: "long" });
const formatExpiryHours = (hours: number): string =>
durationFormatter.format({ hours });
const formatExpiryMinutes = (minutes: number): string =>
durationFormatter.format({ minutes });
const formatExpiryDays = (days: number): string =>
durationFormatter.format({ days });
const roleLabels: Record<OrgRole, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
const formatExpiryMinutes = (minutes: number): string => {
if (minutes === 1) {
return "1 minute";
}
return `${minutes} minutes`;
};
const formatExpiryDays = (days: number): string => {
if (days === 1) {
return "1 day";
}
return `${days} days`;
};
const formatRoleDisplay = (role: OrgRole): string => {
switch (role) {
case "owner":
return "Owner";
case "admin":
return "Admin";
case "member":
return "Member";
}
};
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
/**
* Get the correct article (a/an) for a role

View File

@@ -27,7 +27,7 @@ export async function createSession(
options: CreateSessionOptions,
): Promise<SessionResult> {
const token = generateSessionToken();
const tokenHash = hashToken(token);
const tokenHash = await hashToken(token);
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
const result = await db