af33540f3628

read and write the bucket with one key, removing [bucket.write] (v3.1.9)

time
2026-10-07 01:27 UTC · 6 days ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
one bucket key
parent
d09bbcf21969
17 files package.json+1 −1src/admin/bucket.ts+9 −20src/admin/console.ts+1 −1src/admin/rename.ts+0 −3src/admin/repos.ts+1 −1src/admin/setup.ts+4 −3src/bucket/reader.ts+18 −22src/bucket/sync.ts+3 −6src/server/app.ts+1 −1src/server/config.ts+5 −20src/server/connect.ts+5 −6src/tarball/cache.ts+2 −2tests/bucketadmin.test.ts+22 −38tests/config.test.ts+3 −14tests/connect.test.ts+20 −38tests/kohsteal.test.ts+1 −1tests/repotext.test.ts+21 −6
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.1.8",3+	"version": "3.1.9",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/bucket.ts+9 −20
@@ -14,8 +14,7 @@1414 	["endpoint", "endpoint"],1515 	["name", "bucket name"],1616 	["region", "region"],17-	["access_key_id", "read access key ID"],18-	["write_access_key_id", "write access key ID"],17+	["access_key_id", "access key ID"],1918 ];2019 const REQUIRED = new Set(["endpoint", "name", "region", "access_key_id"]);2120 // Keys, names and URLs have neither; a newline would also end the TOML line it is written to.@@ -56,8 +55,8 @@5655 	if (problem) return problem;5756 	const endpoint = value("endpoint");5857 	const secretAccessKey = value("secret_access_key") || current?.secretAccessKey;59-	if (!secretAccessKey) return "The read secret key is required.";60-	const source: S3Source = {58+	if (!secretAccessKey) return "The secret key is required.";59+	return {6160 		kind: "s3",6261 		endpoint,6362 		name: value("name"),@@ -65,13 +64,6 @@6564 		accessKeyId: value("access_key_id"),6665 		secretAccessKey,6766 	};68-	if (value("write_access_key_id") === "") return source;69-	const writeSecret = value("write_secret_access_key") || current?.write?.secretAccessKey;70-	if (!writeSecret) return "A new write key needs its secret.";71-	return {72-		...source,73-		write: { accessKeyId: value("write_access_key_id"), secretAccessKey: writeSecret },74-	};7567 }7668 7769 /** A folder remote a form names, which Kepr must be able to list; or what is wrong. */@@ -134,7 +126,6 @@134126 		name: s3?.name ?? "",135127 		region: s3?.region ?? "",136128 		access_key_id: s3?.accessKeyId ?? "",137-		write_access_key_id: s3?.write?.accessKeyId ?? "",138129 	};139130 	const value = (key: string) => shown.fields?.[key] ?? stored[key] ?? "";140131 	const input = ([key, label]: (typeof SHOWN)[number]) =>@@ -151,17 +142,15 @@151142 	source.kind === "folder"152143 		? html`<p class="admin-intro">This instance reads a Koh folder remote at153144 <span class="mono">${source.path}</span>. Saving S3 settings switches it to that bucket.</p>`154-		: html`<p class="admin-intro">The S3 bucket Kepr reads with its read-only key. Secrets are never shown.</p>`145+		: html`<p class="admin-intro">The S3 bucket Kepr reads and writes. Secrets are never shown.</p>`155146 }156147 <form class="issues-form" method="post" action="/admin/bucket">157148 ${formField(c)}158-${SHOWN.slice(0, 4).map(input)}159-${secret("secret_access_key", "read secret key", s3 ? "unchanged" : "")}160-${input(SHOWN[4] as (typeof SHOWN)[number])}161-${secret("write_secret_access_key", "write secret key", s3?.write ? "unchanged" : "")}162-<p class="muted">The read key is all Kepr needs to show your repos. The write key is optional: with163-it Kepr keeps tarballs and uploads under .kepr/, deletes builds you delete, and signs machines in164-(/connect). Clear its ID to go without. Saving tests the settings first; secrets are never shown.</p>149+${SHOWN.map(input)}150+${secret("secret_access_key", "secret key", s3 ? "unchanged" : "")}151+<p class="muted">One key reads and writes the bucket: Kepr keeps tarballs and uploads under .kepr/,152+deletes builds you delete, and hands the key to machines that sign in (/connect). Saving tests the153+settings first; secrets are never shown.</p>165154 <div class="issues-actions">166155 <button class="issues-submit" type="submit" name="action" value="test">test connection</button>167156 <button class="issues-submit" type="submit" name="action" value="save">save</button>

