63e55f152976

edit, test and live-apply S3 bucket settings from the admin console (v3.0.131)

time
2026-09-29 22:17 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
editable bucket settings
parent
55425ebd763c
10 files kepr.example.toml+1 −0package.json+1 −1src/admin/bucket.ts+127 −0src/admin/console.ts+11 −4src/bucket/reader.ts+36 −12src/main.ts+6 −4src/server/app.ts+20 −8src/server/config.ts+47 −0src/server/connect.ts+7 −6tests/bucketadmin.test.ts+198 −0
kepr.example.toml+1 −0
@@ -1,4 +1,5 @@11 # Copy to kepr.toml (never saved: it holds the admin password hash and the bucket key).2+# The admin console's bucket section can rewrite it; comments are not kept.23 34 domain = "kepr.example"45 data_dir = "data"

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.130",3+	"version": "3.0.131",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/bucket.ts+127 −0
@@ -0,0 +1,127 @@1+import type { Context, Hono } from "hono";2+import { checkS3Access, openBucket } from "../bucket/reader.ts";3+import { html } from "../pages/html.ts";4+import { type BucketSource, saveConfig } from "../server/config.ts";5+import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";6+7+type S3Source = Extract<BucketSource, { kind: "s3" }>;8+9+// The fields shown back to the operator; the two secrets are never among them.10+const SHOWN: [key: string, label: string][] = [11+	["endpoint", "endpoint"],12+	["name", "bucket name"],13+	["region", "region"],14+	["access_key_id", "read access key ID"],15+	["write_access_key_id", "write access key ID"],16+];17+const REQUIRED = new Set(["endpoint", "name", "region", "access_key_id"]);18+// Keys, names and URLs have neither; a newline would also end the TOML line it is written to.19+const UNPRINTABLE = /[\s\p{C}]/u;20+21+/** The S3 settings a form describes, a blank secret keeping the stored one; or what is wrong. */22+function candidate(fields: Record<string, string>, current: S3Source): S3Source | string {23+	const value = (key: string) => (fields[key] ?? "").trim();24+	for (const [key, label] of SHOWN) {25+		if (REQUIRED.has(key) && value(key) === "") return `The ${label} is required.`;26+		if (UNPRINTABLE.test(fields[key] ?? ""))27+			return `The ${label} cannot contain spaces or control characters.`;28+	}29+	for (const key of ["secret_access_key", "write_secret_access_key"]) {30+		if (UNPRINTABLE.test(value(key)))31+			return "A secret cannot contain spaces or control characters.";32+	}33+	const endpoint = value("endpoint");34+	if (!URL.canParse(endpoint) || !/^https?:$/.test(new URL(endpoint).protocol))35+		return "The endpoint must be an http or https URL.";36+	const source: S3Source = {37+		kind: "s3",38+		endpoint,39+		name: value("name"),40+		region: value("region"),41+		accessKeyId: value("access_key_id"),42+		secretAccessKey: value("secret_access_key") || current.secretAccessKey,43+	};44+	if (value("write_access_key_id") === "") return source;45+	const writeSecret = value("write_secret_access_key") || current.write?.secretAccessKey;46+	if (!writeSecret) return "A new write key needs its secret.";47+	return {48+		...source,49+		write: { accessKeyId: value("write_access_key_id"), secretAccessKey: writeSecret },50+	};51+}52+53+/** §24's Bucket section: edit and test the S3 settings, then write them to kepr.toml (§19). */54+export function registerBucket(app: Hono, ctx: AdminContext): void {55+	app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx)));56+57+	app.post("/admin/bucket", async (c) => {58+		const current = ctx.config.bucket;59+		if (current.kind !== "s3") return c.html(bucketPage(c, ctx), 400);60+		const fields = await textFields(c);61+		const next = candidate(fields, current);62+		if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);63+		const failure = await checkS3Access(next);64+		if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);65+		if (fields.action !== "save") {66+			return c.html(bucketPage(c, ctx, { fields, status: "Connection works. Nothing was saved." }));67+		}68+		try {69+			await saveConfig(ctx.configPath, { ...ctx.config, bucket: next });70+		} catch (error) {71+			const reason = error instanceof Error ? error.message : String(error);72+			const failed = `The settings work, but kepr.toml could not be written: ${reason}`;73+			return c.html(bucketPage(c, ctx, { fields, error: failed }), 500);74+		}75+		// In place, so the sync loop, downloads, the image policy and /connect all see it now.76+		ctx.config.bucket = next;77+		Object.assign(ctx.sync.bucket, openBucket(next));78+		return c.redirect("/admin/bucket?saved=1", 303);79+	});80+}81+82+function bucketPage(83+	c: Context,84+	ctx: AdminContext,85+	shown: { fields?: Record<string, string>; error?: string; status?: string } = {},86+): string {87+	const source = ctx.config.bucket;88+	if (source.kind === "folder") {89+		const body = html`<p class="admin-intro">This instance reads a Koh folder remote at90+<span class="mono">${source.path}</span>.</p>91+<p class="muted">A folder remote is set in kepr.toml only.</p>`;92+		return adminPage(c, ctx, "bucket", body);93+	}94+	const stored: Record<string, string> = {95+		endpoint: source.endpoint,96+		name: source.name,97+		region: source.region,98+		access_key_id: source.accessKeyId,99+		write_access_key_id: source.write?.accessKeyId ?? "",100+	};101+	const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? "";102+	const input = ([key, label]: (typeof SHOWN)[number]) =>103+		html`<label class="issues-label" for="${key}">${label}</label>104+<input class="issues-input mono" id="${key}" name="${key}" value="${value(key)}"105+autocomplete="off" spellcheck="false">`;106+	const secret = (key: string, label: string, hint: string) =>107+		html`<label class="issues-label" for="${key}">${label}</label>108+<input class="issues-input mono" id="${key}" name="${key}" type="password"109+autocomplete="new-password" placeholder="${hint}">`;110+	const body = html`${notice(shown.error ?? "")}111+${shown.status && html`<p class="admin-notice" role="status">${shown.status}</p>`}112+<p class="admin-intro">The S3 bucket Kepr reads with its read-only key. Secrets are never shown.</p>113+<form class="issues-form" method="post" action="/admin/bucket">114+${formField(c)}115+${SHOWN.slice(0, 4).map(input)}116+${secret("secret_access_key", "read secret key", "unchanged")}117+${input(SHOWN[4] as (typeof SHOWN)[number])}118+${secret("write_secret_access_key", "write secret key", source.write ? "unchanged" : "")}119+<p class="muted">The write key is optional: it signs machines in (/connect). Clear its ID to turn that120+off. Saving tests the settings first and rewrites kepr.toml without its comments.</p>121+<div class="issues-actions">122+<button class="issues-submit" type="submit" name="action" value="test">test connection</button>123+<button class="issues-submit" type="submit" name="action" value="save">save</button>124+</div>125+</form>`;126+	return adminPage(c, ctx, "bucket", body);127+}

