53796355d42e

set Kepr up in the browser on first run with a one-time code from the terminal, then hand over without a restart (v3.0.158)

time
2026-09-30 16:17 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator console
parent
3be930495fbc
9 files package.json+1 −1src/admin/account.ts+1 −1src/admin/bucket.ts+6 −3src/admin/setup.ts+150 −0src/main.ts+32 −9src/server/app.ts+21 −18src/server/connect.ts+15 −8tests/front.test.ts+1 −1tests/setup.test.ts+107 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.157",3+	"version": "3.0.158",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/account.ts+1 −1
@@ -5,7 +5,7 @@55 import { endOtherSessions, liveSessions } from "../server/login.ts";66 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";77 8-const MIN_PASSWORD = 12;8+export const MIN_PASSWORD = 12;99 1010 /**1111  * The Account section (June, 2026-09-30): the admin password, the sessions signed in, and the

view file

src/admin/bucket.ts+6 −3
@@ -47,7 +47,10 @@4747 }4848 4949 /** The S3 settings a form describes, a blank secret keeping the stored one; or what is wrong. */50-function candidate(fields: Record<string, string>, current: S3Source | null): S3Source | string {50+export function s3Candidate(51+	fields: Record<string, string>,52+	current: S3Source | null,53+): S3Source | string {5154 	const value = (key: string) => (fields[key] ?? "").trim();5255 	const problem = s3FieldsProblem(fields, SHOWN, REQUIRED);5356 	if (problem) return problem;@@ -72,7 +75,7 @@7275 }7376 7477 /** A folder remote a form names, which Kepr must be able to list; or what is wrong. */75-async function folderCandidate(folder: string): Promise<BucketSource | string> {78+export async function folderCandidate(folder: string): Promise<BucketSource | string> {7679 	const path = folder.trim();7780 	if (!isAbsolute(path)) return "The folder is a full path, starting with /.";7881 	try {@@ -97,7 +100,7 @@97100 		const next =98101 			fields.action === "folder"99102 				? await folderCandidate(fields.folder ?? "")100-				: candidate(fields, current.kind === "s3" ? current : null);103+				: s3Candidate(fields, current.kind === "s3" ? current : null);101104 		if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);102105 		const failure = next.kind === "s3" ? await checkS3Access(next) : null;103106 		if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);

view file

src/admin/setup.ts+150 −0
@@ -0,0 +1,150 @@1+import { timingSafeEqual } from "node:crypto";2+import { join } from "node:path";3+import { type Context, Hono } from "hono";4+import { bodyLimit } from "hono/body-limit";5+import { checkS3Access } from "../bucket/reader.ts";6+import { isSafePath } from "../bucket/repo.ts";7+import { html } from "../pages/html.ts";8+import { page } from "../pages/layout.ts";9+import { PUBLIC_DIR, securityHeaders } from "../server/app.ts";10+import { type Config, isDomain, saveConfig } from "../server/config.ts";11+import { typedCode } from "../server/connect.ts";12+import { openKeprDb } from "../server/db.ts";13+import { MIN_PASSWORD } from "./account.ts";14+import { folderCandidate, s3Candidate } from "./bucket.ts";15+16+const MAX_WRONG_CODES = 10;17+18+type SetupOptions = {19+	/** The one-time code printed in the terminal, as `oneTimeCode` makes it. */20+	code: string;21+	configPath: string;22+	dataDir: string;23+	/** Called once with the saved config, so the caller can start the real app. */24+	done: (config: Config) => void;25+};26+27+/**28+ * First-run setup (June, 2026-09-30): with no kepr.toml, Kepr serves only this. Whoever holds the29+ * code from the terminal sets the admin password, the domain and the bucket; Kepr checks them, writes30+ * kepr.toml itself, and hands over. Ten wrong codes lock it until a restart makes a new code.31+ */32+export function createSetupApp(options: SetupOptions): Hono {33+	const kepr = openKeprDb(options.dataDir);34+	const app = new Hono();35+	let wrong = 0;36+	let finished = false;37+	app.use(securityHeaders());38+	app.use(bodyLimit({ maxSize: 64 * 1024 }));39+	app.get("/static/:path{.+}", async (c) => {40+		const path = c.req.param("path");41+		const file = Bun.file(join(PUBLIC_DIR, path));42+		if (!isSafePath(path) || path.startsWith("assets/") || !(await file.exists())) {43+			return c.notFound();44+		}45+		return c.body(await file.bytes(), 200, { "Content-Type": file.type });46+	});47+	app.get("/setup", (c) => c.html(setupPage(c, kepr, "")));48+	app.post("/setup", async (c) => {49+		if (finished) return c.notFound();50+		const refuse = (status: 400 | 403 | 429, error: string) =>51+			c.html(setupPage(c, kepr, error, fields), status);52+		const fields: Record<string, string> = {};53+		// No session yet to tie a form token to: the code is the credential, and the origin must54+		// be this host, so another site can't post a guess on the operator's behalf.55+		const host = c.req.header("host") ?? "";56+		const origin = c.req.header("origin") ?? "";57+		if (origin !== `http://${host}` && origin !== `https://${host}`) {58+			return refuse(403, "Refused: wrong origin.");59+		}60+		for (const [name, value] of Object.entries(await c.req.parseBody())) {61+			if (typeof value === "string") fields[name] = value;62+		}63+		if (wrong >= MAX_WRONG_CODES)64+			return refuse(429, "Too many wrong codes. Restart Kepr for a new code.");65+		const code = typedCode(fields.code ?? "") ?? "";66+		const right = Buffer.from(options.code);67+		if (code.length !== right.length || !timingSafeEqual(Buffer.from(code), right)) {68+			wrong++;69+			return refuse(403, "That setup code is not right.");70+		}71+		const password = fields.password ?? "";72+		if (password.length < MIN_PASSWORD) {73+			return refuse(400, `The password needs at least ${MIN_PASSWORD} characters.`);74+		}75+		if (password !== fields.confirm) return refuse(400, "The two passwords differ.");76+		const domain = (fields.domain ?? "").trim().toLowerCase();77+		if (!isDomain(domain)) {78+			return refuse(79+				400,80+				"The domain is a bare hostname, such as code.example.com, with an optional :port.",81+			);82+		}83+		const bucket =84+			fields.source === "s3"85+				? s3Candidate(fields, null)86+				: await folderCandidate(fields.folder ?? "");87+		if (typeof bucket === "string") return refuse(400, bucket);88+		const unreachable = bucket.kind === "s3" ? await checkS3Access(bucket) : null;89+		if (unreachable) return refuse(400, unreachable);90+		const config: Config = {91+			domain,92+			dataDir: options.dataDir,93+			adminPasswordHash: await Bun.password.hash(password, "argon2id"),94+			bucket,95+		};96+		await saveConfig(options.configPath, config);97+		finished = true;98+		kepr.close();99+		options.done(config);100+		return c.redirect("/sign-in", 303);101+	});102+	app.all("*", (c) => (finished ? c.notFound() : c.redirect("/setup", 302)));103+	return app;104+}105+106+function setupPage(107+	c: Context,108+	kepr: ReturnType<typeof openKeprDb>,109+	error: string,110+	fields: Record<string, string> = {},111+): string {112+	const host = c.req.header("host") ?? "localhost";113+	const value = (name: string, fallback = "") => fields[name] ?? fallback;114+	const input = (name: string, label: string, fallback = "", type = "text") =>115+		html`<label class="issues-label" for="setup-${name}">${label}</label>116+<input class="issues-input mono" id="setup-${name}" name="${name}" type="${type}"117+value="${type === "password" ? "" : value(name, fallback)}" autocomplete="off" spellcheck="false">`;118+	const s3 = fields.source === "s3";119+	const body = html`<div class="admin-content">120+<header class="admin-heading"><span class="section-label mono">first run</span>121+<h1>Set up Kepr</h1></header>122+${error && html`<p class="issues-error mono" role="alert">${error}</p>`}123+<p class="admin-intro">Kepr printed a setup code in the terminal it was started from. Enter it here124+with the rest; Kepr checks everything, saves it, and takes you to sign in. Every setting can be125+changed later in the console.</p>126+<form class="issues-form" method="post" action="/setup">127+${input("code", "setup code")}128+${input("domain", "domain", host)}129+${input("password", `admin password (at least ${MIN_PASSWORD} characters)`, "", "password")}130+${input("confirm", "admin password again", "", "password")}131+<fieldset class="issues-form"><legend class="issues-label">where your repos are</legend>132+<label class="issues-label"><input type="radio" name="source" value="s3"${s3 ? html` checked` : ""}>133+an S3 bucket Koh offers to</label>134+${input("endpoint", "endpoint", "https://s3.fr-par.scw.cloud")}135+${input("name", "bucket name")}136+${input("region", "region")}137+${input("access_key_id", "read access key ID")}138+${input("secret_access_key", "read secret key", "", "password")}139+<p class="muted">A write key, a backup bucket and email can be added in the console afterwards.</p>140+<label class="issues-label"><input type="radio" name="source" value="folder"${s3 ? "" : html` checked`}>141+a Koh folder remote on this server</label>142+${input("folder", "folder", "", "text")}143+</fieldset>144+<button class="issues-submit" type="submit">save and continue</button>145+</form></div>`;146+	return page(147+		{ domain: host, kepr, mirrorDir: "" },148+		{ title: "setup", description: "", path: "/setup", body },149+	);150+}

view file

src/main.ts+32 −9
@@ -1,26 +1,49 @@11 import { join } from "node:path";2+import type { Hono } from "hono";3+import { createSetupApp } from "./admin/setup.ts";24 import { openBucket } from "./bucket/reader.ts";35 import { startSyncLoop } from "./bucket/sync.ts";46 import { createApp } from "./server/app.ts";57 import { startBackupLoop } from "./server/backup.ts";6-import { CONFIG_FILE, ConfigError, loadConfig } from "./server/config.ts";8+import { CONFIG_FILE, type Config, ConfigError, loadConfig } from "./server/config.ts";9+import { oneTimeCode } from "./server/connect.ts";710 import { openKeprDb } from "./server/db.ts";811 import { startMailLoop } from "./server/mail.ts";912 10-try {11-	const config = await loadConfig(CONFIG_FILE);13+// Where a first-run setup puts Kepr's data, beside kepr.toml.14+const DATA_DIR = "data";15+16+/** Starts the loops for a config and returns the app that serves it. */17+function start(config: Config): Hono["fetch"] {1218 	const kepr = openKeprDb(config.dataDir);1319 	// One bucket for the sync loop and the app, so settings saved in the console reach both.1420 	const bucket = openBucket(config.bucket);1521 	startBackupLoop(kepr, config);1622 	startMailLoop(kepr, config);17-	startSyncLoop({18-		bucket,19-		kepr,20-		mirrorDir: join(config.dataDir, "mirror"),21-	});23+	startSyncLoop({ bucket, kepr, mirrorDir: join(config.dataDir, "mirror") });24+	return createApp(config, kepr, bucket).fetch;25+}26+27+try {28+	let fetch: Hono["fetch"];29+	if (await Bun.file(CONFIG_FILE).exists()) {30+		fetch = start(await loadConfig(CONFIG_FILE));31+	} else {32+		const code = oneTimeCode();33+		fetch = createSetupApp({34+			code,35+			configPath: CONFIG_FILE,36+			dataDir: DATA_DIR,37+			done: (config) => {38+				fetch = start(config);39+			},40+		}).fetch;41+		console.log(42+			`kepr: no ${CONFIG_FILE} yet. Open /setup in a browser and enter the code ${code.slice(0, 4)}-${code.slice(4)}`,43+		);44+	}2245 	// Bun.serve reads the port from $PORT (default 3000).23-	const server = Bun.serve({ fetch: createApp(config, kepr, bucket).fetch });46+	const server = Bun.serve({ fetch: (request, bun) => fetch(request, bun) });2447 	console.log(`kepr listening on ${server.url}`);2548 } catch (error) {2649 	if (!(error instanceof ConfigError)) throw error;

view file

src/server/app.ts+21 −18
@@ -37,10 +37,29 @@3737 // §25's cap, except for the operator's picture and video uploads, which only a session reaches.3838 const MAX_MEDIA_BYTES = 64 * 1024 * 1024;3939 const MEDIA_UPLOAD = /^\/admin\/repos\/[^/]+\/media$/;40-const PUBLIC_DIR = join(import.meta.dir, "../../public");40+export const PUBLIC_DIR = join(import.meta.dir, "../../public");4141 // §18 allows Bunny Fonts: its stylesheet and the font files it points at.4242 const FONTS = "https://fonts.bunny.net";4343 44+/** §25's headers: the CSP, with no inline scripts, and no framing. */45+export function securityHeaders(imgSrc: (string | (() => string))[] = ["'self'"]) {46+	return secureHeaders({47+		contentSecurityPolicy: {48+			defaultSrc: ["'self'"],49+			scriptSrc: ["'self'"],50+			styleSrc: ["'self'", FONTS],51+			fontSrc: [FONTS],52+			imgSrc,53+			mediaSrc: imgSrc,54+			objectSrc: ["'none'"],55+			baseUri: ["'none'"],56+			formAction: ["'self'"],57+			frameAncestors: ["'none'"],58+		},59+		xFrameOptions: "DENY",60+	});61+}62+4463 /**4564  * The Hono app with §25's headers and body cap, and every route built so far. The console's Bucket4665  * section changes `config.bucket` and `bucket` in place, so everything here reads them per request.@@ -68,23 +87,7 @@6887 			c.header("Content-Security-Policy", policy ? `${policy}; sandbox` : "sandbox");6988 		}7089 	});71-	app.use(72-		secureHeaders({73-			contentSecurityPolicy: {74-				defaultSrc: ["'self'"],75-				scriptSrc: ["'self'"],76-				styleSrc: ["'self'", FONTS],77-				fontSrc: [FONTS],78-				imgSrc,79-				mediaSrc: imgSrc,80-				objectSrc: ["'none'"],81-				baseUri: ["'none'"],82-				formAction: ["'self'"],83-				frameAncestors: ["'none'"],84-			},85-			xFrameOptions: "DENY",86-		}),87-	);90+	app.use(securityHeaders(imgSrc));8891 	const limit = (maxSize: number) =>8992 		bodyLimit({9093 			maxSize,

view file

src/server/connect.ts+15 −8
@@ -11,6 +11,18 @@1111 const CODE_SECONDS = 600;1212 /** How many redeemed codes the status page lists, and so how many kepr.db keeps. */1313 export const MACHINES_LISTED = 20;14+15+/** Eight characters from an alphabet with no look-alikes, shown as `ABCD-EFGH`. */16+export function oneTimeCode(): string {17+	// 32 characters into a byte is even, so every character is equally likely.18+	return Array.from({ length: 8 }, () => ALPHABET[randomBytes(1)[0] % ALPHABET.length]).join("");19+}20+21+/** A code as typed, dash and case aside, or null when it can't be one. */22+export function typedCode(typed: string): string | null {23+	const code = typed.trim().replaceAll("-", "").toUpperCase();24+	return code.length === 8 && [...code].every((char) => ALPHABET.includes(char)) ? code : null;25+}1426 const ATTEMPTS_PER_ADDRESS = 10;1527 const ATTEMPT_WINDOW_MS = 3600000;1628 // One body for every failure — unknown, expired, used, malformed and rate-limited alike — so a@@ -83,11 +95,7 @@8395 			return c.text("a machine name of 64 printable characters or fewer is required", 400);8496 		const now = Math.floor(Date.now() / 1000);8597 		ctx.kepr.query("DELETE FROM connect_codes WHERE used_at IS NULL AND expires_at <= ?").run(now);86-		// 32 characters into a byte is even, so every character is equally likely.87-		const code = Array.from(88-			{ length: 8 },89-			() => ALPHABET[randomBytes(1)[0] % ALPHABET.length],90-		).join("");98+		const code = oneTimeCode();9199 		ctx.kepr92100 			.query(93101 				"INSERT INTO connect_codes (code_hash, machine, created_at, expires_at) VALUES (?, ?, ?, ?)",@@ -119,9 +127,8 @@119127 		} catch {120128 			return noCode(c);121129 		}122-		const submitted = code.trim().replaceAll("-", "").toUpperCase();123-		if (submitted.length !== 8 || [...submitted].some((char) => !ALPHABET.includes(char)))124-			return noCode(c);130+		const submitted = typedCode(code);131+		if (!submitted) return noCode(c);125132 		const at = Math.floor(now / 1000);126133 		// Marked used in the same statement that proves it live, so one code is never redeemed twice.127134 		const redeemed = ctx.kepr

view file

tests/front.test.ts+1 −1
@@ -233,7 +233,7 @@233233 	test("render the repo's Markdown, links left as written", async () => {234234 		const docs = await body("/docs");235235 		expect(docs).toContain("<h1>Run your own library</h1>");236-		expect(docs).toContain("<code>admin_password_hash</code>");236+		expect(docs).toContain("<code>/setup</code>");237237 		const support = await body("/support");238238 		expect(support).toContain('<a href="/docs">self-hosting docs</a>');239239 		expect(support).toContain('<meta name="description" content="Found a bug');

view file

tests/setup.test.ts+107 −0
@@ -0,0 +1,107 @@1+// Covers first-run setup (June, 2026-09-30): with no kepr.toml, Kepr serves only /setup, which takes2+// the one-time code printed in the terminal, then the password, domain and bucket, writes kepr.toml3+// itself, and hands over to the real app.4+5+import { beforeAll, describe, expect, test } from "bun:test";6+import { mkdtemp } from "node:fs/promises";7+import { tmpdir } from "node:os";8+import { join } from "node:path";9+import { createSetupApp } from "../src/admin/setup.ts";10+import { type Config, loadConfig } from "../src/server/config.ts";11+import { makeRepos } from "./fixture.ts";12+13+const CODE = "ABCD2345";14+const PASSWORD = "a long enough first password";15+const ORIGIN = "http://localhost";16+17+async function setup() {18+	const dir = await mkdtemp(join(tmpdir(), "kepr-setup-"));19+	const configPath = join(dir, "kepr.toml");20+	const done: Config[] = [];21+	const app = createSetupApp({22+		code: CODE,23+		configPath,24+		dataDir: join(dir, "data"),25+		done: (config) => done.push(config),26+	});27+	const post = (fields: Record<string, string>, origin = ORIGIN) =>28+		app.request("/setup", {29+			method: "POST",30+			headers: { origin, host: "localhost" },31+			body: new URLSearchParams(fields),32+		});33+	return { app, configPath, done, post };34+}35+36+describe("first-run setup", () => {37+	let bucketDir: string;38+	const form = (fields: Record<string, string> = {}) => ({39+		code: "abcd-2345",40+		domain: "localhost:3000",41+		password: PASSWORD,42+		confirm: PASSWORD,43+		source: "folder",44+		folder: bucketDir,45+		...fields,46+	});47+48+	beforeAll(async () => {49+		bucketDir = (await makeRepos(["app"])).bucketDir;50+	});51+52+	test("every other path leads to /setup, which never shows the code", async () => {53+		const { app } = await setup();54+		const home = await app.request("/");55+		expect(home.status).toBe(302);56+		expect(home.headers.get("location")).toBe("/setup");57+		const page = await app.request("/setup", { headers: { host: "localhost:3000" } });58+		expect(page.status).toBe(200);59+		const text = await page.text();60+		expect(text).toContain('name="code"');61+		expect(text).toContain('value="localhost:3000"');62+		expect(text).not.toContain(CODE);63+		expect(page.headers.get("content-security-policy")).toContain("script-src 'self'");64+	});65+66+	test("refuses a wrong code, a foreign origin, and bad settings, writing nothing", async () => {67+		const { configPath, done, post } = await setup();68+		const cases: [Record<string, string>, string][] = [69+			[{ code: "ZZZZ-ZZZZ" }, "That setup code is not right."],70+			[{ password: "short", confirm: "short" }, "at least 12 characters"],71+			[{ confirm: "something else" }, "The two passwords differ."],72+			[{ domain: "https://nope" }, "The domain is a bare hostname"],73+			[{ folder: "/no/such/folder" }, "is not a folder Kepr can read"],74+			[{ source: "s3", folder: "" }, "The endpoint is required."],75+		];76+		for (const [fields, error] of cases) {77+			const response = await post(form(fields));78+			expect(response.status).toBeGreaterThanOrEqual(400);79+			expect(await response.text()).toContain(error);80+		}81+		expect((await post(form(), "https://evil.example")).status).toBe(403);82+		expect(await Bun.file(configPath).exists()).toBe(false);83+		expect(done).toEqual([]);84+	});85+86+	test("ten wrong codes lock setup until Kepr restarts", async () => {87+		const { post } = await setup();88+		for (let i = 0; i < 10; i++) await post(form({ code: "ZZZZZZZZ" }));89+		const locked = await post(form());90+		expect(locked.status).toBe(429);91+		expect(await locked.text()).toContain("Restart Kepr for a new code.");92+	});93+94+	test("the right code writes kepr.toml, hands over, and closes setup", async () => {95+		const { configPath, done, post } = await setup();96+		const response = await post(form());97+		expect(response.status).toBe(303);98+		expect(response.headers.get("location")).toBe("/sign-in");99+		const config = await loadConfig(configPath);100+		expect(config.domain).toBe("localhost:3000");101+		expect(config.bucket).toEqual({ kind: "folder", path: bucketDir });102+		expect(await Bun.password.verify(PASSWORD, config.adminPasswordHash)).toBe(true);103+		expect(done).toHaveLength(1);104+		expect(done[0]?.domain).toBe("localhost:3000");105+		expect((await post(form())).status).toBe(404);106+	});107+});

view file