view file

src/admin/console.ts+1 −1
@@ -131,7 +131,7 @@131131 	});132132 	return html`<h2>Machines connected</h2>133133 <ul>${led}</ul>134-<p class="muted">To revoke access, rotate the write key in Scaleway and update it under <a href="/admin/bucket">bucket</a>.</p>`;134+<p class="muted">To revoke access, rotate the bucket key in Scaleway and update it under <a href="/admin/bucket">bucket</a>.</p>`;135135 }136136 137137 /**

view file

src/admin/rename.ts+0 −3
@@ -28,9 +28,6 @@2828 	if (!NEW_NAME.test(to)) return "a new name is lowercase letters, digits, '.', '_' and '-'.";2929 	if (RESERVED_NAMES.has(to)) return `"${to}" is one of Kepr's own addresses.`;3030 	if (renames.has(from)) return `it is already being renamed to ${renames.get(from)}.`;31-	if (ctx.config.bucket.kind === "s3" && !ctx.config.bucket.write) {32-		return "renaming writes to the bucket: add its write key in the bucket section.";33-	}3431 	if (findRepo(ctx.kepr, to) || (await ctx.sync.bucket.list(`${to}/`)).length > 0) {3532 		return `"${to}" is taken.`;3633 	}

view file

src/admin/repos.ts+1 −1
@@ -314,7 +314,7 @@314314 <textarea class="issues-textarea mono" id="${repo.name}-hidden_paths" name="hidden_paths"315315 rows="4">${repo.hidden_paths}</textarea>316316 <p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,317-the stats and tarballs, and passed to Koh for .kohignore when there is a write key.317+the stats and tarballs, and passed to Koh for .kohignore.318318 For every repo, use <a href="/admin/site#global-hide">global hide</a>.</p>319319 <button class="issues-submit" type="submit">save ${repo.name}</button>320320 </form>

view file

src/admin/setup.ts+4 −3
@@ -139,9 +139,10 @@139139 ${input("endpoint", "endpoint", "https://s3.fr-par.scw.cloud")}140140 ${input("name", "bucket name")}141141 ${input("region", "region")}142-${input("access_key_id", "read access key ID")}143-${input("secret_access_key", "read secret key", "", "password")}144-<p class="muted">A write key, a backup bucket and email can be added in the console afterwards.</p>142+${input("access_key_id", "access key ID")}143+${input("secret_access_key", "secret key", "", "password")}144+<p class="muted">The key reads and writes the bucket. A backup bucket and email can be added in145+the console afterwards.</p>145146 <label class="issues-label"><input type="radio" name="source" value="folder"${s3 ? "" : html` checked`}>146147 a Koh folder remote on this server</label>147148 ${input("folder", "folder", "", "text")}

view file

