a602ee4ac891

change the admin password, sign out other sessions and set the domain from a new Account section, applied without a restart (v3.0.156)

time
2026-09-30 16:11 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator console
parent
9288dbfe8a4f
7 files package.json+1 −1src/admin/account.ts+86 −0src/admin/console.ts+1 −0src/server/app.ts+19 −4src/server/config.ts+5 −0src/server/login.ts+22 −2tests/account.test.ts+105 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.155",3+	"version": "3.0.156",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/account.ts+86 −0
@@ -0,0 +1,86 @@1+import type { Context, Hono } from "hono";2+import { html } from "../pages/html.ts";3+import { plural } from "../pages/layout.ts";4+import { isDomain, saveConfig } from "../server/config.ts";5+import { endOtherSessions, liveSessions } from "../server/login.ts";6+import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";7+8+const MIN_PASSWORD = 12;9+10+/**11+ * The Account section (June, 2026-09-30): the admin password, the sessions signed in, and the12+ * domain, each written to kepr.toml by Kepr and applied without a restart.13+ */14+export function registerAccount(app: Hono, ctx: AdminContext): void {15+	app.get("/admin/account", (c) => c.html(accountPage(c, ctx, "")));16+17+	app.post("/admin/account", async (c) => {18+		const fields = await textFields(c);19+		const refuse = (error: string) => c.html(accountPage(c, ctx, error), 400);20+		const save = async (change: Partial<typeof ctx.config>) => {21+			await saveConfig(ctx.configPath, { ...ctx.config, ...change });22+			Object.assign(ctx.config, change);23+		};24+		if (fields.action === "password") {25+			const password = fields.password ?? "";26+			const current = fields.current_password ?? "";27+			if (!(await Bun.password.verify(current, ctx.config.adminPasswordHash))) {28+				return refuse("The current password is not right.");29+			}30+			if (password.length < MIN_PASSWORD) {31+				return refuse(`The new password needs at least ${MIN_PASSWORD} characters.`);32+			}33+			if (password !== fields.confirm) return refuse("The two new passwords differ.");34+			await save({ adminPasswordHash: await Bun.password.hash(password, "argon2id") });35+			// Whoever knew the old password is signed out with it.36+			endOtherSessions(ctx, c);37+		} else if (fields.action === "sessions") {38+			endOtherSessions(ctx, c);39+		} else if (fields.action === "domain") {40+			const domain = (fields.domain ?? "").trim().toLowerCase();41+			if (!isDomain(domain)) {42+				return refuse(43+					"The domain is a bare hostname, such as code.example.com, with an optional :port.",44+				);45+			}46+			await save({ domain });47+		} else {48+			return refuse("Unknown action.");49+		}50+		return c.redirect("/admin/account?saved=1", 303);51+	});52+}53+54+function accountPage(c: Context, ctx: AdminContext, error: string): string {55+	const password = (name: string, label: string, autocomplete: string) =>56+		html`<label class="issues-label" for="account-${name}">${label}</label>57+<input class="issues-input" id="account-${name}" name="${name}" type="password"58+autocomplete="${autocomplete}" required>`;59+	const body = html`${notice(error)}60+<section class="repo-section"><h2 class="section-label mono">password</h2>61+<form class="issues-form" method="post" action="/admin/account">62+${formField(c)}<input type="hidden" name="action" value="password">63+${password("current_password", "current password", "current-password")}64+${password("password", `new password (at least ${MIN_PASSWORD} characters)`, "new-password")}65+${password("confirm", "new password again", "new-password")}66+<p class="issue-meta mono">Changing it signs out every other session.</p>67+<button class="issues-submit" type="submit">change password</button>68+</form></section>69+<section class="repo-section"><h2 class="section-label mono">sessions</h2>70+<p>${plural(liveSessions(ctx), "session")} signed in, this one included. A session lasts 30 days.</p>71+<form method="post" action="/admin/account">72+${formField(c)}<input type="hidden" name="action" value="sessions">73+<button class="issues-submit" type="submit">sign out everywhere else</button>74+</form></section>75+<section class="repo-section"><h2 class="section-label mono">domain</h2>76+<form class="issues-form" method="post" action="/admin/account">77+${formField(c)}<input type="hidden" name="action" value="domain">78+<label class="issues-label" for="account-domain">domain</label>79+<input class="issues-input mono" id="account-domain" name="domain" value="${ctx.domain}"80+autocomplete="off" spellcheck="false" required>81+<p class="issue-meta mono">Where Kepr is reached: pages, feeds and install.sh use it, and the console82+accepts forms only from it. Point the new name at this server first, then save and sign in there.</p>83+<button class="issues-submit" type="submit">save domain</button>84+</form></section>`;85+	return adminPage(c, ctx, "account", body);86+}

