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
↓ kepr-63e55f15.tar.gzatlas at this save
files changed10 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 −0kepr.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"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": {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+}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 );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 {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;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);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> {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,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+});