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
↓ kepr-53796355.tar.gzatlas at this save
files changed9 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 −0package.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": {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 thesrc/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);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+}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;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,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.keprtests/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');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+});