view file

src/admin/console.ts+11 −4
@@ -5,18 +5,25 @@55 import { type Html, html } from "../pages/html.ts";66 import { type PageContext, page, plural, relativeTime } from "../pages/layout.ts";77 import { backups, newestBackupTarball } from "../server/backup.ts";8-import { MACHINES_LISTED } from "../server/connect.ts";8+import type { Config } from "../server/config.ts";9+import { connectKeys, MACHINES_LISTED } from "../server/connect.ts";910 import { setting } from "../server/db.ts";1011 import { formToken } from "../server/login.ts";1112 import { allRepos } from "./store.ts";1213 13-export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string; canConnect: boolean };14+export type AdminContext = PageContext & {15+	sync: SyncTarget;16+	dataDir: string;17+	config: Config;18+	configPath: string;19+};1420 1521 const SECTIONS = [1622 	["/admin", "overview"],1723 	["/admin/repos", "repos"],1824 	["/admin/issues", "issues"],1925 	["/admin/releases", "releases"],26+	["/admin/bucket", "bucket"],2027 	["/admin?section=backups", "backups"],2128 	["/admin/site?section=profile", "profile"],2229 	["/admin/site", "site"],@@ -109,7 +116,7 @@109116 	});110117 	return html`<h2>Machines connected</h2>111118 <ul>${led}</ul>112-<p class="muted">To revoke access, rotate the write key in Scaleway and update kepr.toml.</p>`;119+<p class="muted">To revoke access, rotate the write key in Scaleway and update it under <a href="/admin/bucket">bucket</a>.</p>`;113120 }114121 115122 /** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */@@ -197,7 +204,7 @@197204 				html`${overview}<section class="repo-section markdown">198205 <p><a href="/admin?section=backups">manage backups →</a></p>199206 <details><summary class="mono">bucket and runtime</summary>${bucket}200-${ctx.canConnect ? connectedMachines(ctx) : ""}207+${connectKeys(ctx.config) ? connectedMachines(ctx) : ""}201208 <p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,202209 			),203210 		);