view file

src/admin/console.ts+1 −0
@@ -29,6 +29,7 @@2929 	["/admin?section=backups", "backups"],3030 	["/admin/site?section=profile", "profile"],3131 	["/admin/site", "site"],32+	["/admin/account", "account"],3233 ] as const;3334 3435 /** The hidden same-site token (§25) that every admin form posts back. */

view file

src/server/app.ts+19 −4
@@ -3,6 +3,7 @@33 import { Hono } from "hono";44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";6+import { registerAccount } from "../admin/account.ts";67 import { registerBackups } from "../admin/backups.ts";78 import { registerBucket } from "../admin/bucket.ts";89 import { registerStatus } from "../admin/console.ts";@@ -110,16 +111,29 @@110111 		c.header("Cache-Control", "public, max-age=3600");111112 		return c.body(await file.bytes(), 200, { "Content-Type": file.type });112113 	});113-	const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };114+	const mirrorDir = join(config.dataDir, "mirror");115+	// A getter, so a domain saved in the console reaches every page and check at once.116+	const ctx = {117+		get domain() {118+			return config.domain;119+		},120+		kepr,121+		mirrorDir,122+	};114123 	// Before every other route, so the admin guard runs ahead of each `/admin` handler.115-	registerLogin(app, ctx, config.adminPasswordHash);124+	registerLogin(app, ctx, config);116125 	registerConnect(app, config, ctx);126+	// Not a spread of `ctx`, which would copy the domain once instead of keeping the getter.117127 	const admin = {118-		...ctx,128+		get domain() {129+			return config.domain;130+		},131+		kepr,132+		mirrorDir,119133 		config,120134 		configPath,121135 		dataDir: config.dataDir,122-		sync: { bucket, kepr, mirrorDir: ctx.mirrorDir },136+		sync: { bucket, kepr, mirrorDir },123137 	};124138 	registerStatus(app, admin);125139 	registerRepos(app, admin);@@ -129,6 +143,7 @@129143 	registerNotifications(app, admin);130144 	registerEmail(app, admin);131145 	registerBackups(app, admin);146+	registerAccount(app, admin);132147 	registerRepoText(app, admin);133148 	registerHousekeeping(app, ctx);134149 	registerFrontPages(app, ctx);

view file

