71860b49096f

let a folder instance switch to S3 from the bucket section (v3.0.141)

time
2026-09-30 13:44 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
admin repo settings
parent
4ffd21d3611b
3 files package.json+1 −1src/admin/bucket.ts+24 −20tests/bucketadmin.test.ts+15 −6
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.140",3+	"version": "3.0.141",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/bucket.ts+24 −20
@@ -19,7 +19,7 @@1919 const UNPRINTABLE = /[\s\p{C}]/u;2020 2121 /** 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 {22+function candidate(fields: Record<string, string>, current: S3Source | null): S3Source | string {2323 	const value = (key: string) => (fields[key] ?? "").trim();2424 	for (const [key, label] of SHOWN) {2525 		if (REQUIRED.has(key) && value(key) === "") return `The ${label} is required.`;@@ -33,16 +33,18 @@3333 	const endpoint = value("endpoint");3434 	if (!URL.canParse(endpoint) || !/^https?:$/.test(new URL(endpoint).protocol))3535 		return "The endpoint must be an http or https URL.";36+	const secretAccessKey = value("secret_access_key") || current?.secretAccessKey;37+	if (!secretAccessKey) return "The read secret key is required.";3638 	const source: S3Source = {3739 		kind: "s3",3840 		endpoint,3941 		name: value("name"),4042 		region: value("region"),4143 		accessKeyId: value("access_key_id"),42-		secretAccessKey: value("secret_access_key") || current.secretAccessKey,44+		secretAccessKey,4345 	};4446 	if (value("write_access_key_id") === "") return source;45-	const writeSecret = value("write_secret_access_key") || current.write?.secretAccessKey;47+	const writeSecret = value("write_secret_access_key") || current?.write?.secretAccessKey;4648 	if (!writeSecret) return "A new write key needs its secret.";4749 	return {4850 		...source,@@ -50,15 +52,17 @@5052 	};5153 }5254 53-/** §24's Bucket section: edit and test the S3 settings, then write them to kepr.toml (§19). */55+/**56+ * §24's Bucket section: edit and test the S3 settings, then write them to kepr.toml (§19). A folder57+ * instance can switch to S3 here (June, 2026-09-30); only the file switches it back.58+ */5459 export function registerBucket(app: Hono, ctx: AdminContext): void {5560 	app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx)));5661 5762 	app.post("/admin/bucket", async (c) => {5863 		const current = ctx.config.bucket;59-		if (current.kind !== "s3") return c.html(bucketPage(c, ctx), 400);6064 		const fields = await textFields(c);61-		const next = candidate(fields, current);65+		const next = candidate(fields, current.kind === "s3" ? current : null);6266 		if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);6367 		const failure = await checkS3Access(next);6468 		if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);@@ -85,18 +89,13 @@8589 	shown: { fields?: Record<string, string>; error?: string; status?: string } = {},8690 ): string {8791 	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-	}92+	const s3 = source.kind === "s3" ? source : null;9493 	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 ?? "",94+		endpoint: s3?.endpoint ?? "",95+		name: s3?.name ?? "",96+		region: s3?.region ?? "",97+		access_key_id: s3?.accessKeyId ?? "",98+		write_access_key_id: s3?.write?.accessKeyId ?? "",10099 	};101100 	const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? "";102101 	const input = ([key, label]: (typeof SHOWN)[number]) =>@@ -109,13 +108,18 @@109108 autocomplete="new-password" placeholder="${hint}">`;110109 	const body = html`${notice(shown.error ?? "")}111110 ${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>111+${112+	source.kind === "folder"113+		? html`<p class="admin-intro">This instance reads a Koh folder remote at114+<span class="mono">${source.path}</span>. Saving S3 settings below switches it to that bucket.</p>`115+		: html`<p class="admin-intro">The S3 bucket Kepr reads with its read-only key. Secrets are never shown.</p>`116+}113117 <form class="issues-form" method="post" action="/admin/bucket">114118 ${formField(c)}115119 ${SHOWN.slice(0, 4).map(input)}116-${secret("secret_access_key", "read secret key", "unchanged")}120+${secret("secret_access_key", "read secret key", s3 ? "unchanged" : "")}117121 ${input(SHOWN[4] as (typeof SHOWN)[number])}118-${secret("write_secret_access_key", "write secret key", source.write ? "unchanged" : "")}122+${secret("write_secret_access_key", "write secret key", s3?.write ? "unchanged" : "")}119123 <p class="muted">The write key is optional: it signs machines in (/connect). Clear its ID to turn that120124 off. Saving tests the settings first and rewrites kepr.toml without its comments.</p>121125 <div class="issues-actions">

view file

tests/bucketadmin.test.ts+15 −6
@@ -186,13 +186,22 @@186186 		expect(await Bun.file(configPath).text()).toBe(original);187187 	});188188 189-	test("a folder remote is shown read-only and never rewritten", async () => {190-		const { browser, configPath, original, config } = await build(true);189+	test("a folder instance can switch to S3 from the console", async () => {190+		const { browser, configPath, config, shared } = await build(true);191191 		const text = await (await browser.get("/admin/bucket")).text();192192 		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);193+		expect(text).toContain('name="endpoint"');194+		const secretless = await browser.post("/admin/bucket", form({ action: "save" }));195+		expect(secretless.status).toBe(400);196+		expect(await secretless.text()).toContain("The read secret key is required.");197+		const saved = await browser.post(198+			"/admin/bucket",199+			form({ action: "save", secret_access_key: "READ-SECRET" }),200+		);201+		expect(saved.status).toBe(303);202+		expect((await loadConfig(configPath)).bucket).toMatchObject({ kind: "s3", name: "koh" });203+		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith(204+			`http://127.0.0.1:${first.port}/koh/app/x`,205+		);197206 	});198207 });

view file