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
↓ kepr-a602ee4a.tar.gzatlas at this save
files changed7 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 −0package.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": {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+}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. */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);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 {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 }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+});