src/server/config.ts+5 −0
@@ -56,6 +56,11 @@5656 	"i",5757 );5858 59+/** A bare hostname with an optional `:port`: pages, feeds and install.sh put it in URLs as is. */60+export function isDomain(domain: string): boolean {61+	return HOSTNAME.test(domain);62+}63+5964 export function parseConfig(text: string): Config {6065 	let raw: unknown;6166 	try {

view file

src/server/login.ts+22 −2
@@ -45,6 +45,21 @@4545 	return sessionToken(ctx, c) !== null;4646 }4747 48+/** How many sessions are live, this one included. */49+export function liveSessions(ctx: PageContext): number {50+	return (51+		ctx.kepr52+			.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM sessions WHERE expires_at > ?")53+			.get(nowSeconds())?.n ?? 054+	);55+}56+57+/** Revokes every session but the one making this request. */58+export function endOtherSessions(ctx: PageContext, c: Context): void {59+	const token = sessionToken(ctx, c) ?? "";60+	ctx.kepr.query("DELETE FROM sessions WHERE token_hash != ?").run(sha256(token));61+}62+4863 /**4964  * Whether a request came from this site. A localhost domain also accepts plain http, since a5065  * local run has no TLS in front of it; browsers keep Secure cookies on http://localhost.@@ -90,7 +105,12 @@90105  * §24's login at `/sign-in`, sessions, sign-out, and the guard in front of every91106  * `/admin` route: a live session, and for anything but GET a matching `Origin` and form token.92107  */93-export function registerLogin(app: Hono, ctx: PageContext, passwordHash: string): void {108+export function registerLogin(109+	app: Hono,110+	ctx: PageContext,111+	// The live config, so a password changed in the console works at once.112+	config: { adminPasswordHash: string },113+): void {94114 	const refuse = (c: Context, status: 401 | 403 | 429, text: string) =>95115 		adminRefusal(c, ctx, status, text);96116 @@ -124,7 +144,7 @@124144 			return signInPage(c, ctx, "Too many attempts. Try again later.", 429, next);125145 		}126146 		const password = typeof fields.password === "string" ? fields.password : "";127-		if (!(await Bun.password.verify(password, passwordHash))) {147+		if (!(await Bun.password.verify(password, config.adminPasswordHash))) {128148 			failures.push({ at: now, address });129149 			return signInPage(c, ctx, "That password is not right.", 401, next);130150 		}

view file

tests/account.test.ts+105 −0
@@ -0,0 +1,105 @@1+// Covers the console's Account section (June, 2026-09-30): the admin password, other sessions and2+// the domain, all changed without touching kepr.toml by hand, and applied without a restart.3+4+import { beforeAll, describe, expect, test } from "bun:test";5+import { join } from "node:path";6+import { createApp } from "../src/server/app.ts";7+import { loadConfig, saveConfig } from "../src/server/config.ts";8+import { Browser, type Instance, instance, PASSWORD } from "./browser.ts";9+import { makeRepos } from "./fixture.ts";10+11+const NEW_PASSWORD = "a much longer passphrase now";12+13+describe("the Account section", () => {14+	let inst: Instance;15+	let operator: Browser;16+	let configPath: string;17+	const change = (fields: Record<string, string>) =>18+		operator.post("/admin/account", { action: "password", ...fields });19+20+	beforeAll(async () => {21+		const repos = await makeRepos(["app"]);22+		inst = await instance(repos.bucketDir);23+		configPath = join(inst.dataDir, "kepr.toml");24+		await saveConfig(configPath, inst.config);25+		inst.app = createApp(inst.config, inst.kepr, undefined, configPath);26+		operator = new Browser(inst.app);27+		await operator.signIn();28+	});29+30+	test("lists the sections's forms and never the hash", async () => {31+		const page = await (await operator.get("/admin/account")).text();32+		expect(page).toContain('name="current_password"');33+		expect(page).toContain('value="kepr.example"');34+		expect(page).not.toContain("$argon2id$");35+	});36+37+	test("refuses a wrong current password, a short one, or a mismatch", async () => {38+		const cases: [Record<string, string>, string][] = [39+			[40+				{ current_password: "wrong", password: NEW_PASSWORD, confirm: NEW_PASSWORD },41+				"The current password is not right.",42+			],43+			[44+				{ current_password: PASSWORD, password: "short", confirm: "short" },45+				"at least 12 characters",46+			],47+			[48+				{ current_password: PASSWORD, password: NEW_PASSWORD, confirm: "different" },49+				"The two new passwords differ.",50+			],51+		];52+		for (const [fields, error] of cases) {53+			const response = await change(fields);54+			expect(response.status).toBe(400);55+			expect(await response.text()).toContain(error);56+		}57+		expect(58+			await Bun.password.verify(PASSWORD, (await loadConfig(configPath)).adminPasswordHash),59+		).toBe(true);60+	});61+62+	test("a new password is saved, works at once, and signs every other session out", async () => {63+		const other = new Browser(inst.app, "198.51.100.9");64+		await other.signIn();65+		const response = await change({66+			current_password: PASSWORD,67+			password: NEW_PASSWORD,68+			confirm: NEW_PASSWORD,69+		});70+		expect(response.status).toBe(303);71+		const saved = await loadConfig(configPath);72+		expect(await Bun.password.verify(NEW_PASSWORD, saved.adminPasswordHash)).toBe(true);73+		expect((await other.get("/admin")).status).toBe(401);74+		expect((await operator.get("/admin")).status).toBe(200);75+		expect((await new Browser(inst.app, "198.51.100.10").signIn(PASSWORD)).status).toBe(401);76+		expect((await new Browser(inst.app, "198.51.100.11").signIn(NEW_PASSWORD)).status).toBe(303);77+	});78+79+	test("signing out everywhere else keeps only this session", async () => {80+		const other = new Browser(inst.app, "198.51.100.12");81+		await other.signIn(NEW_PASSWORD);82+		expect(await (await operator.get("/admin/account")).text()).toMatch(83+			/[2-9] sessions? signed in/,84+		);85+		expect((await operator.post("/admin/account", { action: "sessions" })).status).toBe(303);86+		expect((await other.get("/admin")).status).toBe(401);87+		expect((await operator.get("/admin")).status).toBe(200);88+	});89+90+	test("the domain is checked, saved, and used at once", async () => {91+		const bad = await operator.post("/admin/account", { action: "domain", domain: "not a domain" });92+		expect(bad.status).toBe(400);93+		const moved = await operator.post("/admin/account", {94+			action: "domain",95+			domain: "code.example",96+		});97+		expect(moved.status).toBe(303);98+		expect((await loadConfig(configPath)).domain).toBe("code.example");99+		const origin = "https://code.example";100+		expect((await operator.post("/admin/sync", {}, origin)).status).toBe(303);101+		expect((await operator.post("/admin/sync", {})).status).toBe(403);102+		const page = await (await operator.get("/admin/account")).text();103+		expect(page).toContain('<link rel="canonical" href="https://code.example/admin/account">');104+	});105+});

view file