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
↓ kepr-af33540f.tar.gzatlas at this save
files changed17 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 −6package.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": {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>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 /**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 }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>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")}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 }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);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),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 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 }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,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" });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", () => {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("<img src=x onerror=alert(1)>");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"));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 );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 });