diff --git a/public/app/dom.js b/public/app/dom.js
index ec67011..9406765 100644
--- a/public/app/dom.js
+++ b/public/app/dom.js
@@ -21,6 +21,8 @@ const loginUsernameInput = $("loginUsername");
const loginPasswordInput = $("loginPassword");
const registerUsernameInput = $("registerUsername");
const registerPasswordInput = $("registerPassword");
+const registerInviteField = $("registerInviteField");
+const registerInviteCodeInput = $("registerInviteCode");
const loginSubmit = $("loginSubmit");
const registerSubmit = $("registerSubmit");
const chatList = $("chatList");
@@ -190,6 +192,8 @@ export {
popupModal,
popupTitle,
registerForm,
+ registerInviteCodeInput,
+ registerInviteField,
registerPasswordInput,
registerSubmit,
registerUsernameInput,
diff --git a/public/index.html b/public/index.html
index dd70f3d..a2b7469 100644
--- a/public/index.html
+++ b/public/index.html
@@ -52,6 +52,10 @@
Create password
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/settings.js b/public/settings.js
index 8b33fed..c5ba6d7 100644
--- a/public/settings.js
+++ b/public/settings.js
@@ -21,6 +21,9 @@ const usernamePasswordInput = $("usernamePassword");
const currentPasswordInput = $("currentPassword");
const newPasswordInput = $("newPassword");
const repeatPasswordInput = $("repeatPassword");
+const exportAccountBtn = $("exportAccount");
+const logoutAllSessionsBtn = $("logoutAllSessions");
+const deleteAccountBtn = $("deleteAccount");
const usernameForm = $("usernameForm");
const passwordForm = $("passwordForm");
const settingsNavItems = document.querySelectorAll("[data-settings-view]");
@@ -458,6 +461,56 @@ async function unpublishPersona(id) {
setNotice("Persona unpublished.", "success");
}
+function downloadJson(filename, payload) {
+ const blob = new Blob([JSON.stringify(payload, null, 2)], {type: "application/json;charset=utf-8"});
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
+
+async function exportAccount() {
+ const res = await get("/settings/export");
+ if (res.error) return setNotice(res.error, "error");
+ const username = currentUsernameInput.value || "account";
+ downloadJson(`account-export-${username}-${new Date().toISOString().slice(0, 10)}.json`, res.account);
+ setNotice("Account export downloaded.", "success");
+}
+
+async function logoutAllSessions() {
+ const confirmed = await promptSettingsPopup({
+ eyebrow: "Security",
+ title: "Logout all sessions",
+ description: "Type logout to sign out this browser and every other active session.",
+ label: "Confirmation",
+ placeholder: "logout",
+ confirmLabel: "Logout all"
+ });
+ if (String(confirmed || "").trim().toLowerCase() !== "logout") return;
+ const res = await post("/settings/logout-all-sessions", {});
+ if (res.error) return setNotice(res.error, "error");
+ window.location.href = "/";
+}
+
+async function deleteAccount() {
+ const currentPassword = await promptSettingsPopup({
+ eyebrow: "Danger zone",
+ title: "Delete account",
+ description: "Enter your current password to permanently delete your account and all account data.",
+ label: "Current password",
+ placeholder: "Password",
+ confirmLabel: "Delete account"
+ });
+ if (currentPassword === null) return;
+ const res = await request("/settings/account", {currentPassword}, "DELETE");
+ if (res.error) return setNotice(res.error, "error");
+ window.location.href = "/";
+}
+
usernameForm.addEventListener("submit", async (event) => {
event.preventDefault();
const username = newUsernameInput.value.trim();
@@ -514,6 +567,9 @@ workspaceModeSelect.addEventListener("change", (event) => {
localStorage.setItem("krishd-workspace-mode", mode);
setNotice(`Workspace mode set to ${mode}.`, "success");
});
+exportAccountBtn.addEventListener("click", () => { void exportAccount(); });
+logoutAllSessionsBtn.addEventListener("click", () => { void logoutAllSessions(); });
+deleteAccountBtn.addEventListener("click", () => { void deleteAccount(); });
refreshDiagnosticsBtn.addEventListener("click", () => { void loadDiagnostics(); });
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
diff --git a/src/app.js b/src/app.js
index 93790ee..e57d6f4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -137,6 +137,23 @@ function sameOriginProtection(config) {
};
}
+function enforceSessionVersion(repositories) {
+ return (req, res, next) => {
+ if (!req.session.user) return next();
+ const user = repositories.getUser(req.session.user);
+ const currentVersion = Number(user?.session_version || 0);
+ const sessionVersion = Number(req.session.sessionVersion || 0);
+ if (user && currentVersion === sessionVersion) return next();
+
+ req.session.destroy(() => {
+ if (req.path === "/session") {
+ return res.json({user: null});
+ }
+ return res.status(403).json({error: "Session expired. Please log in again."});
+ });
+ };
+}
+
export function createApp(options = {}) {
const config = createConfig(options.config);
const app = express();
@@ -181,6 +198,7 @@ export function createApp(options = {}) {
}
}));
app.use(sameOriginProtection(config));
+ app.use(enforceSessionVersion(repositories));
app.locals.config = config;
app.locals.db = db;
@@ -188,7 +206,7 @@ export function createApp(options = {}) {
app.locals.modelService = modelService;
app.locals.close = createCloseHandler(closeHandlers);
- app.use(createAuthRouter({repositories, authRateLimiters}));
+ app.use(createAuthRouter({repositories, authRateLimiters, config}));
app.use(createSettingsRouter({repositories}));
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
diff --git a/src/config.js b/src/config.js
index 627bc6f..6019691 100644
--- a/src/config.js
+++ b/src/config.js
@@ -37,6 +37,7 @@ export function createConfig(overrides = {}) {
allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false),
trustProxy: trustProxy(process.env.TRUST_PROXY, false),
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
+ registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "",
sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true),
staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0),
staticEtag: bool(process.env.STATIC_ETAG, true),
diff --git a/src/db/repositories.js b/src/db/repositories.js
index ab1fafe..ed0e2b3 100644
--- a/src/db/repositories.js
+++ b/src/db/repositories.js
@@ -41,9 +41,10 @@ function normalizeMarketPersonaType(personaType) {
export function createRepositories(db, config) {
const statements = {
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
- getUser: db.prepare("SELECT username, password FROM users WHERE username = ?"),
+ getUser: db.prepare("SELECT username, password, session_version FROM users WHERE username = ?"),
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
+ incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
insertChatSession: db.prepare(
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
),
@@ -547,6 +548,17 @@ export function createRepositories(db, config) {
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
+ deleteChatMemoriesByUser: db.prepare("DELETE FROM chat_memories WHERE username = ?"),
+ deleteChatSessionsByUser: db.prepare("DELETE FROM chat_sessions WHERE username = ?"),
+ deletePersonasByUser: db.prepare("DELETE FROM personas WHERE username = ?"),
+ deleteUserSettingsByUser: db.prepare("DELETE FROM user_settings WHERE username = ?"),
+ deletePersonaVersionsByUser: db.prepare("DELETE FROM persona_versions WHERE username = ?"),
+ deletePromptTemplatesByUser: db.prepare("DELETE FROM prompt_templates WHERE username = ?"),
+ deleteMarketPersonasByCreator: db.prepare("DELETE FROM persona_market WHERE creator_username = ?"),
+ deleteMarketFavoritesByUser: db.prepare("DELETE FROM persona_market_favorites WHERE username = ?"),
+ deleteMarketRatingsByUser: db.prepare("DELETE FROM persona_market_ratings WHERE username = ?"),
+ deleteMarketReportsByUser: db.prepare("DELETE FROM persona_market_reports WHERE reporter_username = ?"),
+ deleteUser: db.prepare("DELETE FROM users WHERE username = ?"),
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
};
@@ -566,6 +578,20 @@ export function createRepositories(db, config) {
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
});
+ const deleteUserTransaction = db.transaction((username) => {
+ statements.deleteMarketFavoritesByUser.run(username);
+ statements.deleteMarketRatingsByUser.run(username);
+ statements.deleteMarketReportsByUser.run(username);
+ statements.deleteMarketPersonasByCreator.run(username);
+ statements.deletePromptTemplatesByUser.run(username);
+ statements.deletePersonaVersionsByUser.run(username);
+ statements.deleteChatMemoriesByUser.run(username);
+ statements.deleteChatSessionsByUser.run(username);
+ statements.deleteUserSettingsByUser.run(username);
+ statements.deletePersonasByUser.run(username);
+ return statements.deleteUser.run(username);
+ });
+
const savePersonaVersion = db.transaction((personaId, username) => {
const persona = statements.getPersona.get(personaId, username);
if (!persona) return null;
@@ -670,7 +696,12 @@ export function createRepositories(db, config) {
getUser: (username) => statements.getUser.get(username),
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
+ incrementSessionVersion(username) {
+ statements.incrementSessionVersion.run(username);
+ return statements.getUser.get(username);
+ },
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
+ deleteUser: (username) => deleteUserTransaction(username),
listChats: (username) => statements.listChatSessions.all(username),
searchChats(username, query) {
const like = `%${String(query || "").trim().toLowerCase()}%`;
diff --git a/src/db/schema.js b/src/db/schema.js
index 7ee0910..e834c89 100644
--- a/src/db/schema.js
+++ b/src/db/schema.js
@@ -6,7 +6,8 @@ export function initializeSchema(db) {
CREATE TABLE IF NOT EXISTS users
(
username TEXT PRIMARY KEY,
- password TEXT NOT NULL
+ password TEXT NOT NULL,
+ session_version INTEGER DEFAULT 0
)
`).run();
@@ -204,6 +205,10 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
}
};
+ const userColumns = db.prepare("PRAGMA table_info(users)").all();
+ ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0");
+ db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run();
+
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 840ba43..2f398e3 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -5,18 +5,22 @@ import {validateBody} from "../middleware/validate.js";
const credentialsValidator = (body) => {
const username = String(body?.username || "").trim();
const password = String(body?.password || "");
+ const inviteCode = String(body?.inviteCode || "").trim();
if (!username) return {error: "Username is required"};
if (username.length < 3) return {error: "Username must be at least 3 characters"};
if (!password) return {error: "Password is required"};
if (password.length < 6) return {error: "Password must be at least 6 characters"};
- return {value: {username, password}};
+ return {value: {username, password, inviteCode}};
};
-export function createAuthRouter({repositories, authRateLimiters}) {
+export function createAuthRouter({repositories, authRateLimiters, config}) {
const router = express.Router();
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
- const {username, password} = req.validatedBody;
+ const {username, password, inviteCode} = req.validatedBody;
+ if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
+ return res.status(403).json({error: "Valid invite code is required"});
+ }
if (repositories.getUser(username)) {
return res.status(400).json({error: "User already exists"});
}
@@ -33,6 +37,7 @@ export function createAuthRouter({repositories, authRateLimiters}) {
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(403).json({error: "Invalid password"});
req.session.user = username;
+ req.session.sessionVersion = Number(user.session_version || 0);
return res.json({message: "Login successful"});
});
@@ -41,7 +46,10 @@ export function createAuthRouter({repositories, authRateLimiters}) {
});
router.get("/session", (req, res) => {
- return res.json({user: req.session.user || null});
+ return res.json({
+ user: req.session.user || null,
+ inviteOnlyRegistration: Boolean(config.registrationInviteCode)
+ });
});
return router;
diff --git a/src/routes/settings.js b/src/routes/settings.js
index d654adb..3174ce1 100644
--- a/src/routes/settings.js
+++ b/src/routes/settings.js
@@ -20,6 +20,14 @@ const passwordValidator = (body) => {
return {value: {currentPassword, newPassword}};
};
+const currentPasswordValidator = (body) => {
+ const currentPassword = String(body?.currentPassword || "");
+ if (!currentPassword) return {error: "Current password is required"};
+ return {value: {currentPassword}};
+};
+
+const destroySession = (req) => new Promise((resolve) => req.session.destroy(() => resolve()));
+
export function createSettingsRouter({repositories}) {
const router = express.Router();
router.use(requireLogin);
@@ -59,8 +67,38 @@ export function createSettingsRouter({repositories}) {
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
const hashed = await bcrypt.hash(newPassword, 10);
repositories.updatePassword(hashed, username);
+ const updatedUser = repositories.incrementSessionVersion(username);
+ req.session.sessionVersion = Number(updatedUser.session_version || 0);
return res.json({message: "Password updated"});
});
+ router.post("/settings/logout-all-sessions", (req, res) => {
+ repositories.incrementSessionVersion(req.session.user);
+ req.session.destroy(() => res.json({message: "All sessions logged out"}));
+ });
+
+ router.get("/settings/export", (req, res) => {
+ const user = repositories.getUser(req.session.user);
+ if (!user) return res.status(404).json({error: "User not found"});
+ return res.json({
+ account: {
+ username: user.username,
+ exportedAt: new Date().toISOString(),
+ workspace: repositories.exportWorkspace(req.session.user)
+ }
+ });
+ });
+
+ router.delete("/settings/account", validateBody(currentPasswordValidator), async (req, res) => {
+ const username = req.session.user;
+ const user = repositories.getUser(username);
+ if (!user) return res.status(404).json({error: "User not found"});
+ const valid = await bcrypt.compare(req.validatedBody.currentPassword, user.password);
+ if (!valid) return res.status(403).json({error: "Current password is incorrect"});
+ repositories.deleteUser(username);
+ await destroySession(req);
+ return res.json({message: "Account deleted"});
+ });
+
return router;
}
diff --git a/test/app.test.js b/test/app.test.js
index b4a7e43..eceadb0 100644
--- a/test/app.test.js
+++ b/test/app.test.js
@@ -140,6 +140,25 @@ test("auth flow validates inputs and persists session", async () => {
await server.close();
});
+test("invite-only registration requires invite code", async () => {
+ const server = await startTestServer({config: {registrationInviteCode: "let-me-in"}});
+
+ await request(server.baseUrl, "/register", {
+ method: "POST",
+ body: {username: "invitee", password: "password123"}
+ }).then(({status, json}) => {
+ assert.equal(status, 403);
+ assert.match(json.error, /invite code/i);
+ });
+
+ await request(server.baseUrl, "/register", {
+ method: "POST",
+ body: {username: "invitee", password: "password123", inviteCode: "let-me-in"}
+ }).then(({status}) => assert.equal(status, 200));
+
+ await server.close();
+});
+
test("createApp can use an injected session store", async () => {
class CountingStore extends session.Store {
constructor() {
@@ -183,6 +202,53 @@ test("createApp can use an injected session store", async () => {
await server.close();
});
+test("account export, logout-all, and deletion controls work", async () => {
+ const server = await startTestServer();
+ const firstJar = new CookieJar();
+ const secondJar = new CookieJar();
+
+ await request(server.baseUrl, "/register", {method: "POST", body: {username: "owner", password: "password123"}});
+ await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: firstJar});
+ await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: secondJar});
+
+ const exported = await request(server.baseUrl, "/settings/export", {jar: firstJar});
+ assert.equal(exported.status, 200);
+ assert.equal(exported.json.account.username, "owner");
+ assert.ok(exported.json.account.workspace);
+
+ const logoutAll = await request(server.baseUrl, "/settings/logout-all-sessions", {
+ method: "POST",
+ body: {},
+ jar: firstJar
+ });
+ assert.equal(logoutAll.status, 200);
+ const staleSession = await request(server.baseUrl, "/settings/profile", {jar: secondJar});
+ assert.equal(staleSession.status, 403);
+
+ await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: firstJar});
+ const wrongDelete = await request(server.baseUrl, "/settings/account", {
+ method: "DELETE",
+ body: {currentPassword: "wrong-password"},
+ jar: firstJar
+ });
+ assert.equal(wrongDelete.status, 403);
+
+ const deleted = await request(server.baseUrl, "/settings/account", {
+ method: "DELETE",
+ body: {currentPassword: "password123"},
+ jar: firstJar
+ });
+ assert.equal(deleted.status, 200);
+
+ const loginAfterDelete = await request(server.baseUrl, "/login", {
+ method: "POST",
+ body: {username: "owner", password: "password123"}
+ });
+ assert.equal(loginAfterDelete.status, 400);
+
+ await server.close();
+});
+
test("admin diagnostics require admin and return model status", async () => {
const server = await startTestServer({
config: {