src/bucket/reader.ts+18 −22
@@ -52,7 +52,7 @@5252 	return results;5353 }5454 55-/** A write Kepr can't make: outside its folder, or without the `[bucket.write]` key. */55+/** A write Kepr can't make: outside its folder, or refused by the bucket. */5656 export class BucketWriteError extends Error {}5757 5858 const DOWNLOAD_SECONDS = 5 * 60;@@ -85,8 +85,8 @@8585 type S3Source = Extract<BucketSource, { kind: "s3" }>;8686 8787 /**88- * The console's connection test (§24), made before settings are saved: the read key must list the89- * bucket, and a write key must write Kepr's own test file. Returns what failed, or null.88+ * The console's connection test (§24), made before settings are saved: the key must list the89+ * bucket and write Kepr's own test file. Returns what failed, or null.9090  */9191 export async function checkS3Access(source: S3Source): Promise<string | null> {9292 	const reason = (error: unknown) => (error instanceof Error ? error.message : String(error));@@ -94,40 +94,36 @@9494 		// One key is proof enough; listing the whole bucket could take minutes.9595 		await s3Client(source).list({ maxKeys: 1 });9696 	} catch (error) {97-		return `The read key cannot list the bucket: ${reason(error)}`;97+		return `The key cannot list the bucket: ${reason(error)}`;9898 	}99-	if (!source.write) return null;10099 	try {101100 		const stamp = new TextEncoder().encode(new Date().toISOString());102101 		await s3Reader(source).put(`${KEPR_PREFIX}connection-test`, stamp, "text/plain");103102 	} catch (error) {104-		return `The write key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;103+		return `The key cannot write ${KEPR_PREFIX}connection-test: ${reason(error)}`;105104 	}106105 	return null;107106 }108107 109-function s3Client(110-	source: S3Source,111-	keys: { accessKeyId: string; secretAccessKey: string } = source,112-): Bun.S3Client {108+function s3Client(source: S3Source): Bun.S3Client {113109 	return new Bun.S3Client({114110 		endpoint: source.endpoint,115111 		bucket: source.name,116112 		region: source.region,117-		accessKeyId: keys.accessKeyId,118-		secretAccessKey: keys.secretAccessKey,113+		accessKeyId: source.accessKeyId,114+		secretAccessKey: source.secretAccessKey,119115 	});120116 }121117 122118 function s3Reader(source: S3Source): Bucket {123119 	const client = s3Client(source);124-	// Reads keep the read-only key; only Kepr's own writes use the write key, when there is one.125-	const writer = source.write && s3Client(source, source.write);126-	const requireWriter = () => {127-		if (!writer) {128-			throw new BucketWriteError("Writing needs the bucket's write key: add [bucket.write].");120+	// A refused write is a BucketWriteError, which the console shows and a tarball cache skips.121+	const write = async (key: string, run: (file: Bun.S3File) => Promise<unknown>) => {122+		try {123+			await run(client.file(key));124+		} catch (error) {125+			throw new BucketWriteError(`The bucket refused ${key}: ${error}`);129126 		}130-		return writer;131127 	};132128 	return {133129 		async get(key) {@@ -183,22 +179,22 @@183179 		},184180 		async put(key, bytes, type) {185181 			requireWritable(key, RELEASE_WRITE);186-			await requireWriter().file(key).write(bytes, { type });182+			await write(key, (file) => file.write(bytes, { type }));187183 		},188184 		async remove(key) {189185 			requireWritable(key, RELEASE_FILE);190-			await requireWriter().file(key).delete();186+			await write(key, (file) => file.delete());191187 		},192188 		async copy(from, to) {193189 			requireRepoKey(from);194190 			requireRepoKey(to);195191 			const bytes = await this.get(from);196192 			if (bytes === null) throw new Error(`${from} is missing`);197-			await requireWriter().file(to).write(bytes);193+			await write(to, (file) => file.write(bytes));198194 		},199195 		async removeRenamed(key) {200196 			requireRepoKey(key);201-			await requireWriter().file(key).delete();197+			await write(key, (file) => file.delete());202198 		},203199 	};204200 }

view file

src/bucket/sync.ts+3 −6
@@ -24,7 +24,7 @@2424 } from "../server/db.ts";2525 import { RESERVED_NAMES } from "../server/reserved.ts";2626 import { dropTarballs } from "../tarball/cache.ts";27-import { type Bucket, BucketWriteError, inTurn, KEPR_PREFIX, openBucket } from "./reader.ts";27+import { type Bucket, inTurn, KEPR_PREFIX, openBucket } from "./reader.ts";2828 import {2929 	BucketError,3030 	checkFormat,@@ -209,8 +209,7 @@209209  * Hands a repo's hidden paths to Koh at `.kepr/hidden/<repo>`, one per line, for it to add to210210  * `.kohignore`, unless they are known to be there already, then drops the tarballs kept under the211211  * old list. The sync does it rather than the console, so a failed write or a restart is retried at212- * the next pass, and it never overlaps a rename. Without a write key the list stays in Kepr: Koh213- * reads it only from the bucket anyway.212+ * the next pass, and it never overlaps a rename.214213  */215214 async function handHidden(target: SyncTarget, repo: string): Promise<void> {216215 	const list = hiddenToHand(target.kepr, repo);@@ -223,9 +222,7 @@223222 	try {224223 		await target.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, bytes, "text/plain");225224 	} catch (error) {226-		if (!(error instanceof BucketWriteError)) {227-			console.error(`kepr: handing ${repo}'s hidden paths to Koh failed: ${error}`);228-		}225+		console.error(`kepr: handing ${repo}'s hidden paths to Koh failed: ${error}`);229226 		return;230227 	}231228 	recordHiddenHanded(target.kepr, repo, list);

view file

src/server/app.ts+1 −1
@@ -125,7 +125,7 @@125125 			maxSize,126126 			onError: (c) =>127127 				// An oversized redemption is one more failed redemption — but only where the route128-				// exists: without write keys it is an unknown path and answers as one.128+				// exists: on a folder bucket it is an unknown path and answers as one.129129 				c.req.path === "/api/connect" && connectKeys(config)130130 					? noCode(c)131131 					: c.text("Request body too large", 413),

view file

src/server/config.ts+5 −20
@@ -8,7 +8,6 @@88 			region: string;99 			accessKeyId: string;1010 			secretAccessKey: string;11-			write?: { accessKeyId: string; secretAccessKey: string };1211 	  }1312 	// A directory laid out exactly like the bucket (Koh 3's folder remote), for development and tests.1413 	| { kind: "folder"; path: string };@@ -146,14 +145,6 @@146145 			`access_key_id = ${str(bucket.accessKeyId)}`,147146 			`secret_access_key = ${str(bucket.secretAccessKey)}`,148147 		);149-		if (bucket.write) {150-			lines.push(151-				"",152-				"[bucket.write]",153-				`access_key_id = ${str(bucket.write.accessKeyId)}`,154-				`secret_access_key = ${str(bucket.write.secretAccessKey)}`,155-			);156-		}157148 	}158149 	const { smtp } = config;159150 	if (smtp) {@@ -197,9 +188,12 @@197188 }198189 199190 function parseBucket(bucket: Record<string, unknown>): BucketSource {191+	if (bucket.write !== undefined) {192+		throw new ConfigError(193+			"[bucket.write] is no longer used: Kepr reads and writes with the [bucket] key alone",194+		);195+	}200196 	if (bucket.folder !== undefined) {201-		if (bucket.write !== undefined)202-			throw new ConfigError("[bucket] write cannot be combined with folder");203197 		const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];204198 		const mixed = s3Keys.filter((key) => bucket[key] !== undefined);205199 		if (mixed.length > 0) {@@ -209,7 +203,6 @@209203 	}210204 	const endpoint = requireString(bucket, "endpoint", "[bucket] ");211205 	if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");212-	const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]");213206 	return {214207 		kind: "s3",215208 		endpoint,@@ -217,14 +210,6 @@217210 		region: requireString(bucket, "region", "[bucket] "),218211 		accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),219212 		secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),220-		...(write221-			? {222-					write: {223-						accessKeyId: requireString(write, "access_key_id", "[bucket.write] "),224-						secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "),225-					},226-				}227-			: {}),228213 	};229214 }230215 

