feat: implement multi-factor authentication (MFA) setup and verification flow

- Add SecurityPage component for managing MFA settings, including enrollment and verification.
- Create MfaLoginGate to handle MFA verification state during login.
- Develop MfaVerificationPopup for user input of verification codes.
- Implement VerifyMfaPage for the MFA verification process after login.
- Introduce reusable VerificationCodeInput component for entering verification codes.
- Integrate Supabase MFA API for managing factors and verification.
- Add loading states and error handling for a better user experience.
This commit is contained in:
willchen96 2026-06-10 03:48:08 +08:00
parent 15c96b0dd4
commit 3a10943200
32 changed files with 3704 additions and 311 deletions

View file

@ -10,6 +10,7 @@
alter table public.user_profiles
add column if not exists title_model text,
add column if not exists mfa_on_login boolean not null default false,
add column if not exists quote_model text;
-- ---------------------------------------------------------------------------

View file

@ -19,6 +19,7 @@ create table if not exists public.user_profiles (
title_model text,
tabular_model text not null default 'gemini-3-flash-preview',
quote_model text,
mfa_on_login boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

View file

@ -72,6 +72,18 @@ const uploadLimiter = makeLimiter({
message: "Too many upload requests. Please try again later.",
});
const exportLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_EXPORT_MAX", 10),
message: "Too many export requests. Please try again later.",
});
const dataDeleteLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20),
message: "Too many data deletion requests. Please try again later.",
});
function jsonLimitForPath(path: string): string {
return "50mb";
}
@ -117,6 +129,13 @@ app.post("/chat/:chatId/generate-title", chatCreateLimiter);
app.post("/single-documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.post("/projects/:projectId/documents", uploadLimiter);
app.get("/user/export", exportLimiter);
app.get("/user/chats/export", exportLimiter);
app.get("/user/tabular-reviews/export", exportLimiter);
app.delete("/user/account", dataDeleteLimiter);
app.delete("/user/chats", dataDeleteLimiter);
app.delete("/user/projects", dataDeleteLimiter);
app.delete("/user/tabular-reviews", dataDeleteLimiter);
app.use((req, res, next) =>
express.json({ limit: jsonLimitForPath(req.path) })(req, res, next),

View file

@ -37,6 +37,7 @@ import {
type LlmMessage,
type OpenAIToolSchema,
} from "./llm";
import { safeErrorMessage } from "./safeError";
const STANDARD_FONT_DATA_URL = (() => {
try {
@ -4172,8 +4173,7 @@ export async function runLLMStream(params: {
throw new AssistantStreamAbortError(fullText, events);
}
flushPartialTurn();
const message =
err instanceof Error && err.message ? err.message : "Stream error";
const message = safeErrorMessage(err, "Stream error");
events.push({ type: "error", message });
throw new AssistantStreamError(message, fullText, events);
}

View file

@ -1,25 +1,81 @@
import JSZip from "jszip";
import fs from "node:fs";
import path from "node:path";
let _convert:
| ((buf: Buffer, ext: string, filter: undefined) => Promise<Buffer>)
| null = null;
let _sofficeBinaryPaths: string[] | null = null;
function executablePath(filePath: string) {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function resolveSofficeBinaryPaths(): string[] {
if (_sofficeBinaryPaths) return _sofficeBinaryPaths;
const candidates = new Set<string>();
for (const envName of [
"SOFFICE_BINARY_PATH",
"LIBREOFFICE_BINARY_PATH",
"LIBRE_OFFICE_EXE",
]) {
const value = process.env[envName]?.trim();
if (value) candidates.add(value);
}
const pathDirs = (process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean);
for (const dir of pathDirs) {
candidates.add(path.join(dir, "soffice"));
candidates.add(path.join(dir, "libreoffice"));
}
for (const filePath of [
"/usr/bin/libreoffice",
"/usr/bin/soffice",
"/snap/bin/libreoffice",
"/opt/libreoffice/program/soffice",
"/opt/libreoffice7.6/program/soffice",
]) {
candidates.add(filePath);
}
_sofficeBinaryPaths = [...candidates].filter(executablePath);
return _sofficeBinaryPaths;
}
async function getConvert() {
if (!_convert) {
const libre = await import("libreoffice-convert");
const convert = libre.default.convert.bind(libre.default) as (
const convertWithOptions = libre.default.convertWithOptions.bind(
libre.default,
) as (
buf: Buffer,
ext: string,
filter: undefined,
options: { sofficeBinaryPaths?: string[] },
callback?: (err: Error | null, result: Buffer) => void,
) => Promise<Buffer> | void;
_convert = (buf, ext, filter) =>
new Promise<Buffer>((resolve, reject) => {
try {
const maybePromise = convert(buf, ext, filter, (err, result) => {
if (err) reject(err);
else resolve(result);
});
const maybePromise = convertWithOptions(
buf,
ext,
filter,
{ sofficeBinaryPaths: resolveSofficeBinaryPaths() },
(err, result) => {
if (err) reject(err);
else resolve(result);
},
);
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.then(resolve, reject);
}
@ -67,6 +123,11 @@ export async function normalizeDocxZipPaths(buffer: Buffer): Promise<Buffer> {
* Throws if LibreOffice is not installed or conversion fails.
*/
export async function docxToPdf(buffer: Buffer): Promise<Buffer> {
if (resolveSofficeBinaryPaths().length === 0) {
throw new Error(
"LibreOffice/soffice binary was not found. Ensure Railway uses backend/nixpacks.toml or set SOFFICE_BINARY_PATH/LIBREOFFICE_BINARY_PATH.",
);
}
const convert = await getConvert();
const normalized = await normalizeDocxZipPaths(buffer);
return convert(normalized, ".pdf", undefined);

View file

@ -0,0 +1,59 @@
const SECRET_CONTEXT_PATTERNS = [
/(Incorrect API key provided:\s*)([^.\s]+)(\.?)/gi,
/(api[_ -]?key|x-api-key|token|secret|authorization|bearer)\s*(?:provided\s*)?(?:is|:|=)\s*["']?([A-Za-z0-9._\-]{6,})["']?/gi,
];
const PROVIDER_KEY_PATTERNS = [
/\bsk-[A-Za-z0-9_\-]{12,}\b/g,
/\bsk-ant-[A-Za-z0-9_\-]{12,}\b/g,
/\bsk-or-[A-Za-z0-9_\-]{12,}\b/g,
/\bAIza[A-Za-z0-9_\-]{20,}\b/g,
];
export function redactSensitiveText(value: string): string {
let redacted = value;
for (const pattern of SECRET_CONTEXT_PATTERNS) {
redacted = redacted.replace(pattern, (match, ...groups: string[]) => {
if (match.toLowerCase().startsWith("incorrect api key provided:")) {
return `${groups[0]}[redacted]${groups[2] ?? ""}`;
}
const secret = groups[1];
return secret ? match.replace(secret, "[redacted]") : match;
});
}
for (const pattern of PROVIDER_KEY_PATTERNS) {
redacted = redacted.replace(pattern, "[redacted]");
}
return redacted;
}
export function safeErrorMessage(
error: unknown,
fallback = "Unexpected error",
): string {
const message =
error instanceof Error && error.message
? error.message
: typeof error === "string"
? error
: fallback;
return redactSensitiveText(message);
}
export function safeErrorLog(error: unknown): {
name: string | null;
message: string;
stack?: string;
} {
if (error instanceof Error) {
return {
name: error.name || null,
message: redactSensitiveText(error.message || "Unexpected error"),
stack: error.stack ? redactSensitiveText(error.stack) : undefined,
};
}
return {
name: null,
message: safeErrorMessage(error),
};
}

View file

@ -0,0 +1,339 @@
import { createServerSupabase } from "./supabase";
import { deleteFile, listFiles } from "./storage";
type Db = ReturnType<typeof createServerSupabase>;
const DELETE_BATCH_SIZE = 500;
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => !!value))];
}
function chunks<T>(values: T[], size = DELETE_BATCH_SIZE): T[][] {
const result: T[][] = [];
for (let i = 0; i < values.length; i += size) {
result.push(values.slice(i, i + size));
}
return result;
}
async function throwIfError<T extends { message?: string } | null>(
error: T,
context: string,
) {
if (error) throw new Error(`${context}: ${error.message ?? "unknown error"}`);
}
async function deleteByIds(db: Db, table: string, ids: string[]) {
for (const batch of chunks(ids)) {
const { error } = await (db as any).from(table).delete().in("id", batch);
await throwIfError(error, `Failed to delete ${table}`);
}
}
async function deleteWhereIn(
db: Db,
table: string,
column: string,
values: string[],
) {
for (const batch of chunks(values)) {
const { error } = await (db as any)
.from(table)
.delete()
.in(column, batch);
await throwIfError(error, `Failed to delete ${table}`);
}
}
async function getOwnedProjectIds(db: Db, userId: string): Promise<string[]> {
const { data, error } = await db
.from("projects")
.select("id")
.eq("user_id", userId);
await throwIfError(error, "Failed to load user projects");
return uniqueStrings((data ?? []).map((row) => row.id as string | null));
}
async function getDocumentIdsForAccountDeletion(
db: Db,
userId: string,
ownedProjectIds: string[],
): Promise<string[]> {
const [ownedDocs, projectDocs] = await Promise.all([
db.from("documents").select("id").eq("user_id", userId),
ownedProjectIds.length > 0
? db.from("documents").select("id").in("project_id", ownedProjectIds)
: Promise.resolve({ data: [], error: null }),
]);
await throwIfError(ownedDocs.error, "Failed to load user documents");
await throwIfError(projectDocs.error, "Failed to load project documents");
return uniqueStrings([
...((ownedDocs.data ?? []) as { id: string | null }[]).map((row) => row.id),
...((projectDocs.data ?? []) as { id: string | null }[]).map((row) => row.id),
]);
}
async function deleteDocumentVersionFiles(db: Db, documentIds: string[]) {
const paths = new Set<string>();
for (const batch of chunks(documentIds)) {
const { data, error } = await db
.from("document_versions")
.select("storage_path, pdf_storage_path")
.in("document_id", batch);
await throwIfError(error, "Failed to load document storage paths");
for (const version of data ?? []) {
if (
typeof version.storage_path === "string" &&
version.storage_path.length > 0
) {
paths.add(version.storage_path);
}
if (
typeof version.pdf_storage_path === "string" &&
version.pdf_storage_path.length > 0
) {
paths.add(version.pdf_storage_path);
}
}
}
await Promise.all([...paths].map((path) => deleteFile(path)));
}
async function deleteUserStoragePrefix(userId: string) {
try {
const paths = await listFiles(`documents/${userId}/`);
await Promise.all(paths.map((path) => deleteFile(path).catch(() => {})));
} catch {
// Version-linked objects are deleted above. Prefix cleanup is best-effort
// for orphaned files left behind by interrupted uploads.
}
}
async function removeEmailFromSharedWith(
db: Db,
table: "projects" | "tabular_reviews",
email: string | null | undefined,
) {
const normalizedEmail = email?.trim().toLowerCase();
if (!normalizedEmail) return;
const { data, error } = await db
.from(table)
.select("id, shared_with")
.filter("shared_with", "cs", JSON.stringify([normalizedEmail]));
await throwIfError(error, `Failed to load shared ${table}`);
const updates = (data ?? [])
.map((row) => {
const sharedWith = Array.isArray(row.shared_with)
? row.shared_with.filter(
(value) =>
typeof value !== "string" ||
value.trim().toLowerCase() !== normalizedEmail,
)
: [];
return { id: row.id as string, sharedWith };
})
.filter((row) => row.id);
await Promise.all(
updates.map(async ({ id, sharedWith }) => {
const { error: updateError } = await db
.from(table)
.update({ shared_with: sharedWith })
.eq("id", id);
await throwIfError(updateError, `Failed to update shared ${table}`);
}),
);
}
export async function deleteAllUserChats(db: Db, userId: string) {
const [assistantChats, tabularChats] = await Promise.all([
db.from("chats").delete().eq("user_id", userId),
db.from("tabular_review_chats").delete().eq("user_id", userId),
]);
await throwIfError(assistantChats.error, "Failed to delete assistant chats");
await throwIfError(tabularChats.error, "Failed to delete tabular chats");
}
export async function deleteAllUserTabularReviews(db: Db, userId: string) {
const { data: reviews, error: reviewsError } = await db
.from("tabular_reviews")
.select("id")
.eq("user_id", userId);
await throwIfError(reviewsError, "Failed to load tabular reviews");
const reviewIds = uniqueStrings(
((reviews ?? []) as { id: string | null }[]).map((row) => row.id),
);
if (reviewIds.length === 0) return 0;
const { data: reviewChats, error: reviewChatsError } = await db
.from("tabular_review_chats")
.select("id")
.in("review_id", reviewIds);
await throwIfError(reviewChatsError, "Failed to load tabular review chats");
const reviewChatIds = uniqueStrings(
((reviewChats ?? []) as { id: string | null }[]).map((row) => row.id),
);
await deleteWhereIn(
db,
"tabular_review_chat_messages",
"chat_id",
reviewChatIds,
);
await deleteWhereIn(db, "tabular_review_chats", "review_id", reviewIds);
await deleteWhereIn(db, "tabular_cells", "review_id", reviewIds);
await deleteByIds(db, "tabular_reviews", reviewIds);
return reviewIds.length;
}
export async function deleteUserProjects(
db: Db,
userId: string,
projectIds?: string[],
) {
const requestedProjectIds = projectIds
? uniqueStrings(projectIds)
: undefined;
if (requestedProjectIds && requestedProjectIds.length === 0) return 0;
let query = db.from("projects").select("id").eq("user_id", userId);
if (requestedProjectIds) query = query.in("id", requestedProjectIds);
const { data: projects, error: projectsError } = await query;
await throwIfError(projectsError, "Failed to load user projects");
const ownedProjectIds = uniqueStrings(
((projects ?? []) as { id: string | null }[]).map((row) => row.id),
);
if (ownedProjectIds.length === 0) return 0;
const [projectDocs, projectChats, projectReviews, projectFolders] =
await Promise.all([
db.from("documents").select("id").in("project_id", ownedProjectIds),
db.from("chats").select("id").in("project_id", ownedProjectIds),
db
.from("tabular_reviews")
.select("id")
.in("project_id", ownedProjectIds),
db
.from("project_subfolders")
.select("id")
.in("project_id", ownedProjectIds),
]);
await throwIfError(projectDocs.error, "Failed to load project documents");
await throwIfError(projectChats.error, "Failed to load project chats");
await throwIfError(
projectReviews.error,
"Failed to load project tabular reviews",
);
await throwIfError(projectFolders.error, "Failed to load project folders");
const documentIds = uniqueStrings(
((projectDocs.data ?? []) as { id: string | null }[]).map(
(row) => row.id,
),
);
const chatIds = uniqueStrings(
((projectChats.data ?? []) as { id: string | null }[]).map(
(row) => row.id,
),
);
const reviewIds = uniqueStrings(
((projectReviews.data ?? []) as { id: string | null }[]).map(
(row) => row.id,
),
);
const folderIds = uniqueStrings(
((projectFolders.data ?? []) as { id: string | null }[]).map(
(row) => row.id,
),
);
const { data: reviewChats, error: reviewChatsError } =
reviewIds.length > 0
? await db
.from("tabular_review_chats")
.select("id")
.in("review_id", reviewIds)
: { data: [], error: null };
await throwIfError(reviewChatsError, "Failed to load project review chats");
const reviewChatIds = uniqueStrings(
((reviewChats ?? []) as { id: string | null }[]).map((row) => row.id),
);
await deleteDocumentVersionFiles(db, documentIds);
await deleteWhereIn(
db,
"tabular_review_chat_messages",
"chat_id",
reviewChatIds,
);
await deleteWhereIn(db, "tabular_review_chats", "review_id", reviewIds);
await deleteWhereIn(db, "tabular_cells", "review_id", reviewIds);
await deleteByIds(db, "tabular_reviews", reviewIds);
await deleteWhereIn(db, "chat_messages", "chat_id", chatIds);
await deleteByIds(db, "chats", chatIds);
await deleteByIds(db, "documents", documentIds);
await deleteByIds(db, "project_subfolders", folderIds);
await deleteByIds(db, "projects", ownedProjectIds);
return ownedProjectIds.length;
}
export async function deleteUserAccountData(
db: Db,
userId: string,
userEmail?: string | null,
) {
const ownedProjectIds = await getOwnedProjectIds(db, userId);
const documentIds = await getDocumentIdsForAccountDeletion(
db,
userId,
ownedProjectIds,
);
await Promise.all([
removeEmailFromSharedWith(db, "projects", userEmail),
removeEmailFromSharedWith(db, "tabular_reviews", userEmail),
deleteDocumentVersionFiles(db, documentIds),
deleteUserStoragePrefix(userId),
]);
await deleteByIds(db, "documents", documentIds);
const deletions = [
db.from("tabular_review_chats").delete().eq("user_id", userId),
db.from("tabular_reviews").delete().eq("user_id", userId),
db.from("chats").delete().eq("user_id", userId),
db.from("project_subfolders").delete().eq("user_id", userId),
db.from("hidden_workflows").delete().eq("user_id", userId),
db.from("workflow_shares").delete().eq("shared_by_user_id", userId),
userEmail
? db
.from("workflow_shares")
.delete()
.eq("shared_with_email", userEmail.trim().toLowerCase())
: Promise.resolve({ error: null }),
db.from("workflows").delete().eq("user_id", userId),
db.from("projects").delete().eq("user_id", userId),
];
const results = await Promise.all(deletions);
for (const result of results) {
await throwIfError(result.error, "Failed to delete account data");
}
}

View file

@ -0,0 +1,278 @@
import { createServerSupabase } from "./supabase";
type Db = ReturnType<typeof createServerSupabase>;
const PAGE_SIZE = 1000;
function nowStamp() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
export function userExportFilename(
kind: "account" | "chats" | "tabular-reviews",
userId: string,
) {
return `mike-${kind}-export-${userId.slice(0, 8)}-${nowStamp()}.json`;
}
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => !!value))];
}
async function throwIfError<T extends { message?: string } | null>(
error: T,
context: string,
) {
if (error) throw new Error(`${context}: ${error.message ?? "unknown error"}`);
}
async function selectAll(
db: Db,
table: string,
configure: (query: any) => any,
columns = "*",
): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = [];
for (let from = 0; ; from += PAGE_SIZE) {
const to = from + PAGE_SIZE - 1;
const query = configure(
(db as any)
.from(table)
.select(columns)
.range(from, to),
);
const { data, error } = await query;
await throwIfError(error, `Failed to export ${table}`);
const batch = (data ?? []) as Record<string, unknown>[];
rows.push(...batch);
if (batch.length < PAGE_SIZE) break;
}
return rows;
}
async function selectByIds(
db: Db,
table: string,
column: string,
ids: string[],
): Promise<Record<string, unknown>[]> {
if (ids.length === 0) return [];
return selectAll(db, table, (query) => query.in(column, ids));
}
function idsFrom(rows: Record<string, unknown>[], column = "id"): string[] {
return uniqueStrings(
rows.map((row) =>
typeof row[column] === "string" ? (row[column] as string) : null,
),
);
}
async function loadUserChats(db: Db, userId: string) {
const chats = await selectAll(db, "chats", (query) =>
query.eq("user_id", userId).order("created_at", { ascending: true }),
);
const chatIds = idsFrom(chats);
const messages = await selectByIds(db, "chat_messages", "chat_id", chatIds);
return { chats, messages };
}
async function loadUserTabularChats(db: Db, userId: string) {
const chats = await selectAll(db, "tabular_review_chats", (query) =>
query.eq("user_id", userId).order("created_at", { ascending: true }),
);
const chatIds = idsFrom(chats);
const messages = await selectByIds(
db,
"tabular_review_chat_messages",
"chat_id",
chatIds,
);
return { chats, messages };
}
async function loadApiKeyStatus(db: Db, userId: string) {
const rows = await selectAll(db, "user_api_keys", (query) =>
query
.eq("user_id", userId)
.order("provider", { ascending: true }),
"provider, created_at, updated_at",
);
return rows.map((row) => ({
provider: row.provider,
has_key: true,
created_at: row.created_at,
updated_at: row.updated_at,
}));
}
export async function buildUserChatsExport(
db: Db,
userId: string,
userEmail?: string | null,
) {
const [assistant, tabular] = await Promise.all([
loadUserChats(db, userId),
loadUserTabularChats(db, userId),
]);
return {
exported_at: new Date().toISOString(),
user: { id: userId, email: userEmail ?? null },
assistant_chats: assistant,
tabular_review_chats: tabular,
};
}
export async function buildUserTabularReviewsExport(
db: Db,
userId: string,
userEmail?: string | null,
) {
const tabularReviews = await selectAll(db, "tabular_reviews", (query) =>
query.eq("user_id", userId).order("created_at", { ascending: true }),
);
const reviewIds = idsFrom(tabularReviews);
const [cells, chats] = await Promise.all([
selectByIds(db, "tabular_cells", "review_id", reviewIds),
selectByIds(db, "tabular_review_chats", "review_id", reviewIds),
]);
const chatIds = idsFrom(chats);
const messages = await selectByIds(
db,
"tabular_review_chat_messages",
"chat_id",
chatIds,
);
return {
exported_at: new Date().toISOString(),
user: { id: userId, email: userEmail ?? null },
tabular_reviews: tabularReviews,
tabular_cells: cells,
tabular_review_chats: {
chats,
messages,
},
};
}
export async function buildUserAccountExport(
db: Db,
userId: string,
userEmail?: string | null,
) {
const [
profile,
apiKeys,
projects,
standaloneDocuments,
workflows,
hiddenWorkflows,
workflowSharesByUser,
workflowSharesWithUser,
assistantChats,
tabularChats,
tabularReviews,
sharedProjects,
sharedTabularReviews,
] = await Promise.all([
selectAll(db, "user_profiles", (query) => query.eq("user_id", userId)),
loadApiKeyStatus(db, userId),
selectAll(db, "projects", (query) =>
query.eq("user_id", userId).order("created_at", { ascending: true }),
),
selectAll(db, "documents", (query) =>
query
.eq("user_id", userId)
.is("project_id", null)
.order("created_at", { ascending: true }),
),
selectAll(db, "workflows", (query) =>
query.eq("user_id", userId).order("created_at", { ascending: true }),
),
selectAll(db, "hidden_workflows", (query) =>
query.eq("user_id", userId).order("created_at", { ascending: true }),
),
selectAll(db, "workflow_shares", (query) =>
query
.eq("shared_by_user_id", userId)
.order("created_at", { ascending: true }),
),
userEmail
? selectAll(db, "workflow_shares", (query) =>
query
.eq("shared_with_email", userEmail)
.order("created_at", { ascending: true }),
)
: Promise.resolve([]),
loadUserChats(db, userId),
loadUserTabularChats(db, userId),
selectAll(db, "tabular_reviews", (query) =>
query.eq("user_id", userId).order("created_at", { ascending: true }),
),
userEmail
? selectAll(db, "projects", (query) =>
query
.filter("shared_with", "cs", JSON.stringify([userEmail]))
.neq("user_id", userId)
.order("created_at", { ascending: true }),
"id, user_id, name, cm_number, created_at, updated_at",
)
: Promise.resolve([]),
userEmail
? selectAll(db, "tabular_reviews", (query) =>
query
.filter("shared_with", "cs", JSON.stringify([userEmail]))
.neq("user_id", userId)
.order("created_at", { ascending: true }),
"id, user_id, project_id, title, practice, created_at, updated_at",
)
: Promise.resolve([]),
]);
const projectIds = idsFrom(projects);
const projectDocuments = await selectByIds(
db,
"documents",
"project_id",
projectIds,
);
const documents = [...standaloneDocuments, ...projectDocuments];
const documentIds = idsFrom(documents);
const reviewIds = idsFrom(tabularReviews);
const [folders, versions, edits, tabularCells] = await Promise.all([
selectByIds(db, "project_subfolders", "project_id", projectIds),
selectByIds(db, "document_versions", "document_id", documentIds),
selectByIds(db, "document_edits", "document_id", documentIds),
selectByIds(db, "tabular_cells", "review_id", reviewIds),
]);
return {
exported_at: new Date().toISOString(),
user: { id: userId, email: userEmail ?? null },
profile,
api_keys: apiKeys,
projects,
project_subfolders: folders,
documents,
document_versions: versions,
document_edits: edits,
workflows,
hidden_workflows: hiddenWorkflows,
workflow_shares_by_user: workflowSharesByUser,
workflow_shares_with_user: workflowSharesWithUser,
chats: assistantChats,
tabular_reviews: tabularReviews,
tabular_cells: tabularCells,
tabular_review_chats: tabularChats,
shared_access: {
projects: sharedProjects,
tabular_reviews: sharedTabularReviews,
},
};
}

View file

@ -1,5 +1,90 @@
import { Request, Response, NextFunction } from "express";
import { createClient } from "@supabase/supabase-js";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
function summarizeMfaFactors(
factors: Array<{
factor_type?: string;
status?: string;
}> | null | undefined,
) {
return (factors ?? []).map((factor) => ({
type: factor.factor_type ?? "unknown",
status: factor.status ?? "unknown",
}));
}
function isLoginMfaBootstrapRoute(req: Request) {
const path = req.originalUrl.split("?")[0];
return (
(req.method === "GET" || req.method === "POST") &&
(path === "/user/profile" || path === "/users/profile")
);
}
async function enforceLoginMfaIfEnabled(
req: Request,
res: Response,
admin: SupabaseClient<any, "public", any>,
token: string,
) {
if (isLoginMfaBootstrapRoute(req)) return true;
const { data, error } = await admin
.from("user_profiles")
.select("mfa_on_login")
.eq("user_id", res.locals.userId)
.maybeSingle();
if (error) {
devLog("[auth/mfa] login preference lookup failed", {
method: req.method,
path: req.originalUrl,
userId: res.locals.userId,
error: error.message,
code: error.code,
});
if (error.code === "42703") return true;
res.status(500).json({ detail: error.message });
return false;
}
const profile = data as { mfa_on_login?: boolean } | null;
if (profile?.mfa_on_login !== true) return true;
const { data: assurance, error: assuranceError } =
await admin.auth.mfa.getAuthenticatorAssuranceLevel(token);
if (assuranceError) {
devLog("[auth/mfa] login assurance lookup failed", {
method: req.method,
path: req.originalUrl,
userId: res.locals.userId,
error: assuranceError.message,
});
res.status(401).json({ detail: assuranceError.message });
return false;
}
if (assurance.nextLevel === "aal2" && assurance.currentLevel !== "aal2") {
devLog("[auth/mfa] login verification required", {
method: req.method,
path: req.originalUrl,
userId: res.locals.userId,
});
res.status(403).json({
code: "mfa_verification_required",
detail: "MFA verification required",
});
return false;
}
return true;
}
export async function requireAuth(
req: Request,
@ -33,5 +118,85 @@ export async function requireAuth(
res.locals.userId = data.user.id;
res.locals.userEmail = data.user.email?.toLowerCase() ?? "";
res.locals.token = token;
if (!(await enforceLoginMfaIfEnabled(req, res, admin, token))) {
return;
}
next();
}
export async function requireMfaIfEnrolled(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const token = typeof res.locals.token === "string" ? res.locals.token : "";
if (!token) {
devLog("[auth/mfa] missing auth session", {
method: req.method,
path: req.originalUrl,
});
res.status(401).json({ detail: "Missing auth session" });
return;
}
const supabaseUrl = process.env.SUPABASE_URL ?? "";
const serviceKey = process.env.SUPABASE_SECRET_KEY ?? "";
if (!supabaseUrl || !serviceKey) {
res.status(500).json({ detail: "Server auth is not configured" });
return;
}
const admin = createClient(supabaseUrl, serviceKey, {
auth: { persistSession: false },
});
const { data, error } =
await admin.auth.mfa.getAuthenticatorAssuranceLevel(token);
if (error) {
devLog("[auth/mfa] assurance lookup failed", {
method: req.method,
path: req.originalUrl,
userId: res.locals.userId,
error: error.message,
});
res.status(401).json({ detail: error.message });
return;
}
devLog("[auth/mfa] assurance level", {
method: req.method,
path: req.originalUrl,
userId: res.locals.userId,
currentLevel: data.currentLevel,
nextLevel: data.nextLevel,
required: data.nextLevel === "aal2" && data.currentLevel !== "aal2",
});
if (isDev) {
const { data: userData, error: userError } = await admin.auth.getUser(token);
devLog("[auth/mfa] user factors", {
method: req.method,
path: req.originalUrl,
userId: res.locals.userId,
factorCount: userData.user?.factors?.length ?? 0,
factors: summarizeMfaFactors(userData.user?.factors),
error: userError?.message ?? null,
});
}
if (data.nextLevel === "aal2" && data.currentLevel !== "aal2") {
devLog("[auth/mfa] verification required", {
method: req.method,
path: req.originalUrl,
userId: res.locals.userId,
});
res.status(403).json({
code: "mfa_verification_required",
detail: "MFA verification required",
});
return;
}
next();
}

View file

@ -17,6 +17,7 @@ import {
import { completeText } from "../lib/llm";
import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings";
import { checkProjectAccess } from "../lib/access";
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
export const chatRouter = Router();
@ -427,7 +428,7 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
res.json({ title });
} catch (err) {
console.error("[generate-title]", err);
console.error("[generate-title]", safeErrorLog(err));
res.status(500).json({ detail: "Failed to generate title" });
}
});
@ -639,9 +640,8 @@ chatRouter.post("/", requireAuth, async (req, res) => {
}
return;
}
console.error("[chat/stream] error:", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
console.error("[chat/stream] error:", safeErrorLog(err));
const message = safeErrorMessage(err, "Stream error");
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];

View file

@ -17,6 +17,7 @@ import {
} from "../lib/chatTools";
import { getUserApiKeys } from "../lib/userSettings";
import { checkProjectAccess } from "../lib/access";
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
const PROJECT_SYSTEM_PROMPT_EXTRA = `PROJECT CONTEXT:
You are operating within a project folder that contains a collection of legal documents the user has organised for a single matter. The user's questions will usually refer to one or more documents in this project your job is to find the relevant files to work on. Use list_documents to see what is available and fetch_documents / read_document to pull in any documents you need before answering.
@ -224,9 +225,8 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
}
return;
}
console.error("[project-chat/stream] error:", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
console.error("[project-chat/stream] error:", safeErrorLog(err));
const message = safeErrorMessage(err, "Stream error");
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];

View file

@ -15,6 +15,7 @@ import {
import { docxToPdf, convertedPdfKey } from "../lib/convert";
import { checkProjectAccess } from "../lib/access";
import { singleFileUpload } from "../lib/upload";
import { deleteUserProjects } from "../lib/userDataCleanup";
export const projectsRouter = Router();
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
@ -345,13 +346,15 @@ projectsRouter.delete("/:projectId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const { projectId } = req.params;
const db = createServerSupabase();
const { error } = await db
.from("projects")
.delete()
.eq("id", projectId)
.eq("user_id", userId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
try {
const deletedCount = await deleteUserProjects(db, userId, [projectId]);
if (deletedCount === 0)
return void res.status(404).json({ detail: "Project not found" });
res.status(204).send();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
res.status(500).json({ detail });
}
});
// GET /projects/:projectId/documents

View file

@ -31,6 +31,7 @@ import {
filterAccessibleDocumentIds,
listAccessibleProjectIds,
} from "../lib/access";
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
function formatPromptSuffix(format?: string, tags?: string[]): string {
switch (format) {
@ -1040,7 +1041,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
} catch (err) {
console.error(
`[tabular/generate] queryTabularAllColumns error doc=${docId}`,
err,
safeErrorLog(err),
);
}
@ -1063,10 +1064,10 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
write("data: [DONE]\n\n");
} catch (err) {
console.error("[tabular/generate] stream error", err);
console.error("[tabular/generate] stream error", safeErrorLog(err));
try {
write(
`data: ${JSON.stringify({ type: "error", message: String(err) })}\n\ndata: [DONE]\n\n`,
`data: ${JSON.stringify({ type: "error", message: safeErrorMessage(err, "Stream error") })}\n\ndata: [DONE]\n\n`,
);
} catch {
/* ignore */
@ -1518,9 +1519,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
}
return;
}
console.error("[tabular/chat] error", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
console.error("[tabular/chat] error", safeErrorLog(err));
const message = safeErrorMessage(err, "Stream error");
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];
@ -1633,7 +1633,7 @@ The "summary" field must contain only the extracted value with inline citations
apiKeys,
});
} catch (err) {
console.error("[queryTabularCell] completion failed", err);
console.error("[queryTabularCell] completion failed", safeErrorLog(err));
return null;
}
try {
@ -1844,7 +1844,7 @@ Rules:
},
});
} catch (err) {
console.error("[queryTabularAllColumns] stream failed", err);
console.error("[queryTabularAllColumns] stream failed", safeErrorLog(err));
}
if (contentBuffer.trim()) pending.push(processLine(contentBuffer));

View file

@ -1,5 +1,5 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { requireAuth, requireMfaIfEnrolled } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import {
DEFAULT_TABULAR_MODEL,
@ -15,6 +15,18 @@ import {
normalizeApiKeyProvider,
saveUserApiKey,
} from "../lib/userApiKeys";
import {
deleteAllUserChats,
deleteAllUserTabularReviews,
deleteUserAccountData,
deleteUserProjects,
} from "../lib/userDataCleanup";
import {
buildUserAccountExport,
buildUserChatsExport,
buildUserTabularReviewsExport,
userExportFilename,
} from "../lib/userDataExport";
export const userRouter = Router();
@ -28,6 +40,7 @@ type UserProfileRow = {
tier: string;
title_model: string | null;
tabular_model: string;
mfa_on_login: boolean | null;
};
function errorMessage(error: unknown): string {
@ -48,20 +61,19 @@ function errorMessage(error: unknown): string {
}
const PROFILE_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model";
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model, mfa_on_login";
const LEGACY_PROFILE_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model";
const LEGACY_PROFILE_MODEL_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model";
function isMissingProfileModelColumn(error: unknown): boolean {
function isMissingProfileColumn(error: unknown, column: string): boolean {
const record =
error && typeof error === "object"
? (error as { code?: unknown; message?: unknown })
: {};
const message = typeof record.message === "string" ? record.message : "";
return (
record.code === "42703" ||
message.includes("title_model")
);
return record.code === "42703" && message.includes(column);
}
async function selectProfile(
@ -74,7 +86,30 @@ async function selectProfile(
.select(PROFILE_SELECT)
.eq("user_id", userId);
const result = mode === "single" ? await query.single() : await query.maybeSingle();
if (!result.error || !isMissingProfileModelColumn(result.error)) {
if (!result.error) {
return result;
}
const missingMfaOnLogin = isMissingProfileColumn(result.error, "mfa_on_login");
if (missingMfaOnLogin) {
const modelQuery = db
.from("user_profiles")
.select(LEGACY_PROFILE_MODEL_SELECT)
.eq("user_id", userId);
const modelLegacy =
mode === "single" ? await modelQuery.single() : await modelQuery.maybeSingle();
if (!modelLegacy.error || !isMissingProfileColumn(modelLegacy.error, "title_model")) {
if (modelLegacy.data && typeof modelLegacy.data === "object") {
const row = modelLegacy.data as Record<string, unknown>;
Object.assign(row, {
mfa_on_login: false,
});
}
return modelLegacy;
}
}
if (!missingMfaOnLogin && !isMissingProfileColumn(result.error, "title_model")) {
return result;
}
@ -88,6 +123,7 @@ async function selectProfile(
const row = legacy.data as Record<string, unknown>;
Object.assign(row, {
title_model: null,
mfa_on_login: false,
});
}
return legacy;
@ -114,6 +150,7 @@ function serializeProfile(
tier: row.tier || "Free",
titleModel: resolveModel(row.title_model, titleFallback),
tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL),
mfaOnLogin: row.mfa_on_login === true,
...(apiKeyStatus ? { apiKeyStatus } : {}),
};
}
@ -193,6 +230,44 @@ function validateProfilePayload(body: unknown):
return { ok: true, update };
}
function readBooleanBodyField(
body: unknown,
field: string,
): { ok: true; value: boolean } | { ok: false; detail: string } {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return { ok: false, detail: "Expected a JSON object" };
}
const raw = body as Record<string, unknown>;
const invalidField = Object.keys(raw).find((key) => key !== field);
if (invalidField) {
return { ok: false, detail: `Unsupported field: ${invalidField}` };
}
if (typeof raw[field] !== "boolean") {
return { ok: false, detail: `${field} must be a boolean` };
}
return { ok: true, value: raw[field] };
}
async function userHasVerifiedTotpFactor(
db: ReturnType<typeof createServerSupabase>,
userId: string,
) {
const { data, error } = await db.auth.admin.getUserById(userId);
if (error) return { ok: false as const, error };
const factors = data.user?.factors ?? [];
return {
ok: true as const,
hasVerifiedTotp: factors.some(
(factor) =>
factor.factor_type === "totp" &&
factor.status === "verified",
),
};
}
async function ensureProfileRow(
db: ReturnType<typeof createServerSupabase>,
userId: string,
@ -299,6 +374,54 @@ userRouter.patch("/profile", requireAuth, async (req, res) => {
res.json({ ...data, apiKeyStatus });
});
// PATCH /user/security/mfa-login
userRouter.patch(
"/security/mfa-login",
requireAuth,
requireMfaIfEnrolled,
async (req, res) => {
const userId = res.locals.userId as string;
const parsed = readBooleanBodyField(req.body, "enabled");
if (!parsed.ok)
return void res.status(400).json({ detail: parsed.detail });
const db = createServerSupabase();
if (parsed.value) {
const factorCheck = await userHasVerifiedTotpFactor(db, userId);
if (!factorCheck.ok) {
return void res.status(500).json({
detail: factorCheck.error.message,
});
}
if (!factorCheck.hasVerifiedTotp) {
return void res.status(400).json({
detail:
"Set up an authenticator app before requiring verification on login.",
});
}
}
const ensureError = await ensureProfileRow(db, userId);
if (ensureError)
return void res.status(500).json({ detail: ensureError.message });
const { error: updateError } = await db
.from("user_profiles")
.update({
mfa_on_login: parsed.value,
updated_at: new Date().toISOString(),
})
.eq("user_id", userId);
if (updateError)
return void res.status(500).json({ detail: updateError.message });
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
const { data, error } = await loadProfile(db, userId, { apiKeyStatus });
if (error) return void res.status(500).json({ detail: error.message });
res.json({ ...data, apiKeyStatus });
},
);
// GET /user/api-keys
userRouter.get("/api-keys", requireAuth, async (_req, res) => {
const userId = res.locals.userId as string;
@ -308,7 +431,7 @@ userRouter.get("/api-keys", requireAuth, async (_req, res) => {
});
// PUT /user/api-keys/:provider
userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => {
userRouter.put("/api-keys/:provider", requireAuth, requireMfaIfEnrolled, async (req, res) => {
const userId = res.locals.userId as string;
const provider = normalizeApiKeyProvider(req.params.provider);
if (!provider)
@ -338,10 +461,126 @@ userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => {
});
// DELETE /user/account
userRouter.delete("/account", requireAuth, async (_req, res) => {
userRouter.delete("/account", requireAuth, requireMfaIfEnrolled, async (_req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const db = createServerSupabase();
try {
await deleteUserAccountData(db, userId, userEmail);
const { error } = await db.auth.admin.deleteUser(userId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
} catch (err) {
const detail = errorMessage(err);
console.error("[user/account] delete failed", { userId, error: detail });
res.status(500).json({ detail });
}
});
// DELETE /user/chats
userRouter.delete("/chats", requireAuth, requireMfaIfEnrolled, async (_req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const { error } = await db.auth.admin.deleteUser(userId);
if (error) return void res.status(500).json({ detail: error.message });
res.status(204).send();
try {
await deleteAllUserChats(db, userId);
res.status(204).send();
} catch (err) {
const detail = errorMessage(err);
console.error("[user/chats] delete failed", { userId, error: detail });
res.status(500).json({ detail });
}
});
// DELETE /user/projects
userRouter.delete("/projects", requireAuth, requireMfaIfEnrolled, async (_req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
try {
await deleteUserProjects(db, userId);
res.status(204).send();
} catch (err) {
const detail = errorMessage(err);
console.error("[user/projects] delete failed", { userId, error: detail });
res.status(500).json({ detail });
}
});
// DELETE /user/tabular-reviews
userRouter.delete("/tabular-reviews", requireAuth, requireMfaIfEnrolled, async (_req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
try {
await deleteAllUserTabularReviews(db, userId);
res.status(204).send();
} catch (err) {
const detail = errorMessage(err);
console.error("[user/tabular-reviews] delete failed", {
userId,
error: detail,
});
res.status(500).json({ detail });
}
});
// GET /user/export
userRouter.get("/export", requireAuth, requireMfaIfEnrolled, async (_req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const db = createServerSupabase();
try {
const data = await buildUserAccountExport(db, userId, userEmail);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="${userExportFilename("account", userId)}"`,
);
res.json(data);
} catch (err) {
const detail = errorMessage(err);
console.error("[user/export] failed", { userId, error: detail });
res.status(500).json({ detail });
}
});
// GET /user/chats/export
userRouter.get("/chats/export", requireAuth, requireMfaIfEnrolled, async (_req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const db = createServerSupabase();
try {
const data = await buildUserChatsExport(db, userId, userEmail);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="${userExportFilename("chats", userId)}"`,
);
res.json(data);
} catch (err) {
const detail = errorMessage(err);
console.error("[user/chats/export] failed", { userId, error: detail });
res.status(500).json({ detail });
}
});
// GET /user/tabular-reviews/export
userRouter.get("/tabular-reviews/export", requireAuth, requireMfaIfEnrolled, async (_req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const db = createServerSupabase();
try {
const data = await buildUserTabularReviewsExport(db, userId, userEmail);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="${userExportFilename("tabular-reviews", userId)}"`,
);
res.json(data);
} catch (err) {
const detail = errorMessage(err);
console.error("[user/tabular-reviews/export] failed", {
userId,
error: detail,
});
res.status(500).json({ detail });
}
});