diff --git a/README.md b/README.md index c701a3f..32617e1 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,20 @@ Pure chat message, retry, memory, summary, and persona-state formatting lives in - [Privacy Policy](PRIVACY_POLICY.md) - [Terms of Service](TERMS_OF_SERVICE.md) + +## Page navigation + +- `/` is the public landing page, even for signed-in visitors. +- `/login` and `/register` provide dedicated account forms. Registration starts a session automatically. +- `/app` is workspace home, with recent chats and optional starters. +- `/app/chats/new` opens an empty composer; sending the first message creates the conversation. +- `/app/chats/:id` opens a conversation. Legacy `/?chat=:id` links redirect here. +- `/app/personas` provides persona management, using the shared settings editor module. +- `/app/settings` contains account and appearance settings. `/settings` redirects here. +- `/market` allows visitors to browse public summaries and signed-in users to use the full persona market. + +Protected pages redirect to sign-in with a local return destination. Public discovery only returns published persona summaries; using a persona requires an account. + +All page routes render the same navigation from `src/pageLayout.js` into the `` placeholder. `public/shell.css` owns the shared header, page spacing, and mobile navigation; `public/app/workspaceNavigation.js` owns sign-out. Page-specific headers contain only local titles and actions. The chat sidebar is reserved for conversation history. Public and home content share `public/pages.css` and `public/pages.js`. Page routes run before static assets and authenticated API routers; rendered pages use private, non-cacheable responses. + +For environments that block child-process test workers, run `node --test --test-isolation=none`. diff --git a/public/app/constants.js b/public/app/constants.js index cda0318..53e03e1 100644 --- a/public/app/constants.js +++ b/public/app/constants.js @@ -1,4 +1,4 @@ -export const requestedChatId = Number(new URLSearchParams(window.location.search).get("chat")) || null; +export const requestedChatId = Number(window.location.pathname.match(/^\/app\/chats\/(\d+)$/)?.[1] || new URLSearchParams(window.location.search).get("chat")) || null; export const onboardingPrompts = { ask: ["Explain this topic in simple terms.", "Compare these options and recommend one."], diff --git a/public/app/dom.js b/public/app/dom.js index c8114a2..9669391 100644 --- a/public/app/dom.js +++ b/public/app/dom.js @@ -1,9 +1,6 @@ const $ = (id) => document.getElementById(id); -const authDiv = $("auth"); const chatDiv = $("chat"); -const authMsg = $("authMsg"); -const logoutButton = $("logout"); const messagesDiv = $("messages"); const modelSelect = $("model"); const modelPanel = $("modelPanel"); @@ -20,18 +17,8 @@ const chatResponseLengthInput = $("chatResponseLength"); const chatSystemInstructionInput = $("chatSystemInstruction"); const saveGenerationSettingsBtn = $("saveGenerationSettings"); const chatDrawerPersonaValue = $("chatDrawerPersonaValue"); -const loginForm = $("loginForm"); -const registerForm = $("registerForm"); const msgInput = $("msgInput"); const sendBtn = $("send"); -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"); const chatSearchInput = $("chatSearch"); const chatSidebar = $("chatSidebar"); @@ -54,7 +41,6 @@ const backupWorkspaceBtn = $("backupWorkspace"); const importWorkspaceBtn = $("importWorkspace"); const importWorkspaceFileInput = $("importWorkspaceFile"); const activeChatTitle = $("activeChatTitle"); -const sessionUser = $("sessionUser"); const chatActivityOverlay = $("chatActivityOverlay"); const chatActivityEyebrow = $("chatActivityEyebrow"); const chatActivityTitle = $("chatActivityTitle"); @@ -110,8 +96,6 @@ const onboardingChoices = $("onboardingChoices"); const onboardingBack = $("onboardingBack"); const onboardingContinue = $("onboardingContinue"); const onboardingSkip = $("onboardingSkip"); -const authScreens = document.querySelectorAll(".auth-screen"); -const toggleButtons = document.querySelectorAll(".auth-toggle .toggle"); const brandNameTargets = document.querySelectorAll("[data-brand-name]"); const brandLogoTargets = document.querySelectorAll("[data-brand-logo]"); const modelSection = document.querySelector(".chat-drawer-section--model"); @@ -121,9 +105,6 @@ export { $, activeChatTitle, activeUserPersonaStatus, - authDiv, - authMsg, - authScreens, chatActivityDetail, chatActivityEyebrow, chatActivityOverlay, @@ -156,11 +137,6 @@ export { backupWorkspaceBtn, importWorkspaceBtn, importWorkspaceFileInput, - loginForm, - loginPasswordInput, - loginSubmit, - loginUsernameInput, - logoutButton, messagesDiv, modelBadgeName, headerModelName, @@ -206,12 +182,6 @@ export { popupInputLabel, popupModal, popupTitle, - registerForm, - registerInviteCodeInput, - registerInviteField, - registerPasswordInput, - registerSubmit, - registerUsernameInput, renameChatBtn, roleplayCharacterSelect, roleplayNewPersonaBtn, @@ -230,9 +200,7 @@ export { roleplayUserPersonaSelect, sendBtn, saveGenerationSettingsBtn, - sessionUser, brandLogoTargets, brandNameTargets, - toggleButtons, userPersonaList }; diff --git a/public/index.html b/public/index.html deleted file mode 100644 index a10e550..0000000 --- a/public/index.html +++ /dev/null @@ -1,431 +0,0 @@ - - - - - - Elsewhere - - - - - - - - -
-
-
-
- -
-