view file

src/server/connect.ts+5 −6
@@ -34,11 +34,11 @@3434 const attemptKey = randomBytes(32);3535 const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");3636 37-/** The S3 write keys a signed-in machine is handed, or null when Kepr hands out none. */37+/** The S3 key a signed-in machine is handed, or null when Kepr hands out none. */3838 export function connectKeys(3939 	config: Config,4040 ): { accessKeyId: string; secretAccessKey: string } | null {41-	return config.bucket.kind === "s3" && config.bucket.write ? config.bucket.write : null;41+	return config.bucket.kind === "s3" ? config.bucket : null;4242 }4343 4444 /** A machine name as it may be stored and shown: ≤64 characters, none of them a control code. */@@ -154,14 +154,13 @@154154 			.run(MACHINES_LISTED);155155 		// Read again after the awaits above: the console may have changed the keys meanwhile.156156 		const bucket = config.bucket.kind === "s3" ? config.bucket : null;157-		if (!bucket?.write) return noCode(c);158-		const write = bucket.write;157+		if (!bucket) return noCode(c);159158 		return c.json({160159 			endpoint: bucket.endpoint,161160 			bucket: bucket.name,162161 			region: bucket.region,163-			access_key_id: write.accessKeyId,164-			secret_access_key: write.secretAccessKey,162+			access_key_id: bucket.accessKeyId,163+			secret_access_key: bucket.secretAccessKey,165164 		});166165 	});167166 }

view file

