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
↓ kepr-71860b49.tar.gzatlas at this save
files changed3 files
package.json+1 −1src/admin/bucket.ts+24 −20tests/bucketadmin.test.ts+15 −6package.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": {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">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 });