Your conversation space

-

elsewhere

-

A space for every conversation.

-
-
-
- -
-
- - -
- -
-
- - - -
-
- -
-
- - - - - -
-
- -

-
-
- - -
- - - - - - - - - - - - - - diff --git a/public/market.html b/public/market.html index 0739d19..c74d09c 100644 --- a/public/market.html +++ b/public/market.html @@ -3,26 +3,24 @@ - Elsewhere Market + Elsewhere Discover - - + + + - -
+ + +

Shared personas

-

elsewhere Market

-
-
- - Settings - Back to chat +

Discover

+
-
+ - - + + diff --git a/public/market.js b/public/market.js index 1d44c4a..650ee4b 100644 --- a/public/market.js +++ b/public/market.js @@ -540,7 +540,7 @@ async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPr return; } if (userPersonaSelection === "create_new") { - window.location.href = "/settings?view=my-roleplay-companions&create=user"; + window.location.href = "/app/personas?view=my-roleplay-companions&create=user"; return; } @@ -560,7 +560,7 @@ async function startMarketPersonaChat(marketId, userPersonaSelection, scenarioPr setMarketStatus(res.error || "Unable to start chat.", "error"); return; } - window.location.href = `/?chat=${res.chat.id}`; + window.location.href = `/app/chats/${res.chat.id}`; } async function savePersona() { @@ -623,7 +623,7 @@ if (marketCreateAiCharacterBtn) { } if (marketCreateUserPersonaBtn) { marketCreateUserPersonaBtn.addEventListener("click", () => { - window.location.href = "/settings?view=my-roleplay-companions&create=user"; + window.location.href = "/app/personas?view=my-roleplay-companions&create=user"; }); } marketPreviewClose.addEventListener("click", closeMarketPreview); @@ -633,7 +633,7 @@ marketPreviewModal.addEventListener("click", (event) => { marketPreviewUserPersonaSelect.addEventListener("change", () => { if (!pendingMarketPersona || pendingMarketPersona.persona_type !== "assistant") return; if (marketPreviewUserPersonaSelect.value === "create_new") { - window.location.href = "/settings?view=my-roleplay-companions&create=user"; + window.location.href = "/app/personas?view=my-roleplay-companions&create=user"; return; } marketPreviewConfirm.classList.toggle("hidden", !marketPreviewUserPersonaSelect.value); @@ -679,6 +679,8 @@ window.addEventListener("load", async () => { setMarketSort("user", params.get("userSort") || "best", {reload: false}); setMarketView(params.get("view") || "ai-characters"); await loadMarketPersonas(); + const selectedPersona = [...marketPersonasByType.assistant, ...marketPersonasByType.user].find(persona => persona.id === Number(params.get("persona"))); + if (selectedPersona) openMarketPreview(selectedPersona); if (params.get("create") === "assistant") { setMarketView("ai-characters"); openPersonaForm(); diff --git a/public/privacy.html b/public/privacy.html index 30d0218..7549fcc 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -7,9 +7,13 @@ Privacy Policy – DeepFake + + + - -
+ + +

Privacy Policy

Last updated: 06.01.2026

@@ -85,5 +89,6 @@

We may update this document. Changes will be posted with a “Last updated” date.

+ diff --git a/public/script.js b/public/script.js index 1a8dc7e..ca2bc92 100644 --- a/public/script.js +++ b/public/script.js @@ -6,9 +6,6 @@ import {applyTheme as applySharedTheme, applyThemeMode as applySharedThemeMode, import { activeChatTitle, activeUserPersonaStatus, - authDiv, - authMsg, - authScreens, chatActivityDetail, chatActivityEyebrow, chatActivityOverlay, @@ -39,11 +36,6 @@ import { headerModelName, headerPersonaName, moveChatFolderBtn, - loginForm, - loginPasswordInput, - loginSubmit, - loginUsernameInput, - logoutButton, messagesDiv, modelBadgeName, modelCount, @@ -86,12 +78,6 @@ import { popupInputLabel, popupModal, popupTitle, - registerForm, - registerInviteCodeInput, - registerInviteField, - registerPasswordInput, - registerSubmit, - registerUsernameInput, renameChatBtn, roleplayCharacterSelect, roleplayNewPersonaBtn, @@ -110,10 +96,8 @@ import { roleplayUserPersonaSelect, sendBtn, saveGenerationSettingsBtn, - sessionUser, brandLogoTargets, brandNameTargets, - toggleButtons, userPersonaList } from "./app/dom.js"; @@ -133,8 +117,9 @@ let messageRetryState = new Map(); let availableModelsById = new Map(); -function setAuthMessage(message, state = "") { authMsg.textContent = message; authMsg.className = state ? `status ${state}` : "status"; } function setNotice(message = "", state = "") { + const notice = document.getElementById("workspaceNotice"); + if (notice) { notice.textContent = state === "error" ? message : ""; notice.classList.toggle("hidden", state !== "error"); } return {message, state}; } function setPersonaFormNotice(message = "", state = "") { @@ -192,7 +177,7 @@ function applyWorkspaceMode(nextMode, persist = true) { } function isMobileLayout() { - return window.matchMedia("(max-width: 820px)").matches; + return window.matchMedia("(max-width: 980px)").matches; } function closeMobileSidebar() { @@ -852,9 +837,7 @@ function updateWorkspaceCopy() { if (activeChatTitle) { activeChatTitle.textContent = activeChat ? activeChat.title : "New chat"; } - if (sessionUser) { - sessionUser.textContent = currentUsername || "-"; - } + renderDrawerSummary(); updateComposerPlaceholder(); } @@ -1390,12 +1373,6 @@ async function stopGeneration() { setNotice(res.stopped ? "Stopping generation..." : "Generation already finished."); } -function showAuthScreen(target) { - authScreens.forEach((screen) => screen.classList.toggle("active", screen.id === `${target}Screen`)); - toggleButtons.forEach((btn) => btn.classList.toggle("active", btn.dataset.target === target)); - setAuthMessage(""); -} - async function setActiveChat(id) { if (isMobileLayout()) closeMobileSidebar(); setChatLoading(true, "Opening chat..."); @@ -1405,6 +1382,7 @@ async function setActiveChat(id) { detail: "Fetching messages and restoring the current scene." }); activeChatId = id; + history.replaceState(null, "", `/app/chats/${id}`); messageRetryState = new Map(); updateWorkspaceCopy(); renderChatList(); updateChatActionState(); const res = await get(`/chats/${id}/messages`); @@ -1431,8 +1409,10 @@ async function loadChatSessions() { return setNotice(res.error, "error"); } chatSessions = res.chats || []; renderChatList(); + if (location.pathname.endsWith("/new") && !activeChatId) { currentMessages = []; renderMessages(); updateWorkspaceCopy(); updateSendState(); setChatLoading(false); return; } + if (requestedChatId && !getChatById(requestedChatId) && !activeChatId) { setChatLoading(false); return setNotice("Conversation not found. Choose a chat from the sidebar or start a new one.", "error"); } if (chatSessions.length) { - const preferredChatId = (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : activeChatId && getChatById(activeChatId) ? activeChatId : chatSessions[0].id; + const preferredChatId = activeChatId && getChatById(activeChatId) ? activeChatId : (requestedChatId && getChatById(requestedChatId)) ? requestedChatId : chatSessions[0].id; setChatLoading(false); return setActiveChat(preferredChatId); } @@ -1804,35 +1784,15 @@ async function displayModels() { async function checkSession() { const res = await get("/session"); - if (registerInviteField) registerInviteField.classList.toggle("hidden", !res.inviteOnlyRegistration); - if (!res.user) return false; - currentUsername = res.user; authDiv.classList.add("hidden"); chatDiv.classList.remove("hidden"); + if (!res.user) { window.location.replace(`/login?next=${encodeURIComponent(location.pathname + location.search)}`); return false; } + currentUsername = res.user; chatDiv.classList.remove("hidden"); await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]); - maybeShowOnboarding(); + // Starters are offered on workspace home. + const starter = new URLSearchParams(location.search).get("prompt"); + if (starter) { msgInput.value = starter; resizeComposerInput(); updateSendState(); } msgInput.focus(); return true; } -async function handleAuth(endpoint, credentials) { - const {username, password} = credentials, submitBtn = endpoint === "login" ? loginSubmit : registerSubmit; - if (!username || !password) return setAuthMessage("Please enter both username and password.", "error"); - submitBtn.disabled = true; setAuthMessage("Processing..."); - const payload = endpoint === "register" - ? {username, password, inviteCode: registerInviteCodeInput?.value.trim() || ""} - : {username, password}; - const res = await post(`/${endpoint}`, payload); - submitBtn.disabled = false; - if (res.error) return setAuthMessage(res.error, "error"); - if (endpoint === "login") { - currentUsername = username; authDiv.classList.add("hidden"); chatDiv.classList.remove("hidden"); - await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]); - maybeShowOnboarding(); - setNotice("Ready.", "success"); msgInput.focus(); - } else { - setAuthMessage("Registration successful. You can now log in.", "success"); - showAuthScreen("login"); loginUsernameInput.value = username; loginPasswordInput.focus(); - } -} - async function sendMessage() { if (isProcessing) return; const message = msgInput.value.trim(); @@ -1934,19 +1894,6 @@ function bindChatDrawerActions() { }); } -toggleButtons.forEach((btn) => btn.addEventListener("click", () => showAuthScreen(btn.dataset.target))); -loginForm.addEventListener("submit", (event) => { event.preventDefault(); void handleAuth("login", {username: loginUsernameInput.value.trim(), password: loginPasswordInput.value.trim()}); }); -registerForm.addEventListener("submit", (event) => { event.preventDefault(); void handleAuth("register", {username: registerUsernameInput.value.trim(), password: registerPasswordInput.value.trim()}); }); -logoutButton.addEventListener("click", async () => { - closeChatDrawer(); - await post("/logout", {}); chatDiv.classList.add("hidden"); authDiv.classList.remove("hidden"); - activeChatId = null; currentUsername = ""; currentSummary = null; chatSessions = []; currentMessages = []; assistantPersonas = []; userPersonas = []; - messageRetryState = new Map(); - activeUserPersonaId = null; publishedPersonaIds = new Set(); messagesDiv.innerHTML = ""; renderChatList(); updateChatActionState(); - updateComposerPlaceholder(); - updateSendState(); - showAuthScreen("login"); setAuthMessage("Logged out.", "success"); setNotice("Ready."); -}); sendBtn.addEventListener("click", () => { void (isProcessing ? stopGeneration() : sendMessage()); }); msgInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }); msgInput.addEventListener("input", function () { diff --git a/public/settings.html b/public/settings.html index e268aad..463c877 100644 --- a/public/settings.html +++ b/public/settings.html @@ -7,21 +7,19 @@ - - + + + - -
+ + +

Elsewhere settings

Preferences

-

Loading settings...

@@ -29,7 +27,7 @@
@@ -216,7 +214,7 @@
- +
- - + + diff --git a/public/settings.js b/public/settings.js index 6005eec..83d4835 100644 --- a/public/settings.js +++ b/public/settings.js @@ -136,6 +136,7 @@ function applyThemeMode(modeKey, persist = true) { function setSettingsView(view) { const normalizedView = ["my-ai-characters", "my-personas"].includes(view) ? "my-roleplay-companions" : view; + if (normalizedView === "my-roleplay-companions" && location.pathname !== "/app/personas") { location.assign("/app/personas" + location.search); return; } if (normalizedView === "diagnostics" && !isAdminUser) { setNotice("Diagnostics are available to admin users only.", "error"); return; @@ -309,6 +310,8 @@ async function loadProfile() { setNotice(res.error, "error"); return false; } + const accountName = document.querySelector(".shell-username"); + if (accountName) { accountName.textContent = res.username || ""; accountName.title = res.username || ""; } currentUsernameInput.value = res.username || ""; newUsernameInput.value = res.username || ""; isAdminUser = res.username === "admin"; @@ -575,11 +578,13 @@ window.addEventListener("load", async () => { initializeAppearance({title: "Elsewhere Settings", themeSelect: settingsThemeSelect, modeSelect: themeModeSelect}); workspaceModeSelect.value = localStorage.getItem("krishd-workspace-mode") || "basic"; const params = new URLSearchParams(window.location.search); - const view = params.get("view") || "personal"; + const personaPage = location.pathname === "/app/personas"; + const view = personaPage ? "my-roleplay-companions" : params.get("view") || "personal"; + if (personaPage) { document.querySelector(".market-header .eyebrow").textContent = "Your collection"; document.querySelector("h1").textContent = "My personas"; document.title = "My personas - Elsewhere"; document.querySelector(".settings-nav").hidden = true; document.querySelector(".settings-layout").style.gridTemplateColumns = "1fr"; } const profileOk = await loadProfile(); setSettingsView(view); const personasOk = await loadPersonas(); - if (profileOk && personasOk && view !== "diagnostics") setNotice("Settings loaded."); + if (profileOk && personasOk && view !== "diagnostics") setNotice(personaPage ? "Personas loaded." : "Settings loaded."); const create = params.get("create"); if (create === "assistant") { diff --git a/public/tos.html b/public/tos.html index c017f18..c6decac 100644 --- a/public/tos.html +++ b/public/tos.html @@ -7,9 +7,13 @@ Terms of Service – DeepFake + + + - -
+ + +

Terms of Service

Last updated: 06.01.2026

@@ -25,7 +29,7 @@

Data Processing

-

By using the service, you consent to storing and processing your data as described in the Privacy Policy.

+

By using the service, you consent to storing and processing your data as described in the Privacy Policy.

@@ -43,5 +47,6 @@

We may modify these Terms at any time. Updates will be reflected with a new “Last updated” date.

+ diff --git a/scripts/lint.mjs b/scripts/lint.mjs index 5046922..c8fdd85 100644 --- a/scripts/lint.mjs +++ b/scripts/lint.mjs @@ -3,6 +3,7 @@ import {execFileSync} from "node:child_process"; const files = [ "server.js", "src/app.js", + "src/pageLayout.js", "src/config.js", "src/db/index.js", "src/db/repositories.js", @@ -21,6 +22,10 @@ const files = [ "src/services/modelService.js", "src/services/personaService.js", "public/script.js", + "public/pages.js", + "public/app/workspaceNavigation.js", + "public/settings.js", + "public/market.js", "public/app/api.js", "public/app/chatDrawer.js", "public/app/constants.js", diff --git a/src/app.js b/src/app.js index d159d38..6696c52 100644 --- a/src/app.js +++ b/src/app.js @@ -257,11 +257,6 @@ export function createApp(options = {}) { app.use(observability.middleware); app.use(express.json({limit: "1mb"})); - app.use(express.static(config.publicDir, { - maxAge: config.staticMaxAge, - etag: config.staticEtag, - fallthrough: config.staticFallthrough - })); app.use(session({ store: options.sessionStore || (config.testMode ? undefined : new FileStore({path: config.sessionsDir, reapInterval: 3600})), secret: config.sessionSecret, @@ -284,12 +279,17 @@ export function createApp(options = {}) { app.locals.observability = observability; app.locals.close = createCloseHandler(closeHandlers); + app.use(createPagesRouter(config)); + app.use(express.static(config.publicDir, { + maxAge: config.staticMaxAge, + etag: config.staticEtag, + fallthrough: config.staticFallthrough + })); 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})); app.use(createSystemRouter({modelService, config})); - app.use(createPagesRouter(config)); app.use(notFoundHandler); app.use(errorHandler); diff --git a/src/routes/auth.js b/src/routes/auth.js index 2f398e3..b79e37f 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -27,6 +27,8 @@ export function createAuthRouter({repositories, authRateLimiters, config}) { const hashed = await bcrypt.hash(password, 10); repositories.insertUser(username, hashed); + req.session.user = username; + req.session.sessionVersion = 0; return res.json({message: "Registration successful"}); }); diff --git a/src/routes/pages.js b/src/routes/pages.js index d8cec9d..519a6ad 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -1,11 +1,39 @@ import express from "express"; +import {readFile} from "node:fs/promises"; +import {renderSiteHeader} from "../pageLayout.js"; export function createPagesRouter(config) { const router = express.Router(); - - router.get("/market", (req, res) => res.sendFile(`${config.publicDir}/market.html`)); - router.get("/settings", (req, res) => res.sendFile(`${config.publicDir}/settings.html`)); - router.get("/", (req, res) => res.sendFile(`${config.publicDir}/index.html`)); - + const page = (name) => async (req, res, next) => { + try { + const template = await readFile(`${config.publicDir}/${name}.html`, "utf8"); + const header = renderSiteHeader({user: req.session.user, pathname: req.path}); + res.set("Cache-Control", "private, no-store").type("html").send(template.replace("", () => header)); + } catch (error) { next(error); } + }; + const signedIn = (req, res, next) => req.session.user ? next() : res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`); + router.get(["/", "/index.html"], (req, res, next) => { + if (/^\d+$/.test(String(req.query.chat || ""))) return res.redirect(`/app/chats/${req.query.chat}`); + return page("landing")(req, res, next); + }); + router.get(["/login", "/register"], (req, res, next) => { + if (req.session.user) return res.redirect("/app"); + return page("account")(req, res, next); + }); + router.get("/market", (req, res, next) => page(req.session.user ? "market" : "discover")(req, res, next)); + router.get("/privacy.html", page("privacy")); + router.get("/tos.html", page("tos")); + router.get("/api/discover", (req, res) => { + const personas = req.app.locals.repositories.listMarketPersonas().map(({id, name, background, creator_username, persona_type}) => ({id, name, description: String(background || "").slice(0, 240), creator_username, persona_type})); + res.json({personas}); + }); + router.get(["/home.html", "/account.html", "/discover.html", "/landing.html"], (req, res) => res.redirect(({"/home.html": "/app", "/account.html": "/login", "/discover.html": "/market", "/landing.html": "/"})[req.path])); + router.get("/settings", (req, res) => res.redirect(`/app/settings${req.url.slice(req.path.length)}`)); + router.get("/app", signedIn, page("home")); + router.get(["/app/chats", "/app/chats/:id"], signedIn, page("chat")); + router.get(["/app/settings", "/app/personas"], signedIn, page("settings")); + router.get("/chat.html", signedIn, (req, res) => res.redirect("/app/chats")); + router.get("/settings.html", signedIn, (req, res) => res.redirect("/app/settings")); + router.get("/market.html", (req, res) => res.redirect("/market")); return router; } diff --git a/test/app.test.js b/test/app.test.js index 9f01568..5d7b9eb 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -988,3 +988,79 @@ test("market listing supports persona type filters, sorting, and favorite state" await server.close(); }); + +test("public pages, workspace guards, and legacy chat links have distinct destinations", async () => { + const server = await startTestServer(); + try { + for (const [url, marker] of [["/", "A space for every conversation."], ["/login", 'id="accountForm"'], ["/register", 'id="accountForm"'], ["/market", 'id="publicSearch"']]) { + const response = await fetch(server.baseUrl + url); + assert.equal(response.status, 200); + const html = await response.text(); + assert.ok(html.includes(marker)); + assert.ok(!html.includes('id="chat"')); + } + for (const url of ["/app", "/app/personas", "/app/settings", "/app/chats/42", "/app/chats/new?prompt=hello"]) { + const response = await fetch(server.baseUrl + url, {redirect: "manual"}); + assert.equal(response.status, 302); + assert.equal(response.headers.get("location"), `/login?next=${encodeURIComponent(url)}`); + } + const legacy = await fetch(server.baseUrl + "/?chat=42", {redirect: "manual"}); + assert.equal(legacy.headers.get("location"), "/app/chats/42"); + const jar = new CookieJar(); + await request(server.baseUrl, "/register", {method: "POST", body: {username: "newvisitor", password: "password123"}, jar}); + const session = await request(server.baseUrl, "/session", {jar}); + assert.equal(session.json.user, "newvisitor", "registration signs in immediately"); + const home = await fetch(server.baseUrl + "/app", {headers: jar.headers()}); + assert.match(await home.text(), /What would you like to do/); + const chat = await fetch(server.baseUrl + "/app/chats/new", {headers: jar.headers()}); + const html = await chat.text(); + assert.ok(html.includes('src="/script.js')); + assert.ok(!html.includes('id="loginForm"')); + assert.deepEqual((await request(server.baseUrl, "/chats", {jar})).json.chats, [], "home and chat pages do not create chats on GET"); + await request(server.baseUrl, "/logout", {method: "POST", body: {}, jar}); + assert.equal((await fetch(server.baseUrl + "/app", {headers: jar.headers(), redirect: "manual"})).status, 302); + } finally { await server.close(); } +}); + +test("public discovery only exposes published persona summaries", async () => { + const server = await startTestServer(); + try { + const jar = new CookieJar(); + await request(server.baseUrl, "/register", {method: "POST", body: {username: "publicmaker", password: "password123"}, jar}); + const created = await request(server.baseUrl, "/personas", {method: "POST", body: {name: "Rainkeeper", background: "A guide to a rainy city", details: "Private implementation detail", personaType: "assistant"}, jar}); + const id = created.json.persona.id; + assert.equal((await request(server.baseUrl, "/api/discover")).json.personas.length, 0); + await request(server.baseUrl, `/personas/${id}/publish`, {method: "POST", body: {}, jar}); + const result = await request(server.baseUrl, "/api/discover"); + assert.equal(result.status, 200); + assert.equal(result.json.personas[0].name, "Rainkeeper"); + assert.equal(result.json.personas[0].description, "A guide to a rainy city"); + assert.deepEqual(Object.keys(result.json.personas[0]).sort(), ["creator_username", "description", "id", "name", "persona_type"]); + } finally { await server.close(); } +}); + +test("page headers share navigation and keep session markup private", async () => { + const server = await startTestServer(); + try { + const jar = new CookieJar(); + const username = ''; + await request(server.baseUrl, "/register", {method: "POST", body: {username, password: "password123"}, jar}); + let sharedNavigation; + for (const route of ["/", "/app", "/app/chats/new", "/app/personas", "/market", "/app/settings", "/privacy.html", "/tos.html"]) { + const response = await fetch(server.baseUrl + route, {headers: jar.headers()}); + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "private, no-store"); + const html = await response.text(); + assert.equal((html.match(/class="shell-header"/g) || []).length, 1); + assert.ok(html.includes('<maker&friend>')); + assert.ok(!html.includes(username), "account names must be escaped in header markup"); + const navigation = html.match(/