src/tarball/cache.ts+2 −2
@@ -9,8 +9,8 @@99 /**1010  * A save's tarball, kept in the bucket under Kepr's own folder (June, 2026-09-30) as1111  * `.kepr/tarballs/<repo>/<generation>/<id>.tar.gz` once first requested (`tarballGeneration`).12- * Without a write key nothing is kept, and each request builds it again. Null when the save is13- * gone from the mirror, as it is for a moment while a rebuild runs.12+ * If the bucket refuses the write nothing is kept, and each request builds it again. Null when the13+ * save is gone from the mirror, as it is for a moment while a rebuild runs.1414  */1515 export async function cachedTarball(1616 	bucket: Bucket,

view file

tests/bucketadmin.test.ts+22 −38
@@ -12,11 +12,12 @@1212 import { Browser, PASSWORD } from "./browser.ts";1313 1414 const HASH = await Bun.password.hash(PASSWORD, "argon2id");15-const READ_IDS = new Set(["READ-ID", "NEW-READ"]);16-const WRITE_ID = "WRITE-ID";15+const KEY_IDS = new Set(["KEY-ID", "NEW-KEY"]);16+const READ_ONLY = "READ-ONLY";1717 const written: string[] = [];1818 19-// Just enough S3 for the connection test: ListObjectsV2 and PUT, allowed by the signing key's ID.19+// Just enough S3 for the connection test: ListObjectsV2 and PUT, allowed by the signing key's ID;20+// READ-ONLY may list but not write.2021 function fakeS3() {2122 	return Bun.serve({2223 		port: 0,@@ -27,11 +28,11 @@2728 					status: 403,2829 				});2930 			if (request.method === "PUT") {30-				if (keyId !== WRITE_ID) return denied();31+				if (!keyId || !KEY_IDS.has(keyId)) return denied();3132 				written.push(new URL(request.url).pathname);3233 				return new Response("", { headers: { etag: '"1"' } });3334 			}34-			if (!keyId || !READ_IDS.has(keyId)) return denied();35+			if (!keyId || !(KEY_IDS.has(keyId) || keyId === READ_ONLY)) return denied();3536 			return new Response(3637 				"<ListBucketResult><IsTruncated>false</IsTruncated><Contents><Key>app/head</Key></Contents></ListBucketResult>",3738 			);@@ -55,13 +56,13 @@5556 				endpoint: `http://127.0.0.1:${first.port}`,5657 				name: "koh",5758 				region: "fr-par",58-				accessKeyId: "READ-ID",59-				secretAccessKey: "READ-SECRET",59+				accessKeyId: "KEY-ID",60+				secretAccessKey: "KEY-SECRET",6061 			};6162 	const config: Config = { domain: "kepr.example", dataDir, adminPasswordHash: HASH, bucket };6263 	const original = folder6364 		? "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+		: `# 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 = "KEY-ID"\nsecret_access_key = "KEY-SECRET"\n`;6566 	await Bun.write(configPath, original);6667 	const kepr = openKeprDb(dataDir);6768 	const shared = openBucket(config.bucket);@@ -76,10 +77,8 @@7677 	endpoint: `http://127.0.0.1:${first.port}`,7778 	name: "koh",7879 	region: "fr-par",79-	access_key_id: "READ-ID",80+	access_key_id: "KEY-ID",8081 	secret_access_key: "",81-	write_access_key_id: "",82-	write_secret_access_key: "",8382 	...fields,8483 });8584 @@ -92,9 +91,9 @@9291 		const { browser } = await build();9392 		const text = await (await browser.get("/admin/bucket")).text();9493 		expect(text).toContain(`http://127.0.0.1:${first.port}`);95-		expect(text).toContain('value="READ-ID"');94+		expect(text).toContain('value="KEY-ID"');9695 		expect(text).toContain('href="/admin/bucket" aria-current="page"');97-		expect(text).not.toContain("READ-SECRET");96+		expect(text).not.toContain("KEY-SECRET");9897 	});9998 10099 	test("the test checks the form and writes nothing to kepr.toml", async () => {@@ -107,30 +106,24 @@107106 			form({ action: "test", access_key_id: "WRONG" }),108107 		);109108 		expect(refused.status).toBe(400);110-		expect(await refused.text()).toContain("The read key cannot list the bucket");109+		expect(await refused.text()).toContain("The key cannot list the bucket");111110 		expect(await Bun.file(configPath).text()).toBe(original);111+		expect(written).toEqual(["/koh/.kepr/connection-test"]);112112 	});113113 114-	test("a write key must write Kepr's own test file, and only that", async () => {114+	test("a key that can only read is refused", async () => {115115 		const { browser, configPath, original } = await build();116116 		const refused = await browser.post(117117 			"/admin/bucket",118-			form({ action: "save", write_access_key_id: "READ-ID", write_secret_access_key: "x" }),118+			form({ action: "save", access_key_id: READ_ONLY, secret_access_key: "x" }),119119 		);120120 		expect(refused.status).toBe(400);121-		expect(await refused.text()).toContain("The write key cannot write");121+		expect(await refused.text()).toContain("The key cannot write");122122 		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"]);129123 	});130124 131125 	test("save rewrites kepr.toml, keeps blank secrets and applies at once", async () => {132126 		const { app, browser, configPath, shared } = await build();133-		expect((await browser.get("/connect?machine=laptop")).status).toBe(404);134127 		const endpoint = `http://127.0.0.1:${second.port}`;135128 		const saved = await browser.post(136129 			"/admin/bucket",@@ -138,8 +131,6 @@138131 				action: "save",139132 				endpoint,140133 				region: "nl-ams",141-				write_access_key_id: WRITE_ID,142-				write_secret_access_key: "WRITE-SECRET",143134 			}),144135 		);145136 		expect(saved.status).toBe(303);@@ -150,9 +141,8 @@150141 			endpoint,151142 			name: "koh",152143 			region: "nl-ams",153-			accessKeyId: "READ-ID",154-			secretAccessKey: "READ-SECRET",155-			write: { accessKeyId: WRITE_ID, secretAccessKey: "WRITE-SECRET" },144+			accessKeyId: "KEY-ID",145+			secretAccessKey: "KEY-SECRET",156146 		});157147 		expect(reloaded.adminPasswordHash).toBe(HASH);158148 		expect((await stat(configPath)).mode & 0o777).toBe(0o600);@@ -162,12 +152,7 @@162152 		expect(csp).not.toContain(`127.0.0.1:${first.port}`);163153 		expect((await browser.get("/connect?machine=laptop")).status).toBe(200);164154 		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);155+		expect(page).not.toContain("KEY-SECRET");171156 	});172157 173158 	test("refuses a malformed form before testing anything", async () => {@@ -176,7 +161,6 @@176161 			[{ endpoint: "ftp://example.com" }, "The endpoint must be an http or https URL."],177162 			[{ name: "" }, "The bucket name is required."],178163 			[{ 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."],180164 		];181165 		for (const [fields, error] of cases) {182166 			const response = await browser.post("/admin/bucket", form({ action: "save", ...fields }));@@ -193,10 +177,10 @@193177 		expect(text).toContain('name="endpoint"');194178 		const secretless = await browser.post("/admin/bucket", form({ action: "save" }));195179 		expect(secretless.status).toBe(400);196-		expect(await secretless.text()).toContain("The read secret key is required.");180+		expect(await secretless.text()).toContain("The secret key is required.");197181 		const saved = await browser.post(198182 			"/admin/bucket",199-			form({ action: "save", secret_access_key: "READ-SECRET" }),183+			form({ action: "save", secret_access_key: "KEY-SECRET" }),200184 		);201185 		expect(saved.status).toBe(303);202186 		expect((await loadConfig(configPath)).bucket).toMatchObject({ kind: "s3", name: "koh" });

view file

tests/config.test.ts+3 −14
@@ -30,21 +30,10 @@3030 		},3131 	});3232 });33-test("parses optional S3 write keys", () => {34-	const config = parseConfig(35-		`${s3}[bucket.write]\naccess_key_id = "SCWWRITE"\nsecret_access_key = "wsecret"\n`,36-	);37-	expect(config.bucket).toMatchObject({38-		write: { accessKeyId: "SCWWRITE", secretAccessKey: "wsecret" },39-	});33+test("refuses a leftover [bucket.write]: the one [bucket] key reads and writes", () => {4034 	expect(() =>41-		parseConfig(`${s3}[bucket.write]\naccess_key_id = ""\nsecret_access_key = "x"\n`),42-	).toThrow("access_key_id is required");43-	expect(() =>44-		parseConfig(45-			`domain = "d"\ndata_dir = "data"\nadmin_password_hash = "${hash}"\n[bucket]\nfolder = "/tmp/b"\n[bucket.write]\naccess_key_id = "x"\nsecret_access_key = "y"\n`,46-		),47-	).toThrow("cannot be combined");35+		parseConfig(`${s3}[bucket.write]\naccess_key_id = "SCWWRITE"\nsecret_access_key = "w"\n`),36+	).toThrow("[bucket.write] is no longer used");4837 });4938 5039 test("parses a folder source", () => {

view file

tests/connect.test.ts+20 −38
@@ -7,19 +7,18 @@77 import { join } from "node:path";88 import { createApp } from "../src/server/app.ts";99 import { makeBackup } from "../src/server/backup.ts";10-import { type Config, parseConfig } from "../src/server/config.ts";10+import type { Config } from "../src/server/config.ts";1111 import { openKeprDb } from "../src/server/db.ts";1212 import { Browser, PASSWORD } from "./browser.ts";1313 1414 const ORIGIN = "https://kepr.example";15-const WRITE = { accessKeyId: "WRITE-ID", secretAccessKey: "WRITE-SECRET" };1615 // One argon2 hash for the whole file: every instance shares it, so the suite stays quick.1716 const HASH = await Bun.password.hash(PASSWORD, "argon2id");1817 let clock = Date.parse("2026-09-27T12:00:00Z");1918 setSystemTime(clock);2019 afterAll(() => setSystemTime());2120 22-async function build(write = true, folder = false) {21+async function build(folder = false) {2322 	const dataDir = await mkdtemp(join(tmpdir(), "kepr-connect-"));2423 	const kepr = openKeprDb(dataDir);2524 	const bucket: Config["bucket"] = folder@@ -29,9 +28,8 @@2928 				endpoint: "https://s3.fr-par.scw.cloud",3029 				name: "my-bucket",3130 				region: "fr-par",32-				accessKeyId: "read-id",33-				secretAccessKey: "read-secret",34-				...(write ? { write: WRITE } : {}),31+				accessKeyId: "KEY-ID",32+				secretAccessKey: "KEY-SECRET",3533 			};3634 	const config: Config = {3735 		domain: "kepr.example",@@ -42,8 +40,8 @@4240 	return { app: createApp(config, kepr), kepr, config };4341 }4442 45-async function connected(write = true) {46-	const built = await build(write);43+async function connected() {44+	const built = await build();4745 	const browser = new Browser(built.app, `198.51.100.${Math.floor(Math.random() * 200) + 1}`);4846 	await browser.signIn();4947 	return { ...built, browser };@@ -68,31 +66,15 @@6866 }6967 7068 describe.serial("connect configuration and page", () => {71-	test("parseConfig rejects write keys beside a folder bucket and empty write values", () => {72-		const hash = "$argon2id$v=19$m=65536,t=2,p=1$abc$abc";73-		expect(() =>74-			parseConfig(75-				`domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nfolder = "/tmp/x"\n[bucket.write]\naccess_key_id = "x"\nsecret_access_key = "y"`,76-			),77-		).toThrow();78-		expect(() =>79-			parseConfig(80-				`domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nendpoint = "https://x"\nname = "n"\nregion = "r"\naccess_key_id = "a"\nsecret_access_key = "s"\n[bucket.write]\naccess_key_id = ""\nsecret_access_key = "y"`,81-			),82-		).toThrow();69+	test("a folder bucket hides all connect routes", async () => {70+		const browser = new Browser((await build(true)).app);71+		expect((await browser.get("/connect?machine=x")).status).toBe(404);72+		expect((await browser.post("/connect", { machine: "x" })).status).toBe(404);73+		expect((await browser.post("/api/connect", { code: "ABCDEFGH" })).status).toBe(404);8374 	});8475 85-	test("folder buckets and S3 buckets without write keys hide all connect routes", async () => {86-		for (const built of [await build(false), await build(false, true)]) {87-			const browser = new Browser(built.app);88-			expect((await browser.get("/connect?machine=x")).status).toBe(404);89-			expect((await browser.post("/connect", { machine: "x" })).status).toBe(404);90-			expect((await browser.post("/api/connect", { code: "ABCDEFGH" })).status).toBe(404);91-		}92-	});93-9476 	test("a disabled connect route is an unknown path, byte for byte", async () => {95-		const { app } = await build(false);77+		const { app } = await build(true);9678 		const browser = new Browser(app);9779 		for (const [method, path] of [9880 			["GET", "/connect?machine=x"],@@ -163,7 +145,7 @@163145 		const result = await issue(browser, "<img src=x onerror=alert(1)>");164146 		expect(result.page.status).toBe(200);165147 		expect(result.body).toContain("&lt;img src=x onerror=alert(1)&gt;");166-		expect(result.body).not.toContain("WRITE-SECRET");148+		expect(result.body).not.toContain("KEY-SECRET");167149 		expect(result.page.headers.get("cache-control")).toBe("no-store");168150 	});169151 });@@ -173,7 +155,7 @@173155 		const { app, browser, kepr, config } = await connected();174156 		const { code, body } = await issue(browser, "office");175157 		expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);176-		expect(body).not.toContain("WRITE-SECRET");158+		expect(body).not.toContain("KEY-SECRET");177159 		const row = kepr.query("SELECT code_hash, machine FROM connect_codes").get() as {178160 			code_hash: string;179161 			machine: string;@@ -187,8 +169,8 @@187169 			endpoint: config.bucket.kind === "s3" ? config.bucket.endpoint : "",188170 			bucket: "my-bucket",189171 			region: "fr-par",190-			access_key_id: "WRITE-ID",191-			secret_access_key: "WRITE-SECRET",172+			access_key_id: "KEY-ID",173+			secret_access_key: "KEY-SECRET",192174 		});193175 		expect(success.headers.get("cache-control")).toBe("no-store");194176 		const used = await redeem(app, code, "203.0.113.89");@@ -312,7 +294,7 @@312294 		expect(response.headers.get("cache-control")).toBe("no-store");313295 	});314296 315-	test("a backup carries the hashed code and never the write key", async () => {297+	test("a backup carries the hashed code and never the bucket key", async () => {316298 		const { browser, kepr, config } = await connected();317299 		const { code } = await issue(browser, "laptop");318300 		const name = makeBackup(kepr, config.dataDir);@@ -322,9 +304,9 @@322304 		copy.close();323305 		expect(rows.length).toBe(1);324306 		expect(JSON.stringify(rows)).not.toContain(code.replace("-", ""));325-		// The write key is read from kepr.toml alone, so no copy of kepr.db can hold it.307+		// The key is read from kepr.toml alone, so no copy of kepr.db can hold it.326308 		const bytes = await Bun.file(file).bytes();327-		expect(new TextDecoder().decode(bytes)).not.toContain("WRITE-SECRET");309+		expect(new TextDecoder().decode(bytes)).not.toContain("KEY-SECRET");328310 	});329311 });330312 @@ -341,7 +323,7 @@341323 		const admin = await browser.get("/admin");342324 		const html = await admin.text();343325 		expect(html).toContain("Machines connected");344-		expect(html).toContain("rotate the write key in Scaleway");326+		expect(html).toContain("rotate the bucket key in Scaleway");345327 		expect(html).not.toContain("machine-0 ");346328 		expect(html).toContain("machine-21");347329 		expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-20"));

view file

tests/kohsteal.test.ts+1 −1
@@ -140,7 +140,7 @@140140 						domain: `127.0.0.1:${server.port}`,141141 						dataDir,142142 						adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),143-						bucket: { ...bucket, write: { accessKeyId: ACCESS, secretAccessKey: SECRET } },143+						bucket,144144 					},145145 					kepr,146146 				);