view file

src/bucket/reader.ts+36 −12
@@ -37,23 +37,47 @@3737 	return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);3838 }3939 40-function s3Reader(source: Extract<BucketSource, { kind: "s3" }>): Bucket {41-	const client = new Bun.S3Client({40+type S3Source = Extract<BucketSource, { kind: "s3" }>;41+42+/**43+ * The console's connection test (§24), made before settings are saved: the read key must list the44+ * bucket, and a write key must write Kepr's own test file. Returns what failed, or null.45+ */46+export async function checkS3Access(source: S3Source): Promise<string | null> {47+	const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));48+	try {49+		// One key is proof enough; listing the whole bucket could take minutes.50+		await s3Client(source).list({ maxKeys: 1 });51+	} catch (error) {52+		return `The read key cannot list the bucket: ${reason(error)}`;53+	}54+	if (!source.write) return null;55+	try {56+		const stamp = new TextEncoder().encode(new Date().toISOString());57+		await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");58+	} catch (error) {59+		return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;60+	}61+	return null;62+}63+64+function s3Client(65+	source: S3Source,66+	keys: { accessKeyId: string; secretAccessKey: string } = source,67+): Bun.S3Client {68+	return new Bun.S3Client({4269 		endpoint: source.endpoint,4370 		bucket: source.name,4471 		region: source.region,45-		accessKeyId: source.accessKeyId,46-		secretAccessKey: source.secretAccessKey,72+		accessKeyId: keys.accessKeyId,73+		secretAccessKey: keys.secretAccessKey,4774 	});75+}76+77+function s3Reader(source: S3Source): Bucket {78+	const client = s3Client(source);4879 	// Reads keep the read-only key; only Kepr's own writes use the write key, when there is one.49-	const writer =50-		source.write &&51-		new Bun.S3Client({52-			endpoint: source.endpoint,53-			bucket: source.name,54-			region: source.region,55-			...source.write,56-		});80+	const writer = source.write && s3Client(source, source.write);5781 	return {5882 		async get(key) {5983 			try {

view file

src/main.ts+6 −4
@@ -3,21 +3,23 @@33 import { startSyncLoop } from "./bucket/sync.ts";44 import { createApp } from "./server/app.ts";55 import { startBackupLoop } from "./server/backup.ts";6-import { ConfigError, loadConfig } from "./server/config.ts";6+import { CONFIG_FILE, ConfigError, loadConfig } from "./server/config.ts";77 import { openKeprDb } from "./server/db.ts";88 99 try {10-	const config = await loadConfig("kepr.toml");10+	const config = await loadConfig(CONFIG_FILE);1111 	const kepr = openKeprDb(config.dataDir);12+	// One bucket for the sync loop and the app, so settings saved in the console reach both.13+	const bucket = openBucket(config.bucket);1214 	startBackupLoop(kepr, config.dataDir);1315 	startSyncLoop({14-		bucket: openBucket(config.bucket),16+		bucket,1517 		kepr,1618 		mirrorDir: join(config.dataDir, "mirror"),1719 		tarballDir: join(config.dataDir, "tarballs"),1820 	});1921 	// Bun.serve reads the port from $PORT (default 3000).20-	const server = Bun.serve({ fetch: createApp(config, kepr).fetch });22+	const server = Bun.serve({ fetch: createApp(config, kepr, bucket).fetch });2123 	console.log(`kepr listening on ${server.url}`);2224 } catch (error) {2325 	if (!(error instanceof ConfigError)) throw error;

view file

src/server/app.ts+20 −8
@@ -3,13 +3,14 @@33 import { Hono } from "hono";44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";6+import { registerBucket } from "../admin/bucket.ts";67 import { registerStatus } from "../admin/console.ts";78 import { registerIssues } from "../admin/issues.ts";89 import { registerReleases } from "../admin/releases.ts";910 import { registerRepos } from "../admin/repos.ts";1011 import { registerSite } from "../admin/site.ts";1112 import { registerRepoText } from "../admin/text.ts";12-import { openBucket } from "../bucket/reader.ts";13+import { type Bucket, openBucket } from "../bucket/reader.ts";1314 import { isSafePath } from "../bucket/repo.ts";1415 import { registerIssuePages } from "../issues/pages.ts";1516 import { registerKohRemote } from "../mirror/remote.ts";@@ -25,7 +26,7 @@2526 import { registerSavePage } from "../pages/save.ts";2627 import { registerReleaseRoutes } from "../releases/routes.ts";2728 import { registerTarballRoutes } from "../tarball/archive.ts";28-import type { Config } from "./config.ts";29+import { CONFIG_FILE, type Config } from "./config.ts";2930 import { connectKeys, noCode, registerConnect } from "./connect.ts";3031 import { registerLogin } from "./login.ts";3132 @@ -37,13 +38,23 @@3738 // §18 allows Bunny Fonts: its stylesheet and the font files it points at.3839 const FONTS = "https://fonts.bunny.net";3940 40-/** The Hono app with §25's headers and body cap, and every route built so far. */41-export function createApp(config: Config, kepr: Database): Hono {41+/**42+ * The Hono app with §25's headers and body cap, and every route built so far. The console's Bucket43+ * section changes `config.bucket` and `bucket` in place, so everything here reads them per request.44+ */45+export function createApp(46+	config: Config,47+	kepr: Database,48+	bucket: Bucket = openBucket(config.bucket),49+	configPath = CONFIG_FILE,50+): Hono {4251 	const app = new Hono();4352 	// §25 allows images from the presigned host, which is the bucket endpoint's origin; uploaded4453 	// videos come from there too.45-	const imgSrc =46-		config.bucket.kind === "s3" ? ["'self'", new URL(config.bucket.endpoint).origin] : ["'self'"];54+	const imgSrc = [55+		"'self'",56+		() => (config.bucket.kind === "s3" ? new URL(config.bucket.endpoint).origin : ""),57+	];4758 	app.use("*", async (c, next) => {4859 		await next();4960 		const path = c.req.path;@@ -98,13 +109,13 @@98109 		return c.body(await file.bytes(), 200, { "Content-Type": file.type });99110 	});100111 	const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };101-	const bucket = openBucket(config.bucket);102112 	// Before every other route, so the admin guard runs ahead of each `/admin` handler.103113 	registerLogin(app, ctx, config.adminPasswordHash);104114 	registerConnect(app, config, ctx);105115 	const admin = {106116 		...ctx,107-		canConnect: connectKeys(config) !== null,117+		config,118+		configPath,108119 		dataDir: config.dataDir,109120 		sync: { bucket, kepr, mirrorDir: ctx.mirrorDir, tarballDir: join(config.dataDir, "tarballs") },110121 	};@@ -113,6 +124,7 @@113124 	registerIssues(app, admin);114125 	registerReleases(app, admin);115126 	registerSite(app, admin);127+	registerBucket(app, admin);116128 	registerRepoText(app, admin);117129 	registerHousekeeping(app, ctx);118130 	registerFrontPages(app, ctx);

view file

src/server/config.ts+47 −0
@@ -1,3 +1,5 @@1+import { chmod, rename, writeFile } from "node:fs/promises";2+13 export type BucketSource =24 	| {35 			kind: "s3";@@ -49,6 +51,51 @@4951 		adminPasswordHash,5052 		bucket: parseBucket(bucket),5153 	};54+}55+56+/** Where `main` reads the config, and where the console's Bucket section writes it back (§19). */57+export const CONFIG_FILE = "kepr.toml";58+59+/**60+ * Replaces the config file with `config` in one rename, owner-only since it holds keys (§19).61+ * Comments in the old file are not kept. What would not parse back is never written.62+ */63+export async function saveConfig(path: string, config: Config): Promise<void> {64+	// A JSON string is a valid TOML basic string for everything the parse-back below accepts.65+	const str = JSON.stringify;66+	const { bucket } = config;67+	const lines = [68+		`domain = ${str(config.domain)}`,69+		`data_dir = ${str(config.dataDir)}`,70+		`admin_password_hash = ${str(config.adminPasswordHash)}`,71+		"",72+		"[bucket]",73+	];74+	if (bucket.kind === "folder") lines.push(`folder = ${str(bucket.path)}`);75+	else {76+		lines.push(77+			`endpoint = ${str(bucket.endpoint)}`,78+			`name = ${str(bucket.name)}`,79+			`region = ${str(bucket.region)}`,80+			`access_key_id = ${str(bucket.accessKeyId)}`,81+			`secret_access_key = ${str(bucket.secretAccessKey)}`,82+		);83+		if (bucket.write) {84+			lines.push(85+				"",86+				"[bucket.write]",87+				`access_key_id = ${str(bucket.write.accessKeyId)}`,88+				`secret_access_key = ${str(bucket.write.secretAccessKey)}`,89+			);90+		}91+	}92+	const text = `${lines.join("\n")}\n`;93+	parseConfig(text);94+	const next = `${path}.new`;95+	await writeFile(next, text, { mode: 0o600 });96+	// `mode` applies only to a new file, and a crash may have left an older one behind.97+	await chmod(next, 0o600);98+	await rename(next, path);5299 }53100 54101 export async function loadConfig(path: string): Promise<Config> {

view file

src/server/connect.ts+7 −6
@@ -50,12 +50,9 @@5050 5151 /** §24's connect page and the one-time code it hands a machine, plus the API that redeems it. */5252 export function registerConnect(app: Hono, config: Config, ctx: PageContext): void {53-	const bucket = config.bucket.kind === "s3" ? config.bucket : null;54-	const write = bucket?.write ?? null;55-5653 	app.get("/connect", (c) => {5754 		// A disabled route is an unknown path, byte for byte, so nothing is set before this line.58-		if (!write) return c.notFound();55+		if (!connectKeys(config)) return c.notFound();5956 		c.header("Cache-Control", "no-store");6057 		const machine = machineName(c.req.query("machine"));6158 		if (!signedIn(ctx, c)) {@@ -75,7 +72,7 @@7572 	});7673 7774 	app.post("/connect", async (c) => {78-		if (!write) return c.notFound();75+		if (!connectKeys(config)) return c.notFound();7976 		c.header("Cache-Control", "no-store");8077 		// As for every admin POST: a live session, the right origin and the same-site form token.8178 		if (!signedIn(ctx, c)) return adminRefusal(c, ctx, 401, "Sign in first.");@@ -103,7 +100,7 @@103100 	});104101 105102 	app.post("/api/connect", async (c) => {106-		if (!write || !bucket) return c.notFound();103+		if (!connectKeys(config)) return c.notFound();107104 		c.header("Cache-Control", "no-store");108105 		const now = Date.now();109106 		// By value, not by position: a clock that stepped backwards outside this process (a test's@@ -148,6 +145,10 @@148145 				 )`,149146 			)150147 			.run(MACHINES_LISTED);148+		// Read again after the awaits above: the console may have changed the keys meanwhile.149+		const bucket = config.bucket.kind === "s3" ? config.bucket : null;150+		if (!bucket?.write) return noCode(c);151+		const write = bucket.write;151152 		return c.json({152153 			endpoint: bucket.endpoint,153154 			bucket: bucket.name,

view file

tests/bucketadmin.test.ts+198 −0
@@ -0,0 +1,198 @@1+// Covers §24's Bucket section: masked secrets, the connection test, and a save that rewrites2+// kepr.toml and applies the new settings without a restart.3+4+import { afterAll, beforeEach, describe, expect, test } from "bun:test";5+import { mkdtemp, stat } from "node:fs/promises";6+import { tmpdir } from "node:os";7+import { join } from "node:path";8+import { openBucket } from "../src/bucket/reader.ts";9+import { createApp } from "../src/server/app.ts";10+import { type Config, loadConfig } from "../src/server/config.ts";11+import { openKeprDb } from "../src/server/db.ts";12+import { Browser, PASSWORD } from "./browser.ts";13+14+const HASH = await Bun.password.hash(PASSWORD, "argon2id");15+const READ_IDS = new Set(["READ-ID", "NEW-READ"]);16+const WRITE_ID = "WRITE-ID";17+const written: string[] = [];18+19+// Just enough S3 for the connection test: ListObjectsV2 and PUT, allowed by the signing key's ID.20+function fakeS3() {21+	return Bun.serve({22+		port: 0,23+		async fetch(request) {24+			const keyId = /Credential=([^/]+)\//.exec(request.headers.get("authorization") ?? "")?.[1];25+			const denied = () =>26+				new Response("<Error><Code>AccessDenied</Code><Message>no</Message></Error>", {27+					status: 403,28+				});29+			if (request.method === "PUT") {30+				if (keyId !== WRITE_ID) return denied();31+				written.push(new URL(request.url).pathname);32+				return new Response("", { headers: { etag: '"1"' } });33+			}34+			if (!keyId || !READ_IDS.has(keyId)) return denied();35+			return new Response(36+				"<ListBucketResult><IsTruncated>false</IsTruncated><Contents><Key>app/head</Key></Contents></ListBucketResult>",37+			);38+		},39+	});40+}41+const first = fakeS3();42+const second = fakeS3();43+afterAll(() => {44+	first.stop(true);45+	second.stop(true);46+});47+48+async function build(folder = false) {49+	const dataDir = await mkdtemp(join(tmpdir(), "kepr-bucket-admin-"));50+	const configPath = join(dataDir, "kepr.toml");51+	const bucket: Config["bucket"] = folder52+		? { kind: "folder", path: dataDir }53+		: {54+				kind: "s3",55+				endpoint: `http://127.0.0.1:${first.port}`,56+				name: "koh",57+				region: "fr-par",58+				accessKeyId: "READ-ID",59+				secretAccessKey: "READ-SECRET",60+			};61+	const config: Config = { domain: "kepr.example", dataDir, adminPasswordHash: HASH, bucket };62+	const original = folder63+		? "folder instance\n"64+		: `# hand-written\ndomain = "kepr.example"\ndata_dir = "${dataDir}"\nadmin_password_hash = "${HASH}"\n[bucket]\nendpoint = "${bucket.kind === "s3" ? bucket.endpoint : ""}"\nname = "koh"\nregion = "fr-par"\naccess_key_id = "READ-ID"\nsecret_access_key = "READ-SECRET"\n`;65+	await Bun.write(configPath, original);66+	const kepr = openKeprDb(dataDir);67+	const shared = openBucket(config.bucket);68+	const app = createApp(config, kepr, shared, configPath);69+	const browser = new Browser(app);70+	await browser.signIn();71+	await browser.get("/admin/bucket");72+	return { app, config, configPath, original, shared, browser };73+}74+75+const form = (fields: Record<string, string>) => ({76+	endpoint: `http://127.0.0.1:${first.port}`,77+	name: "koh",78+	region: "fr-par",79+	access_key_id: "READ-ID",80+	secret_access_key: "",81+	write_access_key_id: "",82+	write_secret_access_key: "",83+	...fields,84+});85+86+beforeEach(() => {87+	written.length = 0;88+});89+90+describe("the Bucket section (§24)", () => {91+	test("shows every setting but the secrets", async () => {92+		const { browser } = await build();93+		const text = await (await browser.get("/admin/bucket")).text();94+		expect(text).toContain(`http://127.0.0.1:${first.port}`);95+		expect(text).toContain('value="READ-ID"');96+		expect(text).toContain('href="/admin/bucket" aria-current="page"');97+		expect(text).not.toContain("READ-SECRET");98+	});99+100+	test("the test checks the form and writes nothing to kepr.toml", async () => {101+		const { browser, configPath, original } = await build();102+		const works = await browser.post("/admin/bucket", form({ action: "test" }));103+		expect(works.status).toBe(200);104+		expect(await works.text()).toContain("Connection works");105+		const refused = await browser.post(106+			"/admin/bucket",107+			form({ action: "test", access_key_id: "WRONG" }),108+		);109+		expect(refused.status).toBe(400);110+		expect(await refused.text()).toContain("The read key cannot list the bucket");111+		expect(await Bun.file(configPath).text()).toBe(original);112+	});113+114+	test("a write key must write Kepr's own test file, and only that", async () => {115+		const { browser, configPath, original } = await build();116+		const refused = await browser.post(117+			"/admin/bucket",118+			form({ action: "save", write_access_key_id: "READ-ID", write_secret_access_key: "x" }),119+		);120+		expect(refused.status).toBe(400);121+		expect(await refused.text()).toContain("The write key cannot write");122+		expect(await Bun.file(configPath).text()).toBe(original);123+		const works = await browser.post(124+			"/admin/bucket",125+			form({ action: "test", write_access_key_id: WRITE_ID, write_secret_access_key: "ws" }),126+		);127+		expect(works.status).toBe(200);128+		expect(written).toEqual(["/koh/.kepr/connection-test"]);129+	});130+131+	test("save rewrites kepr.toml, keeps blank secrets and applies at once", async () => {132+		const { app, browser, configPath, shared } = await build();133+		expect((await browser.get("/connect?machine=laptop")).status).toBe(404);134+		const endpoint = `http://127.0.0.1:${second.port}`;135+		const saved = await browser.post(136+			"/admin/bucket",137+			form({138+				action: "save",139+				endpoint,140+				region: "nl-ams",141+				write_access_key_id: WRITE_ID,142+				write_secret_access_key: "WRITE-SECRET",143+			}),144+		);145+		expect(saved.status).toBe(303);146+		expect(saved.headers.get("location")).toBe("/admin/bucket?saved=1");147+		const reloaded = await loadConfig(configPath);148+		expect(reloaded.bucket).toEqual({149+			kind: "s3",150+			endpoint,151+			name: "koh",152+			region: "nl-ams",153+			accessKeyId: "READ-ID",154+			secretAccessKey: "READ-SECRET",155+			write: { accessKeyId: WRITE_ID, secretAccessKey: "WRITE-SECRET" },156+		});157+		expect(reloaded.adminPasswordHash).toBe(HASH);158+		expect((await stat(configPath)).mode & 0o777).toBe(0o600);159+		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith(`${endpoint}/koh/app/x`);160+		const csp = (await app.request("/")).headers.get("content-security-policy") ?? "";161+		expect(csp).toContain(endpoint);162+		expect(csp).not.toContain(`127.0.0.1:${first.port}`);163+		expect((await browser.get("/connect?machine=laptop")).status).toBe(200);164+		const page = await (await browser.get("/admin/bucket")).text();165+		expect(page).not.toContain("WRITE-SECRET");166+167+		const cleared = await browser.post("/admin/bucket", form({ action: "save", endpoint }));168+		expect(cleared.status).toBe(303);169+		expect((await loadConfig(configPath)).bucket).not.toHaveProperty("write");170+		expect((await browser.get("/connect?machine=laptop")).status).toBe(404);171+	});172+173+	test("refuses a malformed form before testing anything", async () => {174+		const { browser, configPath, original } = await build();175+		const cases: [Record<string, string>, string][] = [176+			[{ endpoint: "ftp://example.com" }, "The endpoint must be an http or https URL."],177+			[{ name: "" }, "The bucket name is required."],178+			[{ region: "fr\npar" }, "The region cannot contain spaces or control characters."],179+			[{ write_access_key_id: "NEW-WRITE" }, "A new write key needs its secret."],180+		];181+		for (const [fields, error] of cases) {182+			const response = await browser.post("/admin/bucket", form({ action: "save", ...fields }));183+			expect(response.status).toBe(400);184+			expect(await response.text()).toContain(error);185+		}186+		expect(await Bun.file(configPath).text()).toBe(original);187+	});188+189+	test("a folder remote is shown read-only and never rewritten", async () => {190+		const { browser, configPath, original, config } = await build(true);191+		const text = await (await browser.get("/admin/bucket")).text();192+		expect(text).toContain(config.bucket.kind === "folder" ? config.bucket.path : "");193+		expect(text).not.toContain('name="endpoint"');194+		const response = await browser.post("/admin/bucket", form({ action: "save" }));195+		expect(response.status).toBe(400);196+		expect(await Bun.file(configPath).text()).toBe(original);197+	});198+});

view file