view file

tests/repotext.test.ts+21 −6
@@ -139,7 +139,7 @@139139 	});140140 });141141 142-test("Kepr writes only its own folder and builds, and to S3 only with the write key", async () => {142+test("Kepr writes only its own folder and builds, and a refused S3 write says so", async () => {143143 	const { bucketDir } = await makeRepos(["app"]);144144 	const folder = openBucket({ kind: "folder", path: bucketDir });145145 	const refused = ["app/head", ".kepr/../app/head", ".kepr//x", "x/.kepr/y", "app/releases/.."];@@ -157,16 +157,31 @@157157 	await folder.put(".kepr/x", new Uint8Array([1]), "text/plain");158158 	await folder.remove(".kepr/x");159159 	expect(await Bun.file(`${bucketDir}/.kepr/x`).exists()).toBe(false);160-	const readOnly = openBucket({160+	let allowed = true;161+	const writes: string[] = [];162+	using s3 = Bun.serve({163+		port: 0,164+		fetch(request) {165+			writes.push(`${request.method} ${new URL(request.url).pathname}`);166+			return allowed167+				? new Response(null, { status: request.method === "DELETE" ? 204 : 200 })168+				: new Response("<Error><Code>AccessDenied</Code></Error>", { status: 403 });169+		},170+	});171+	const bucket = openBucket({161172 		kind: "s3",162-		endpoint: "http://127.0.0.1:9",173+		endpoint: s3.url.href,163174 		name: "b",164175 		region: "r",165176 		accessKeyId: "k",166177 		secretAccessKey: "s",167178 	});179+	await bucket.put(".kepr/media/app/x.png", new Uint8Array([1]), "image/png");180+	await bucket.remove("app/releases/1.0.0/app");181+	expect(writes).toEqual(["PUT /b/.kepr/media/app/x.png", "DELETE /b/app/releases/1.0.0/app"]);182+	allowed = false;168183 	await expect(169-		readOnly.put(".kepr/media/app/x.png", new Uint8Array([1]), "image/png"),170-	).rejects.toThrow("[bucket.write]");171-	await expect(readOnly.remove(".kepr/media/app/x.png")).rejects.toThrow("[bucket.write]");184+		bucket.put(".kepr/media/app/x.png", new Uint8Array([1]), "image/png"),185+	).rejects.toBeInstanceOf(BucketWriteError);186+	await expect(bucket.remove(".kepr/media/app/x.png")).rejects.toBeInstanceOf(BucketWriteError);172187 });

view file