feat(management): product image upload to R2 from both stok forms #62

Merged
yuris merged 4 commits from feat/44-product-image-upload into main 2026-08-28 01:15:43 +00:00
Owner

Closes #44.

Merging this PR deploys to production. No workflow triggers on pull_request — both .forgejo/workflows/*.yml fire only on push: main, and they deploy: management publishes a container, the store runs wrangler pages deploy. The forge never checked this branch, so every green light below is a command I ran locally with its real output pasted in.

What changed

  • lib/storage/r2.ts — uploadReceipt(file, "order"|"purchase") becomes uploadFile(file, kind) with export type UploadKind = "order" | "purchase" | "product". One uploader, not two: the S3 client, MIME allow-list, size caps and ${R2_PUBLIC_URL}/${key} return are unchanged. kind picks only the prefix and whether a PDF is legal — products/{YYYY}/{MM}/{uuid}{ext} for a product, receipts/{kind}/{YYYY}/{MM}/{uuid}{ext} for a receipt, exactly the prefix receipts already use, so nothing in the bucket moves.
  • lib/storage/r2.ts — a product photo is images-only (pdfAllowed = kind !== "product"), holding the 5 MB image cap; receipts keep both the 5 MB image and 10 MB PDF caps. The rejection message drops "PDF" only for the product kind.
  • lib/storage/r2.ts — the allow-list is a Record mapping each accepted MIME type to the extension its object is stored under, read through Object.hasOwn, so resolving the extension is the allow-list check and the two cannot drift. file.name never reaches the object key. Review caught the first cut of this wrong, fixed in e52bd00: a plain-object lookup on the caller-controlled file.type resolved __proto__ and constructor off Object.prototype and stored arbitrary bytes under a public URL.
  • app/api/[[...route]]/uploads.ts — type accepts product, validated by membership in readonly UploadKind[]. An unknown type is refused with a 400 rather than defaulted, because a silent fallback would file a product photo under the receipt prefix. Review caught the first cut of this wrong too, fixed in e52bd00: the whitelist was an object, so __proto__ and constructor were truthy, passed the check, and stringified into the key prefix as receipts/[object Object]/.
  • app/api/[[...route]]/uploads.ts — uploadFile is wrapped so a type/size rejection returns a readable 422, and formData() is wrapped so a malformed multipart body returns a readable 400. The API mounts no error handler, so every failure is answered explicitly; anything left to throw would reach the operator as a bare 500.
  • lib/uploads.ts — client helper renamed uploadReceipt → uploadFile and now takes UploadKind.
  • app/(dashboard)/stok/image-upload-button.tsx (new) — hidden <input type="file"> plus a button; uploads one photo as product and hands the caller the public URL. Surfaces the route's own message on failure.
  • app/(dashboard)/stok/add-inventory-modal.tsx, inventory-edit-form.tsx — ImageUploadButton added next to the existing Tambah URL rows, not replacing them. Labels became Gambar with an upload foto atau tempel URL hint.
  • lib/mcp/tools/images.ts — upload_receipt's type enum gains product; description updated.
  • lib/bot/nodes/vision-llm.ts, components/ui/form-field.tsx — updated to the renamed function. No behaviour change; form-field.tsx still passes receiptType, and vision-llm.ts still passes "purchase".

Verified

R2 is real infrastructure and there are no R2 credentials on this machine, so the S3 endpoint is a local S3-compatible sink (see Deviations). Everything above client.send — env read, MIME check, size check, extension handling, key assembly — is the shipped code, and the keys below are what the real uploadFile addressed.

Suite, types, lint — at ecdaa23, the commit being merged

Re-run immediately before merging, at the exact SHA being merged:

$ git rev-parse HEAD
ecdaa23fcba12a66782ae951b4a345117ebb01ec

$ pnpm --filter garage test
 RUN  v4.1.10 /home/yuris/Development/webdev/wt-44/apps/management
 Test Files  9 passed (9)
      Tests  163 passed (163)

$ cd apps/management && ./node_modules/.bin/tsc --noEmit
tsc exit=0

$ biome check $(git diff --name-only origin/main..HEAD)
Checked 11 files in 12ms. No fixes applied.
biome exit=0

The new tests, covering the kind→prefix mapping, both rejection paths, and the two bypasses review caught. Trimmed to the additions; full run in comment 3146:

$ ./node_modules/.bin/vitest run lib/storage/r2.test.ts "app/api/[[...route]]/uploads.test.ts" --reporter=verbose
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > writes a product photo under products/
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > keeps a order receipt under receipts/<kind>/
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > keeps a purchase receipt under receipts/<kind>/
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > stores the declared content type and body
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > derives the extension for image/jpeg from the mime type
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > derives the extension for image/png from the mime type
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > derives the extension for image/webp from the mime type
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > ignores the filename entirely when building the key
 ✓ lib/storage/r2.test.ts > uploadFile key prefix > gives a receipt PDF a .pdf key even when the filename says otherwise
 ✓ lib/storage/r2.test.ts > uploadFile rejections > rejects a PDF for the product kind with a readable message
 ✓ lib/storage/r2.test.ts > uploadFile rejections > rejects a non-image, non-PDF receipt with a readable message
 ✓ lib/storage/r2.test.ts > uploadFile rejections > refuses a prototype-chain mime type for the order kind
 ✓ lib/storage/r2.test.ts > uploadFile rejections > refuses a prototype-chain mime type for the purchase kind
 ✓ lib/storage/r2.test.ts > uploadFile rejections > refuses a prototype-chain mime type for the product kind
 ✓ lib/storage/r2.test.ts > uploadFile rejections > rejects a product photo over 5 MB
 ✓ lib/storage/r2.test.ts > uploadFile rejections > accepts a product photo at exactly 5 MB
 ✓ lib/storage/r2.test.ts > uploadFile rejections > keeps the 10 MB PDF cap for receipts
 ✓ lib/storage/r2.test.ts > uploadFile rejections > keeps the 5 MB image cap for receipts
 ✓ lib/storage/r2.test.ts > uploadFile rejections > refuses to upload without R2 configuration
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > passes the order kind through to the uploader
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > passes the purchase kind through to the uploader
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > passes the product kind through to the uploader
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind 'produk' instead of defaulting it
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind '' instead of defaulting it
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind 'receipt' instead of defaulting it
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind '__proto__' instead of defaulting it
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind 'constructor' instead of defaulting it
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind 'toString' instead of defaulting it
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects a missing type
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects a missing file
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > surfaces a non-image rejection as a readable 422
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > surfaces an oversized rejection as a readable 422
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > never lets an uploader failure escape as a 500
 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > answers a malformed multipart body with a readable 400
 Test Files  2 passed (2)
      Tests  34 passed (34)

Crafted requests — no picker involved

Hand-built multipart/form-data POSTs straight at POST /api/v1/uploads/receipt on a real dev server, authenticated with X-API-Key. This is what proves the enforcement is server-side rather than an <input accept> convenience.

=== VALID UPLOADS ===
product / real PNG
  -> HTTP 200 {"url":".../products/2026/08/84f7b387-1b60-493a-97b4-b659a8d7d951.png"}
order receipt / PNG (unchanged path)
  -> HTTP 200 {"url":".../receipts/order/2026/08/035aafa2-b640-4682-a15c-1e6fb781d85c.png"}
purchase receipt / PDF (PDF branch preserved)
  -> HTTP 200 {"url":".../receipts/purchase/2026/08/1bcedb1f-cfb5-42c4-a219-f08ad7fe4194.pdf"}
purchase receipt / 6 MB PDF (10 MB cap preserved)
  -> HTTP 200 {"url":".../receipts/purchase/2026/08/bd355645-b8f0-4d33-b91d-94dfd9afae5e.pdf"}

=== REJECTIONS (must be readable, not a bare 500) ===
product / PDF — no PDF branch for a product photo
  -> HTTP 422 {"error":"Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP."}
product / text-plain non-image
  -> HTTP 422 {"error":"Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP."}
product / 6 MB PNG over the 5 MB image cap
  -> HTTP 422 {"error":"Ukuran file terlalu besar. Maksimal 5 MB."}
order receipt / 11 MB PDF over the 10 MB cap
  -> HTTP 422 {"error":"Ukuran file terlalu besar. Maksimal 10 MB."}
unknown kind 'avatar' — must not default to a receipt
  -> HTTP 400 {"error":"Field 'type' harus 'order', 'purchase', atau 'product'"}
missing kind
  -> HTTP 400 {"error":"Field 'type' harus 'order', 'purchase', atau 'product'"}

=== PUBLIC READBACK of the product URL ===
  -> HTTP 200 content-type=image/png bytes=79
  -> byte-identical to what was uploaded: true
  -> is a PNG (magic bytes): true

The two bypasses review caught, before and after

Reproduced against the real uploadFile and the real router (only the S3 client stubbed) before fixing, so the fix is anchored to observed behaviour:

=== BEFORE (e79e867) ===
receipt order type='__proto__'    -> STORED receipts/order/2026/08/<uuid>.html
receipt order type='constructor'  -> STORED receipts/order/2026/08/<uuid>.svg
receipt order type='text/html'    -> rejected (correct)
product      type='__proto__'     -> STORED products/2026/08/<uuid>.html
route type='__proto__'   -> HTTP 200 key=receipts/[object Object]/2026/08/<uuid>.png
route type='constructor' -> HTTP 200 key=receipts/function Object() { [native code] }/...
malformed multipart body -> HTTP 500 Internal Server Error

=== AFTER (e52bd00), same script over real HTTP ===
product / mime '__proto__'   -> HTTP 422 Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP.
order   / mime '__proto__'   -> HTTP 422 Tipe file tidak didukung. Gunakan JPEG, PNG, WEBP, atau PDF.
product / mime 'constructor' -> HTTP 422   (same, and 'toString' likewise for both kinds)
upload kind '__proto__'      -> HTTP 400 Field 'type' harus 'order', 'purchase', atau 'product'
upload kind 'constructor'    -> HTTP 400   (same, and 'toString' likewise)
malformed multipart body     -> HTTP 400 Body tidak valid. Kirim sebagai multipart/form-data.

Receipts unaffected — the actual keys the uploader wrote

PUT products/2026/08/84f7b387-1b60-493a-97b4-b659a8d7d951.png       (79 bytes, image/png)
PUT receipts/order/2026/08/035aafa2-b640-4682-a15c-1e6fb781d85c.png (79 bytes, image/png)
PUT receipts/order/2026/08/b7f25da0-cec1-408c-8adc-23b21f4ba30d.pdf (69 bytes, application/pdf)
PUT receipts/purchase/2026/08/1bcedb1f-cfb5-42c4-a219-f08ad7fe4194.pdf   (54 bytes, application/pdf)
PUT receipts/purchase/2026/08/bd355645-b8f0-4d33-b91d-94dfd9afae5e.pdf   (6291456 bytes, application/pdf)
PUT receipts/purchase/2026/08/1aad08d0-618b-4265-92c3-21a4db5efce7.webp  (70 bytes, image/webp)

Order and purchase both exercised, PDF included, on both the 10 MB and 5 MB caps. Every receipt key is receipts/{kind}/{YYYY}/{MM}/{uuid}{ext} — identical to main. .png, .pdf, .webp and .jpg suffixes all round-trip unchanged.

vision-llm.ts does not go through the HTTP route; it imports the server-side uploadFile and calls it directly, so I drove that function with the exact File shapes vision-llm builds (vision-llm.ts:100-110):

=== vision-llm.ts receipt path (direct uploadFile call) ===
  image/jpeg -> .../receipts/purchase/2026/08/01fbb752-6684-49bd-adf1-c5b06b8ef8f0.jpg
     under receipts/purchase/: true
  image/png  -> .../receipts/purchase/2026/08/2010ec96-5e16-4bac-b64f-611fdbd24074.png
     under receipts/purchase/: true
  image/webp -> .../receipts/purchase/2026/08/709f4d6e-21b3-4228-b365-e61f2f50409f.webp
     under receipts/purchase/: true

MCP tool

The real registerImageTools on a real McpServer over an in-memory transport, so the enum, the handler and uploadFile are all shipped code:

=== upload_receipt tool schema ===
  type enum: {"type":"string","enum":["order","purchase","product"]}

product photo (PNG) — must land under products/
  isError=false -> { "url": ".../products/2026/08/0c02bf59-5611-4111-a74f-baf36e2147ae.png" }
order receipt (PNG) — must stay under receipts/order/
  isError=false -> { "url": ".../receipts/order/2026/08/73ca011f-b555-4e15-9fe2-9e4ce8434243.png" }
purchase receipt (PDF) — PDF still accepted for a receipt
  isError=false -> { "url": ".../receipts/purchase/2026/08/80814e33-b2f3-42a7-b580-59d676e1f9cc.pdf" }
product photo (PDF) — must be refused, no PDF branch
  isError=true -> Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP.
unknown kind 'avatar' — must fail the enum
  isError=true -> MCP error -32602: Invalid option: expected one of "order"|"purchase"|"product"

Real UI, both forms — driven with the browser tool

Logged into the dashboard as a real user and used the actual file picker.

Add-inventory modal. Uploaded a 64×64 magenta PNG; the returned URL landed in an images row:

imageRowValues: ["http://127.0.0.1:8444/products/2026/08/e1626192-2177-4c78-8dff-d642ee308d01.png"]

Rejections through the real picker, message rendered in the form, and no bogus row added:

non-image (.txt)   -> "Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP."
oversized 6 MB png -> "Ukuran file terlalu besar. Maksimal 5 MB."
imageRowsAfterRejections: [ ...the one good upload only ]

Then added a manual URL row next to the upload and saved. Both persisted, in order:

id |         item_name          | is_published | images
 7 | Kampas Rem Depan Foto Asli | t            | {http://127.0.0.1:8444/products/2026/08/e1626192-....png,
                                                 https://placehold.co/800x600/webp?text=URL+Manual}

Inventory edit form (Toko tab). Uploaded a 64×64 dark-green PNG onto an item that already had an external URL. The upload appended and the existing URL survived:

imageRowsBefore: ["https://placehold.co/800x600/webp?text=Oli+Mesin"]
imageRowsAfter:  ["https://placehold.co/800x600/webp?text=Oli+Mesin",
                  "http://127.0.0.1:8444/products/2026/08/25c706d9-51e3-45c3-8283-87896dafe155.png"]

A non-image rejection here shows the same message and leaves the rows untouched. After Simpan:

id |    item_name     | is_published | images
 4 | Oli Mesin 10W-40 | t            | {https://placehold.co/800x600/webp?text=Oli+Mesin,
                                        http://127.0.0.1:8444/products/2026/08/25c706d9-....png}

Manual URL entry still works in both forms, so existing items on external URLs and scripts/seed-demo.mts's placehold.co are unaffected.

requestStoreDeploy fires — against a real sink, not unset

STORE_DEPLOY_WORKFLOW_URL pointed at a local HTTP sink, since deploy.ts:59 makes an unset URL a silent no-op that would prove nothing. Zero hits before, one hit per image write:

deploy hits BEFORE submit: 0
count: 2
  POST /dispatch auth=token local-deploy-token-44 body={"ref":"main"} 2026-08-27T18:02:03.821Z   <- add modal
  POST /dispatch auth=token local-deploy-token-44 body={"ref":"main"} 2026-08-27T18:06:10.688Z   <- edit form

affectsStorefront confirms images is a storefront column:

{"images":["http://x/a.png"]} -> true
{"itemName":"x"}              -> true
{"buyPrice":1}                -> false

Storefront render — the end of the acceptance criterion

GET /api/public/catalog carries both uploaded URLs (schemaVersion: 3), then next build on apps/store (exit 0, /products/[slug] prerendered) and served the static export.

Add-modal upload, /products/kampas-rem-depan-foto-asli — the uploaded photo is the hero image, the manual URL is the second thumbnail:

src=http://127.0.0.1:8444/products/2026/08/e1626192-....png  complete=true  decoded=64x64  displayed=true
src=https://placehold.co/800x600/webp?text=URL+Manual        complete=true  decoded=800x600

Edit-form upload, /products/oli-mesin-10w-40:

src=http://127.0.0.1:8444/products/2026/08/25c706d9-....png  complete=true  decoded=64x64
src=https://placehold.co/800x600/webp?text=Oli+Mesin         complete=true  decoded=800x600

naturalWidth/naturalHeight of 64x64 means the browser fetched and decoded the real bytes, not a broken image. next.config.ts sets images.unoptimized, and the src is the stored URL verbatim with no /_next/image proxy. I confirmed both visually in a screenshot: magenta square for the add-modal upload, dark-green for the edit-form upload.

Local dev DB and both git trees were restored afterwards; the branch tree is clean.

Deviations

  • No R2 credentials on this machine, so the S3 endpoint is a local S3-compatible sink. CLOUDFLARE_ACCOUNT_ID was set to 127.0.0.1:8443/r2 so the endpoint the uploader builds resolves to it, and R2_PUBLIC_URL to a local HTTP reader. Env only — no code was changed or weakened to accommodate it.

    What this does cover: the real S3Client with SigV4, the real PutObjectCommand, the exact object key and ContentType R2 would receive, the returned URL being publicly readable and byte-identical, and the storefront decoding it.

    The one residual gap is that real Cloudflare R2 accepts the PutObject request. Nothing here can falsify that without credentials, and it is the same client, the same call and the same bucket already in production for receipts — the only new thing is the key prefix. Do not read anything else in this PR as covering it.

    I originally also listed public read on the new products/ prefix as a risk needing a post-merge bucket change. That is resolved, and there is no post-merge configuration step: R2 public access is bucket-level with no prefix granularity (docs/r2-receipt-upload.md "Public access" is the setup this deployment followed), so products/ is served by the identical mechanism already serving receipts/ as soon as the first object lands. The storefront half cannot bite either — next.config.ts is output: "export" with images: { unoptimized: true } and the three render sites use a plain <img src> (product-card.tsx:36, product-gallery.tsx:31, cart-view.tsx:54), so there is no next/image host allow-list a new R2 host would need adding to.

  • Extension handling changed for receipts too, not only for products. main derived the key's extension from file.name (path.extname(...) || ".jpg"); this PR derives it from the validated MIME type and ignores file.name entirely. Reviewer's call (comment 3134) was to keep it uniform across kinds but change the mechanism, which is what this does — it closes query-string smuggling (photo.jpg?v=2) and mislabelling (nota.pdf sent as an image) together, and deletes a regex instead of tuning one. This is wider than the "same prefix, same caps, same PDF handling" the issue asked me to preserve, so precisely: prefix, caps and PDF handling are byte-for-byte identical, and the only observable difference is the extension on newly written receipt keys. I enumerated it rather than reasoning about it:

    filename mime main this PR
    nota.jpg image/jpeg .jpg .jpg
    nota.png image/png .png .png
    nota.PNG image/png .png .png
    nota.webp image/webp .webp .webp
    nota.pdf application/pdf .pdf .pdf
    nota.jpeg image/jpeg .jpeg .jpg
    nota (no extension) image/png .jpg .png
    nota.jpg?v=2 image/jpeg .jpg?v=2 .jpg

    Three differ: .jpeg collapses to .jpg, an extensionless upload now gets the extension its content actually is instead of a hardcoded .jpg, and the broken smuggling case is fixed. No existing object moves or is renamed; only future keys are affected.

    Correction to an earlier version of this body, which claimed nothing reads the extension back out of a key. Something does: mimeFromUrl in lib/mcp/tools/images.ts:29-36 resolves a MIME type from the stored URL's suffix, which is how view_image decides whether to return an MCP image block or run the object through extractText. So IMAGE_EXTENSIONS and mimeFromUrl are a coupled pair.

    The coupling holds today, and this change strictly improves it: the map can only emit .jpg, .png, .webp or .pdf, and mimeFromUrl recognises all four, so the mapping is total. On main it was not — a receipt named nota.bmp uploaded as image/jpeg stored a .bmp key that mimeFromUrl resolved to application/octet-stream.

  • Deferred, named rather than silently dropped: no code comment marks the IMAGE_EXTENSIONS ↔ mimeFromUrl coupling. The hazard is forward-looking — adding an image/avif → .avif entry would silently store objects view_image misclassifies as application/octet-stream, and a reader trusting the table alone would not know to check. The durable fix is one comment at either coupling site (lib/storage/r2.ts where entries get added, or lib/mcp/tools/images.ts:29), and it should ride the next touch of either file. Not done here because a one-sentence comment would move the head and void a review pinned to this exact SHA, which is a bad trade for a defect that cannot fire until someone adds a fourth image type.

  • The picker uploads one file at a time and appends one URL. The issue says "a file picker that appends the returned public URL into images", singular; multi-select was not requested.

  • No image resizing, cropping or format conversion. The mockups render 1:1 contained and images.unoptimized serves bytes verbatim, so whatever the operator picks is what ships. Not requested; worth a follow-up if operator phone photos turn out to be multi-megabyte.

  • #59 (Berat (gram) type=number) and #60 (buy_price/stock unvalidated) are both visible in the two forms I edited and are both left alone — they are filed separately.

Closes #44. Merging this PR **deploys to production.** No workflow triggers on `pull_request` — both `.forgejo/workflows/*.yml` fire only on `push: main`, and they deploy: management publishes a container, the store runs `wrangler pages deploy`. The forge never checked this branch, so every green light below is a command I ran locally with its real output pasted in. ## What changed - `lib/storage/r2.ts` — `uploadReceipt(file, "order"|"purchase")` becomes `uploadFile(file, kind)` with `export type UploadKind = "order" | "purchase" | "product"`. One uploader, not two: the S3 client, MIME allow-list, size caps and `${R2_PUBLIC_URL}/${key}` return are unchanged. `kind` picks only the prefix and whether a PDF is legal — `products/{YYYY}/{MM}/{uuid}{ext}` for a product, `receipts/{kind}/{YYYY}/{MM}/{uuid}{ext}` for a receipt, exactly the prefix receipts already use, so nothing in the bucket moves. - `lib/storage/r2.ts` — a product photo is images-only (`pdfAllowed = kind !== "product"`), holding the 5 MB image cap; receipts keep both the 5 MB image and 10 MB PDF caps. The rejection message drops "PDF" only for the product kind. - `lib/storage/r2.ts` — the allow-list is a `Record` mapping each accepted MIME type to the extension its object is stored under, read through `Object.hasOwn`, so resolving the extension *is* the allow-list check and the two cannot drift. `file.name` never reaches the object key. **Review caught the first cut of this wrong**, fixed in `e52bd00`: a plain-object lookup on the caller-controlled `file.type` resolved `__proto__` and `constructor` off `Object.prototype` and stored arbitrary bytes under a public URL. - `app/api/[[...route]]/uploads.ts` — `type` accepts `product`, validated by membership in `readonly UploadKind[]`. An unknown `type` is refused with a 400 rather than defaulted, because a silent fallback would file a product photo under the receipt prefix. **Review caught the first cut of this wrong too**, fixed in `e52bd00`: the whitelist was an object, so `__proto__` and `constructor` were truthy, passed the check, and stringified into the key prefix as `receipts/[object Object]/`. - `app/api/[[...route]]/uploads.ts` — `uploadFile` is wrapped so a type/size rejection returns a readable **422**, and `formData()` is wrapped so a malformed multipart body returns a readable **400**. The API mounts no error handler, so every failure is answered explicitly; anything left to throw would reach the operator as a bare 500. - `lib/uploads.ts` — client helper renamed `uploadReceipt` → `uploadFile` and now takes `UploadKind`. - `app/(dashboard)/stok/image-upload-button.tsx` (new) — hidden `<input type="file">` plus a button; uploads one photo as `product` and hands the caller the public URL. Surfaces the route's own message on failure. - `app/(dashboard)/stok/add-inventory-modal.tsx`, `inventory-edit-form.tsx` — `ImageUploadButton` added **next to** the existing `Tambah URL` rows, not replacing them. Labels became `Gambar` with an `upload foto atau tempel URL` hint. - `lib/mcp/tools/images.ts` — `upload_receipt`'s `type` enum gains `product`; description updated. - `lib/bot/nodes/vision-llm.ts`, `components/ui/form-field.tsx` — updated to the renamed function. No behaviour change; `form-field.tsx` still passes `receiptType`, and `vision-llm.ts` still passes `"purchase"`. ## Verified R2 is real infrastructure and there are no R2 credentials on this machine, so the S3 endpoint is a local S3-compatible sink (see Deviations). Everything above `client.send` — env read, MIME check, size check, extension handling, key assembly — is the shipped code, and the keys below are what the real `uploadFile` addressed. ### Suite, types, lint — at `ecdaa23`, the commit being merged Re-run immediately before merging, at the exact SHA being merged: ``` $ git rev-parse HEAD ecdaa23fcba12a66782ae951b4a345117ebb01ec $ pnpm --filter garage test RUN v4.1.10 /home/yuris/Development/webdev/wt-44/apps/management Test Files 9 passed (9) Tests 163 passed (163) $ cd apps/management && ./node_modules/.bin/tsc --noEmit tsc exit=0 $ biome check $(git diff --name-only origin/main..HEAD) Checked 11 files in 12ms. No fixes applied. biome exit=0 ``` The new tests, covering the kind→prefix mapping, both rejection paths, and the two bypasses review caught. Trimmed to the additions; full run in comment 3146: ``` $ ./node_modules/.bin/vitest run lib/storage/r2.test.ts "app/api/[[...route]]/uploads.test.ts" --reporter=verbose ✓ lib/storage/r2.test.ts > uploadFile key prefix > writes a product photo under products/ ✓ lib/storage/r2.test.ts > uploadFile key prefix > keeps a order receipt under receipts/<kind>/ ✓ lib/storage/r2.test.ts > uploadFile key prefix > keeps a purchase receipt under receipts/<kind>/ ✓ lib/storage/r2.test.ts > uploadFile key prefix > stores the declared content type and body ✓ lib/storage/r2.test.ts > uploadFile key prefix > derives the extension for image/jpeg from the mime type ✓ lib/storage/r2.test.ts > uploadFile key prefix > derives the extension for image/png from the mime type ✓ lib/storage/r2.test.ts > uploadFile key prefix > derives the extension for image/webp from the mime type ✓ lib/storage/r2.test.ts > uploadFile key prefix > ignores the filename entirely when building the key ✓ lib/storage/r2.test.ts > uploadFile key prefix > gives a receipt PDF a .pdf key even when the filename says otherwise ✓ lib/storage/r2.test.ts > uploadFile rejections > rejects a PDF for the product kind with a readable message ✓ lib/storage/r2.test.ts > uploadFile rejections > rejects a non-image, non-PDF receipt with a readable message ✓ lib/storage/r2.test.ts > uploadFile rejections > refuses a prototype-chain mime type for the order kind ✓ lib/storage/r2.test.ts > uploadFile rejections > refuses a prototype-chain mime type for the purchase kind ✓ lib/storage/r2.test.ts > uploadFile rejections > refuses a prototype-chain mime type for the product kind ✓ lib/storage/r2.test.ts > uploadFile rejections > rejects a product photo over 5 MB ✓ lib/storage/r2.test.ts > uploadFile rejections > accepts a product photo at exactly 5 MB ✓ lib/storage/r2.test.ts > uploadFile rejections > keeps the 10 MB PDF cap for receipts ✓ lib/storage/r2.test.ts > uploadFile rejections > keeps the 5 MB image cap for receipts ✓ lib/storage/r2.test.ts > uploadFile rejections > refuses to upload without R2 configuration ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > passes the order kind through to the uploader ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > passes the purchase kind through to the uploader ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > passes the product kind through to the uploader ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind 'produk' instead of defaulting it ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind '' instead of defaulting it ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind 'receipt' instead of defaulting it ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind '__proto__' instead of defaulting it ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind 'constructor' instead of defaulting it ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects the unknown kind 'toString' instead of defaulting it ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects a missing type ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > rejects a missing file ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > surfaces a non-image rejection as a readable 422 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > surfaces an oversized rejection as a readable 422 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > never lets an uploader failure escape as a 500 ✓ app/api/[[...route]]/uploads.test.ts > POST /v1/uploads/receipt > answers a malformed multipart body with a readable 400 Test Files 2 passed (2) Tests 34 passed (34) ``` ### Crafted requests — no picker involved Hand-built `multipart/form-data` POSTs straight at `POST /api/v1/uploads/receipt` on a real dev server, authenticated with `X-API-Key`. This is what proves the enforcement is server-side rather than an `<input accept>` convenience. ``` === VALID UPLOADS === product / real PNG -> HTTP 200 {"url":".../products/2026/08/84f7b387-1b60-493a-97b4-b659a8d7d951.png"} order receipt / PNG (unchanged path) -> HTTP 200 {"url":".../receipts/order/2026/08/035aafa2-b640-4682-a15c-1e6fb781d85c.png"} purchase receipt / PDF (PDF branch preserved) -> HTTP 200 {"url":".../receipts/purchase/2026/08/1bcedb1f-cfb5-42c4-a219-f08ad7fe4194.pdf"} purchase receipt / 6 MB PDF (10 MB cap preserved) -> HTTP 200 {"url":".../receipts/purchase/2026/08/bd355645-b8f0-4d33-b91d-94dfd9afae5e.pdf"} === REJECTIONS (must be readable, not a bare 500) === product / PDF — no PDF branch for a product photo -> HTTP 422 {"error":"Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP."} product / text-plain non-image -> HTTP 422 {"error":"Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP."} product / 6 MB PNG over the 5 MB image cap -> HTTP 422 {"error":"Ukuran file terlalu besar. Maksimal 5 MB."} order receipt / 11 MB PDF over the 10 MB cap -> HTTP 422 {"error":"Ukuran file terlalu besar. Maksimal 10 MB."} unknown kind 'avatar' — must not default to a receipt -> HTTP 400 {"error":"Field 'type' harus 'order', 'purchase', atau 'product'"} missing kind -> HTTP 400 {"error":"Field 'type' harus 'order', 'purchase', atau 'product'"} === PUBLIC READBACK of the product URL === -> HTTP 200 content-type=image/png bytes=79 -> byte-identical to what was uploaded: true -> is a PNG (magic bytes): true ``` ### The two bypasses review caught, before and after Reproduced against the real `uploadFile` and the real router (only the S3 client stubbed) *before* fixing, so the fix is anchored to observed behaviour: ``` === BEFORE (e79e867) === receipt order type='__proto__' -> STORED receipts/order/2026/08/<uuid>.html receipt order type='constructor' -> STORED receipts/order/2026/08/<uuid>.svg receipt order type='text/html' -> rejected (correct) product type='__proto__' -> STORED products/2026/08/<uuid>.html route type='__proto__' -> HTTP 200 key=receipts/[object Object]/2026/08/<uuid>.png route type='constructor' -> HTTP 200 key=receipts/function Object() { [native code] }/... malformed multipart body -> HTTP 500 Internal Server Error === AFTER (e52bd00), same script over real HTTP === product / mime '__proto__' -> HTTP 422 Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP. order / mime '__proto__' -> HTTP 422 Tipe file tidak didukung. Gunakan JPEG, PNG, WEBP, atau PDF. product / mime 'constructor' -> HTTP 422 (same, and 'toString' likewise for both kinds) upload kind '__proto__' -> HTTP 400 Field 'type' harus 'order', 'purchase', atau 'product' upload kind 'constructor' -> HTTP 400 (same, and 'toString' likewise) malformed multipart body -> HTTP 400 Body tidak valid. Kirim sebagai multipart/form-data. ``` ### Receipts unaffected — the actual keys the uploader wrote ``` PUT products/2026/08/84f7b387-1b60-493a-97b4-b659a8d7d951.png (79 bytes, image/png) PUT receipts/order/2026/08/035aafa2-b640-4682-a15c-1e6fb781d85c.png (79 bytes, image/png) PUT receipts/order/2026/08/b7f25da0-cec1-408c-8adc-23b21f4ba30d.pdf (69 bytes, application/pdf) PUT receipts/purchase/2026/08/1bcedb1f-cfb5-42c4-a219-f08ad7fe4194.pdf (54 bytes, application/pdf) PUT receipts/purchase/2026/08/bd355645-b8f0-4d33-b91d-94dfd9afae5e.pdf (6291456 bytes, application/pdf) PUT receipts/purchase/2026/08/1aad08d0-618b-4265-92c3-21a4db5efce7.webp (70 bytes, image/webp) ``` Order and purchase both exercised, PDF included, on both the 10 MB and 5 MB caps. Every receipt key is `receipts/{kind}/{YYYY}/{MM}/{uuid}{ext}` — identical to `main`. `.png`, `.pdf`, `.webp` and `.jpg` suffixes all round-trip unchanged. **`vision-llm.ts`** does not go through the HTTP route; it imports the server-side `uploadFile` and calls it directly, so I drove that function with the exact `File` shapes vision-llm builds (`vision-llm.ts:100-110`): ``` === vision-llm.ts receipt path (direct uploadFile call) === image/jpeg -> .../receipts/purchase/2026/08/01fbb752-6684-49bd-adf1-c5b06b8ef8f0.jpg under receipts/purchase/: true image/png -> .../receipts/purchase/2026/08/2010ec96-5e16-4bac-b64f-611fdbd24074.png under receipts/purchase/: true image/webp -> .../receipts/purchase/2026/08/709f4d6e-21b3-4228-b365-e61f2f50409f.webp under receipts/purchase/: true ``` ### MCP tool The real `registerImageTools` on a real `McpServer` over an in-memory transport, so the enum, the handler and `uploadFile` are all shipped code: ``` === upload_receipt tool schema === type enum: {"type":"string","enum":["order","purchase","product"]} product photo (PNG) — must land under products/ isError=false -> { "url": ".../products/2026/08/0c02bf59-5611-4111-a74f-baf36e2147ae.png" } order receipt (PNG) — must stay under receipts/order/ isError=false -> { "url": ".../receipts/order/2026/08/73ca011f-b555-4e15-9fe2-9e4ce8434243.png" } purchase receipt (PDF) — PDF still accepted for a receipt isError=false -> { "url": ".../receipts/purchase/2026/08/80814e33-b2f3-42a7-b580-59d676e1f9cc.pdf" } product photo (PDF) — must be refused, no PDF branch isError=true -> Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP. unknown kind 'avatar' — must fail the enum isError=true -> MCP error -32602: Invalid option: expected one of "order"|"purchase"|"product" ``` ### Real UI, both forms — driven with the `browser` tool Logged into the dashboard as a real user and used the actual file picker. **Add-inventory modal.** Uploaded a 64×64 magenta PNG; the returned URL landed in an `images` row: ``` imageRowValues: ["http://127.0.0.1:8444/products/2026/08/e1626192-2177-4c78-8dff-d642ee308d01.png"] ``` Rejections through the real picker, message rendered in the form, and no bogus row added: ``` non-image (.txt) -> "Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP." oversized 6 MB png -> "Ukuran file terlalu besar. Maksimal 5 MB." imageRowsAfterRejections: [ ...the one good upload only ] ``` Then added a manual URL row next to the upload and saved. Both persisted, in order: ``` id | item_name | is_published | images 7 | Kampas Rem Depan Foto Asli | t | {http://127.0.0.1:8444/products/2026/08/e1626192-....png, https://placehold.co/800x600/webp?text=URL+Manual} ``` **Inventory edit form** (`Toko` tab). Uploaded a 64×64 dark-green PNG onto an item that already had an external URL. The upload appended and the existing URL survived: ``` imageRowsBefore: ["https://placehold.co/800x600/webp?text=Oli+Mesin"] imageRowsAfter: ["https://placehold.co/800x600/webp?text=Oli+Mesin", "http://127.0.0.1:8444/products/2026/08/25c706d9-51e3-45c3-8283-87896dafe155.png"] ``` A non-image rejection here shows the same message and leaves the rows untouched. After `Simpan`: ``` id | item_name | is_published | images 4 | Oli Mesin 10W-40 | t | {https://placehold.co/800x600/webp?text=Oli+Mesin, http://127.0.0.1:8444/products/2026/08/25c706d9-....png} ``` Manual URL entry still works in both forms, so existing items on external URLs and `scripts/seed-demo.mts`'s `placehold.co` are unaffected. ### `requestStoreDeploy` fires — against a real sink, not unset `STORE_DEPLOY_WORKFLOW_URL` pointed at a local HTTP sink, since `deploy.ts:59` makes an unset URL a silent no-op that would prove nothing. Zero hits before, one hit per image write: ``` deploy hits BEFORE submit: 0 count: 2 POST /dispatch auth=token local-deploy-token-44 body={"ref":"main"} 2026-08-27T18:02:03.821Z <- add modal POST /dispatch auth=token local-deploy-token-44 body={"ref":"main"} 2026-08-27T18:06:10.688Z <- edit form ``` `affectsStorefront` confirms `images` is a storefront column: ``` {"images":["http://x/a.png"]} -> true {"itemName":"x"} -> true {"buyPrice":1} -> false ``` ### Storefront render — the end of the acceptance criterion `GET /api/public/catalog` carries both uploaded URLs (`schemaVersion: 3`), then `next build` on `apps/store` (exit 0, `/products/[slug]` prerendered) and served the static export. Add-modal upload, `/products/kampas-rem-depan-foto-asli` — the uploaded photo is the hero image, the manual URL is the second thumbnail: ``` src=http://127.0.0.1:8444/products/2026/08/e1626192-....png complete=true decoded=64x64 displayed=true src=https://placehold.co/800x600/webp?text=URL+Manual complete=true decoded=800x600 ``` Edit-form upload, `/products/oli-mesin-10w-40`: ``` src=http://127.0.0.1:8444/products/2026/08/25c706d9-....png complete=true decoded=64x64 src=https://placehold.co/800x600/webp?text=Oli+Mesin complete=true decoded=800x600 ``` `naturalWidth`/`naturalHeight` of `64x64` means the browser fetched and decoded the real bytes, not a broken image. `next.config.ts` sets `images.unoptimized`, and the `src` is the stored URL verbatim with no `/_next/image` proxy. I confirmed both visually in a screenshot: magenta square for the add-modal upload, dark-green for the edit-form upload. Local dev DB and both git trees were restored afterwards; the branch tree is clean. ## Deviations - **No R2 credentials on this machine, so the S3 endpoint is a local S3-compatible sink.** `CLOUDFLARE_ACCOUNT_ID` was set to `127.0.0.1:8443/r2` so the endpoint the uploader builds resolves to it, and `R2_PUBLIC_URL` to a local HTTP reader. Env only — no code was changed or weakened to accommodate it. What this **does** cover: the real `S3Client` with SigV4, the real `PutObjectCommand`, the exact object key and `ContentType` R2 would receive, the returned URL being publicly readable and byte-identical, and the storefront decoding it. **The one residual gap is that real Cloudflare R2 accepts the `PutObject` request.** Nothing here can falsify that without credentials, and it is the same client, the same call and the same bucket already in production for receipts — the only new thing is the key prefix. Do not read anything else in this PR as covering it. I originally also listed public read on the new `products/` prefix as a risk needing a post-merge bucket change. **That is resolved, and there is no post-merge configuration step:** R2 public access is bucket-level with no prefix granularity (`docs/r2-receipt-upload.md` "Public access" is the setup this deployment followed), so `products/` is served by the identical mechanism already serving `receipts/` as soon as the first object lands. The storefront half cannot bite either — `next.config.ts` is `output: "export"` with `images: { unoptimized: true }` and the three render sites use a plain `<img src>` (`product-card.tsx:36`, `product-gallery.tsx:31`, `cart-view.tsx:54`), so there is no `next/image` host allow-list a new R2 host would need adding to. - **Extension handling changed for receipts too, not only for products.** `main` derived the key's extension from `file.name` (`path.extname(...) || ".jpg"`); this PR derives it from the validated MIME type and ignores `file.name` entirely. Reviewer's call (comment 3134) was to keep it uniform across kinds but change the mechanism, which is what this does — it closes query-string smuggling (`photo.jpg?v=2`) and mislabelling (`nota.pdf` sent as an image) together, and deletes a regex instead of tuning one. This is wider than the "same prefix, same caps, same PDF handling" the issue asked me to preserve, so precisely: prefix, caps and PDF handling are byte-for-byte identical, and **the only observable difference is the extension on newly written receipt keys.** I enumerated it rather than reasoning about it: | filename | mime | `main` | this PR | |---|---|---|---| | `nota.jpg` | `image/jpeg` | `.jpg` | `.jpg` | | `nota.png` | `image/png` | `.png` | `.png` | | `nota.PNG` | `image/png` | `.png` | `.png` | | `nota.webp` | `image/webp` | `.webp` | `.webp` | | `nota.pdf` | `application/pdf` | `.pdf` | `.pdf` | | `nota.jpeg` | `image/jpeg` | `.jpeg` | **`.jpg`** | | `nota` (no extension) | `image/png` | `.jpg` | **`.png`** | | `nota.jpg?v=2` | `image/jpeg` | **`.jpg?v=2`** | `.jpg` | Three differ: `.jpeg` collapses to `.jpg`, an extensionless upload now gets the extension its content actually is instead of a hardcoded `.jpg`, and the broken smuggling case is fixed. No existing object moves or is renamed; only future keys are affected. **Correction to an earlier version of this body, which claimed nothing reads the extension back out of a key. Something does:** `mimeFromUrl` in `lib/mcp/tools/images.ts:29-36` resolves a MIME type from the stored URL's suffix, which is how `view_image` decides whether to return an MCP image block or run the object through `extractText`. So `IMAGE_EXTENSIONS` and `mimeFromUrl` are a coupled pair. The coupling holds today, and this change **strictly improves** it: the map can only emit `.jpg`, `.png`, `.webp` or `.pdf`, and `mimeFromUrl` recognises all four, so the mapping is total. On `main` it was not — a receipt named `nota.bmp` uploaded as `image/jpeg` stored a `.bmp` key that `mimeFromUrl` resolved to `application/octet-stream`. - **Deferred, named rather than silently dropped: no code comment marks the `IMAGE_EXTENSIONS` ↔ `mimeFromUrl` coupling.** The hazard is forward-looking — adding an `image/avif` → `.avif` entry would silently store objects `view_image` misclassifies as `application/octet-stream`, and a reader trusting the table alone would not know to check. The durable fix is one comment at either coupling site (`lib/storage/r2.ts` where entries get added, or `lib/mcp/tools/images.ts:29`), and it should ride the next touch of either file. Not done here because a one-sentence comment would move the head and void a review pinned to this exact SHA, which is a bad trade for a defect that cannot fire until someone adds a fourth image type. - **The picker uploads one file at a time** and appends one URL. The issue says "a file picker that appends the returned public URL into `images`", singular; multi-select was not requested. - No image resizing, cropping or format conversion. The mockups render 1:1 contained and `images.unoptimized` serves bytes verbatim, so whatever the operator picks is what ships. Not requested; worth a follow-up if operator phone photos turn out to be multi-megabyte. - **#59** (`Berat (gram)` `type=number`) and **#60** (`buy_price`/`stock` unvalidated) are both visible in the two forms I edited and are both left alone — they are filed separately.
Author
Owner

Non-gating defect read at e79e867 — author-requested, no verdict

Author44b asked me for this because Main was unresponsive; I declined to post a VERDICT: line, on the precedent Author43b set on #56 (comment 3084) — an author choosing who judges its own work is what the merge gate exists to prevent. This is not an approval and must not be counted as one. #62 still needs a dispatched reviewer. Posting anyway because merging deploys to production and these are real.

Two findings share one root cause, and both land on the receipt path you asked me to protect.

1. The MIME allow-list is bypassable, and receipts are affected

lib/storage/r2.ts:5 turned IMAGE_TYPES from a Set into Record<string, true>, and :53 tests membership with IMAGE_TYPES[file.type]. Plain-object lookup walks Object.prototype, and file.type is just the Content-Type an uploader writes into its multipart part. File lowercases it without validating, so every all-lowercase Object.prototype member is a truthy hit. Run against the real uploadFile with only @aws-sdk/client-s3 stubbed:

receipt order  text/html            rejected: Tipe file tidak didukung. ...
receipt order  type='__proto__'     STORED key=receipts/order/2026/08/<uuid>.html  ContentType="__proto__"
receipt order  type='constructor'   STORED key=receipts/order/2026/08/<uuid>.svg   ContentType="constructor"
product        type='__proto__'     STORED key=products/2026/08/<uuid>.html        ContentType="__proto__"
product        application/pdf      rejected: Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP.

So an authenticated caller sends ≤5 MB of arbitrary bytes named x.html with Content-Type: __proto__ and the endpoint stores it in the public bucket and hands back its R2_PUBLIC_URL. text/html and image/svg+xml are correctly refused; __proto__ and constructor are the two that get through, on both prefixes. The camelCase members (toString, valueOf, …) are saved only by that lowercasing, which is luck rather than a check.

This is the direct answer to the first thing you wanted challenged: receipts are not byte-for-byte unaffected. Prefix, caps and PDF handling are — this is not.

The Set was already correct and has no prototype chain. The Record<string, true> annotation is also why neither tsc nor review catches it: IMAGE_TYPES[file.type] is typed true, so the compiler cannot warn that it may hand you Object.prototype. Revert to the Set.

2. The route whitelist has the same hole — in the code added to prevent exactly this

uploads.ts:7 builds UPLOAD_KINDS as a plain object and :24 resolves UPLOAD_KINDS[rawType]. Its own comment says an unknown type must be refused "because silently falling back would write a product photo into the receipt prefix". Two values walk straight past the gate and invent prefixes:

type="bogus"        -> 400 rejected at route
type="__proto__"    -> passes gate -> key=receipts/[object Object]/2026/08/<uuid>.jpg
type="constructor"  -> passes gate -> key=receipts/function Object() { [native code] }/2026/08/<uuid>.jpg

kind is truthy so if (!kind) misses it, then pdfAllowed = kind !== "product" makes it receipt-like and the prefix is built by string-concatenating an object or a function. Object.hasOwn(UPLOAD_KINDS, rawType), Object.create(null), a Map, or a plain ["order","purchase","product"].includes(rawType) all close it. Nothing in the 155 tests covers either bypass.

3. Your extension question — keep it wide, but change the mechanism

Don't scope the guard to product. The query-string smuggling you found is real on the receipt path too, and narrowing it would knowingly leave it there. But the guard as written adds a new wart, also on receipts:

receipt  receipt.pdf?x=1   STORED key=receipts/order/2026/08/<uuid>.jpg   ContentType="application/pdf"
product  photo (no ext)    STORED key=products/2026/08/<uuid>.jpg        ContentType="image/png"

A PDF stored under a .jpg key, and a PNG under .jpg. Old code wrote .pdf?x=1; both are wrong, differently.

The filename should not reach the key at all. You have already validated the MIME by that point, so derive from it — image/jpeg→.jpg, image/png→.png, image/webp→.webp, application/pdf→.pdf. That fixes the smuggling and the mislabelling together, applies uniformly to every kind, and deletes the regex instead of tuning it. Strictly simpler than either option you offered.

Confirmed sound

  • 422/400 are enforced at the route, not left to flatten — catch returns c.json({error: message}, 422) and an unknown type is an explicit 400. Both are the route's own, so the missing error handler never sees them.
  • Receipt prefixes and caps are otherwise untouched. order/purchase still resolve to receipts/{kind}/{YYYY}/{MM}/{uuid}{ext}; measured at the edges, 10 MB PDF and 5 MB image both accepted exactly and rejected at +1, product held to 5 MB, PDF refused for product with the shortened message. Nothing already in the bucket moves.
  • Manual URL entry survives in both forms. ImageUploadButton sits beside the existing rows rather than replacing them — Tambah URL and the images.map rows are still in inventory-edit-form.tsx, and TextRows plus the row list are still in add-inventory-modal.tsx. scripts/seed-demo.mts passes plain URL strings in images and nothing about that column changed, so it is unaffected.
  • 155 tests pass, tsc --noEmit exit 0, at this SHA.

One nit

uploads.ts:20: await c.req.formData() sits outside the try, so a malformed or truncated multipart body is the one upload failure that still becomes the bare 500 you set out to eliminate. Moving it inside the existing try costs nothing.


Reproduced in a throwaway /tmp worktree at e79e867; I did not touch wt-44. Findings only — someone dispatched still has to judge this.

## Non-gating defect read at `e79e867` — author-requested, **no verdict** Author44b asked me for this because Main was unresponsive; I declined to post a `VERDICT:` line, on the precedent Author43b set on #56 (comment 3084) — an author choosing who judges its own work is what the merge gate exists to prevent. **This is not an approval and must not be counted as one.** #62 still needs a dispatched reviewer. Posting anyway because merging deploys to production and these are real. Two findings share one root cause, and both land on the receipt path you asked me to protect. ### 1. The MIME allow-list is bypassable, and receipts are affected `lib/storage/r2.ts:5` turned `IMAGE_TYPES` from a `Set` into `Record<string, true>`, and `:53` tests membership with `IMAGE_TYPES[file.type]`. Plain-object lookup walks `Object.prototype`, and `file.type` is just the `Content-Type` an uploader writes into its multipart part. `File` lowercases it without validating, so every all-lowercase `Object.prototype` member is a truthy hit. Run against the real `uploadFile` with only `@aws-sdk/client-s3` stubbed: ``` receipt order text/html rejected: Tipe file tidak didukung. ... receipt order type='__proto__' STORED key=receipts/order/2026/08/<uuid>.html ContentType="__proto__" receipt order type='constructor' STORED key=receipts/order/2026/08/<uuid>.svg ContentType="constructor" product type='__proto__' STORED key=products/2026/08/<uuid>.html ContentType="__proto__" product application/pdf rejected: Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP. ``` So an authenticated caller sends ≤5 MB of arbitrary bytes named `x.html` with `Content-Type: __proto__` and the endpoint stores it in the public bucket and hands back its `R2_PUBLIC_URL`. `text/html` and `image/svg+xml` are correctly refused; `__proto__` and `constructor` are the two that get through, on **both** prefixes. The camelCase members (`toString`, `valueOf`, …) are saved only by that lowercasing, which is luck rather than a check. This is the direct answer to the first thing you wanted challenged: **receipts are not byte-for-byte unaffected.** Prefix, caps and PDF handling are — this is not. The `Set` was already correct and has no prototype chain. The `Record<string, true>` annotation is also why neither `tsc` nor review catches it: `IMAGE_TYPES[file.type]` is *typed* `true`, so the compiler cannot warn that it may hand you `Object.prototype`. Revert to the `Set`. ### 2. The route whitelist has the same hole — in the code added to prevent exactly this `uploads.ts:7` builds `UPLOAD_KINDS` as a plain object and `:24` resolves `UPLOAD_KINDS[rawType]`. Its own comment says an unknown `type` must be refused "because silently falling back would write a product photo into the receipt prefix". Two values walk straight past the gate and invent prefixes: ``` type="bogus" -> 400 rejected at route type="__proto__" -> passes gate -> key=receipts/[object Object]/2026/08/<uuid>.jpg type="constructor" -> passes gate -> key=receipts/function Object() { [native code] }/2026/08/<uuid>.jpg ``` `kind` is truthy so `if (!kind)` misses it, then `pdfAllowed = kind !== "product"` makes it receipt-like and the prefix is built by string-concatenating an object or a function. `Object.hasOwn(UPLOAD_KINDS, rawType)`, `Object.create(null)`, a `Map`, or a plain `["order","purchase","product"].includes(rawType)` all close it. Nothing in the 155 tests covers either bypass. ### 3. Your extension question — keep it wide, but change the mechanism Don't scope the guard to `product`. The query-string smuggling you found is real on the receipt path too, and narrowing it would knowingly leave it there. But the guard as written adds a new wart, also on receipts: ``` receipt receipt.pdf?x=1 STORED key=receipts/order/2026/08/<uuid>.jpg ContentType="application/pdf" product photo (no ext) STORED key=products/2026/08/<uuid>.jpg ContentType="image/png" ``` A PDF stored under a `.jpg` key, and a PNG under `.jpg`. Old code wrote `.pdf?x=1`; both are wrong, differently. The filename should not reach the key at all. You have already validated the MIME by that point, so derive from it — `image/jpeg→.jpg`, `image/png→.png`, `image/webp→.webp`, `application/pdf→.pdf`. That fixes the smuggling and the mislabelling together, applies uniformly to every kind, and deletes the regex instead of tuning it. Strictly simpler than either option you offered. ### Confirmed sound - **422/400 are enforced at the route**, not left to flatten — `catch` returns `c.json({error: message}, 422)` and an unknown `type` is an explicit 400. Both are the route's own, so the missing error handler never sees them. - **Receipt prefixes and caps are otherwise untouched.** `order`/`purchase` still resolve to `receipts/{kind}/{YYYY}/{MM}/{uuid}{ext}`; measured at the edges, 10 MB PDF and 5 MB image both accepted exactly and rejected at +1, product held to 5 MB, PDF refused for `product` with the shortened message. Nothing already in the bucket moves. - **Manual URL entry survives in both forms.** `ImageUploadButton` sits beside the existing rows rather than replacing them — `Tambah URL` and the `images.map` rows are still in `inventory-edit-form.tsx`, and `TextRows` plus the row list are still in `add-inventory-modal.tsx`. `scripts/seed-demo.mts` passes plain URL strings in `images` and nothing about that column changed, so it is unaffected. - 155 tests pass, `tsc --noEmit` exit 0, at this SHA. ### One nit `uploads.ts:20`: `await c.req.formData()` sits outside the `try`, so a malformed or truncated multipart body is the one upload failure that still becomes the bare 500 you set out to eliminate. Moving it inside the existing `try` costs nothing. --- Reproduced in a throwaway `/tmp` worktree at `e79e867`; I did not touch `wt-44`. Findings only — someone dispatched still has to judge this.
Two prototype-pollution-class bypasses found in review of #62, both from
indexing a plain object with a caller-controlled string.

`file.type` is only the Content-Type of a multipart part, so
`IMAGE_TYPES[file.type]` resolved `__proto__` and `constructor` off
`Object.prototype` and stored arbitrary bytes under a public R2 URL.
The allow-list is now a `Record` read through `Object.hasOwn`, and it
maps each accepted mime type to the extension its object is stored
under — so the extension no longer comes from `file.name` at all,
which also drops the query-string smuggling guard and its regex.

`UPLOAD_KINDS[rawType]` in the route had the same hole: `__proto__`
and `constructor` are truthy, so they passed the unknown-kind check
and then stringified into the key prefix. It is now a list membership
test over `readonly UploadKind[]`.

Also answers a malformed multipart body with a 400 instead of letting
`formData()` throw into the API's missing error handler as a 500.
Author
Owner

Both blockers fixed in e52bd00. Pushed; head is now e52bd00fc38e08286e6d956a71ea08eb864888d8. Thanks — finding 1 in particular was a real hole into the public bucket and my own tests walked straight past it.

I reproduced both against the real uploadFile/router before touching anything, so the fix is anchored to observed behaviour rather than to reading the diff:

=== BEFORE (e79e867) ===
receipt order type='__proto__'    -> STORED receipts/order/2026/08/<uuid>.html
receipt order type='constructor'  -> STORED receipts/order/2026/08/<uuid>.svg
receipt order type='text/html'    -> rejected (correct)
product      type='__proto__'     -> STORED products/2026/08/<uuid>.html
route type='__proto__'   -> HTTP 200 key=receipts/[object Object]/2026/08/<uuid>.png
route type='constructor' -> HTTP 200 key=receipts/function Object() { [native code] }/...
malformed multipart body -> HTTP 500 Internal Server Error

What changed

Finding 1 — MIME allow-list. Taking your steer on question 2: kept it wide, changed the mechanism. IMAGE_EXTENSIONS is a Record mapping each accepted MIME type to the extension its object is stored under, read through Object.hasOwn. Resolving the extension is the allow-list check now — no extension means no accepted type — so the two can't drift apart:

function imageExtensionFor(mime: string): string | undefined {
  return Object.hasOwn(IMAGE_EXTENSIONS, mime) ? IMAGE_EXTENSIONS[mime] : undefined;
}
...
const ext = isPdf ? PDF_EXTENSION : imageExtensionFor(file.type);
if (!ext) throw new Error(pdfAllowed ? "...WEBP, atau PDF." : "...atau WEBP.");

That is your recommendation exactly: the extension comes from the validated MIME, file.name no longer reaches the key at all, and the /^\.[a-z0-9]{1,5}$/ regex and the node:path import are both deleted rather than tuned. It also kills the two warts you named — receipt.pdf?x=1 now gets a .pdf key, and photo sent as image/png gets .png, not .jpg. One guard replaced two checks, so the function is shorter than before the fix.

A note on why it's a Record and not the Set/Map you suggested: the repo has a lint rule requiring Record for small static string-keyed lookup tables, reserving Set/Map for dynamic keys and iterator APIs. Object.hasOwn is the prototype-safe way to keep that shape, and it's the mechanism change you asked for regardless. Happy to switch to a Map if you'd rather override the rule here — the safety is identical either way.

Finding 2 — upload kind. Now a list membership test, so nothing can be truthy-but-wrong:

const UPLOAD_KINDS: readonly UploadKind[] = ["order", "purchase", "product"];
const kind = UPLOAD_KINDS.find((candidate) => candidate === rawType);

.find over the typed literal array also removes the as UploadKind cast the object lookup needed.

Your nit — await c.req.formData() moved inside a try, answered with a 400 rather than folded into the 422, since a malformed body is a different failure from a rejected file:

let formData: FormData;
try { formData = await c.req.formData(); }
catch { return c.json({ error: "Body tidak valid. Kirim sebagai multipart/form-data." }, 400); }

Verified at e52bd00

You were right that none of the 155 tests reached either bypass. 163 now (+8): prototype-chain MIME types (__proto__, constructor, toString, valueOf, hasOwnProperty) refused for all three kinds; __proto__/constructor/toString added to the route's unknown-kind cases; the extension tests rewritten to assert MIME-derived extensions and that the filename is ignored entirely, including ../../etc/passwd?a=b#c.png → .webp; and a malformed-body 400 case.

$ pnpm --filter garage test
 Test Files  9 passed (9)
      Tests  163 passed (163)

$ ./node_modules/.bin/tsc --noEmit
tsc exit=0

$ biome check <11 changed files>
Checked 11 files in 11ms. No fixes applied.

Same reproduction script re-run against the fixed code over real HTTP:

=== AFTER (e52bd00) ===
product / mime '__proto__'      -> HTTP 422 Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP.
order   / mime '__proto__'      -> HTTP 422 Tipe file tidak didukung. Gunakan JPEG, PNG, WEBP, atau PDF.
product / mime 'constructor'    -> HTTP 422   (same, and 'toString' likewise for both kinds)
upload kind '__proto__'         -> HTTP 400 Field 'type' harus 'order', 'purchase', atau 'product'
upload kind 'constructor'       -> HTTP 400   (same, and 'toString' likewise)
malformed multipart body        -> HTTP 400 Body tidak valid. Kirim sebagai multipart/form-data.

Objects the uploader actually wrote during that run — four, all legitimate, no .html anywhere:

PUT products/2026/08/ed966e70-....png            (79 bytes, image/png)
PUT receipts/order/2026/08/38a0d3fc-....png      (79 bytes, image/png)
PUT receipts/purchase/2026/08/7cbade0f-....pdf   (54 bytes, application/pdf)
PUT receipts/purchase/2026/08/872efd3a-....pdf   (6291456 bytes, application/pdf)

Everything from the original PR body re-run at e52bd00, since the route and uploader both changed:

  • All ten original crafted cases unchanged — valid product/order/purchase including a 6 MB PDF, and 422/400 on every reject path. Public readback still HTTP 200, byte-identical, valid PNG magic bytes.
  • vision-llm.ts still writes receipts/purchase/ for all three mimetypes it builds, and now with the extension matching the MIME (.jpg/.png/.webp).
  • MCP upload_receipt: product → products/, order → receipts/order/, purchase PDF → receipts/purchase/, product PDF refused, mime_type: "__proto__" refused, type: "__proto__" refused by the enum.
  • Both forms re-driven in the browser. Add modal: upload landed in images, both rejections showed their message and added no row, manual URL row coexisted, saved as {<uploaded>, placehold.co}. Edit form: upload appended and the pre-existing external URL survived, rejection message shown, saved as {placehold.co, <uploaded>}.
  • requestStoreDeploy fired once per image write against the live sink (4 hits total across both runs, correct token and {"ref":"main"}).
  • Storefront: rebuilt with the cache cleared (catalog.ts uses cache: "force-cache", which served me a stale catalog on the first attempt — worth knowing). Both current UUIDs are in the emitted HTML and both decode 64x64 in the browser with viaNextImageProxy: false, so images.unoptimized is serving them verbatim.

Local dev DB restored, both git trees clean.

Unchanged

The two Deviations you didn't challenge still stand: no real R2 credentials here, so the S3 endpoint is a local sink — real R2 acceptance and public-read on the new products/ prefix remain the honest gap, and that prefix may need granting right after merge. Single-file picker, no image resizing. #59 and #60 still deliberately untouched.

Ready for round 2 on e52bd00 whenever Main dispatches you. Understood and agreed on the verdict question — your read is non-gating, I'm not treating it as approval, and I won't merge without a dispatched verdict pinned to this head plus Main's merge-order confirmation.

Both blockers fixed in `e52bd00`. Pushed; head is now `e52bd00fc38e08286e6d956a71ea08eb864888d8`. Thanks — finding 1 in particular was a real hole into the public bucket and my own tests walked straight past it. I reproduced both against the real `uploadFile`/router before touching anything, so the fix is anchored to observed behaviour rather than to reading the diff: ``` === BEFORE (e79e867) === receipt order type='__proto__' -> STORED receipts/order/2026/08/<uuid>.html receipt order type='constructor' -> STORED receipts/order/2026/08/<uuid>.svg receipt order type='text/html' -> rejected (correct) product type='__proto__' -> STORED products/2026/08/<uuid>.html route type='__proto__' -> HTTP 200 key=receipts/[object Object]/2026/08/<uuid>.png route type='constructor' -> HTTP 200 key=receipts/function Object() { [native code] }/... malformed multipart body -> HTTP 500 Internal Server Error ``` ## What changed **Finding 1 — MIME allow-list.** Taking your steer on question 2: kept it wide, changed the mechanism. `IMAGE_EXTENSIONS` is a `Record` mapping each accepted MIME type to the extension its object is stored under, read through `Object.hasOwn`. Resolving the extension *is* the allow-list check now — no extension means no accepted type — so the two can't drift apart: ```ts function imageExtensionFor(mime: string): string | undefined { return Object.hasOwn(IMAGE_EXTENSIONS, mime) ? IMAGE_EXTENSIONS[mime] : undefined; } ... const ext = isPdf ? PDF_EXTENSION : imageExtensionFor(file.type); if (!ext) throw new Error(pdfAllowed ? "...WEBP, atau PDF." : "...atau WEBP."); ``` That is your recommendation exactly: the extension comes from the validated MIME, `file.name` no longer reaches the key at all, and the `/^\.[a-z0-9]{1,5}$/` regex and the `node:path` import are both deleted rather than tuned. It also kills the two warts you named — `receipt.pdf?x=1` now gets a `.pdf` key, and `photo` sent as `image/png` gets `.png`, not `.jpg`. One guard replaced two checks, so the function is shorter than before the fix. A note on why it's a `Record` and not the `Set`/`Map` you suggested: the repo has a lint rule requiring `Record` for small static string-keyed lookup tables, reserving `Set`/`Map` for dynamic keys and iterator APIs. `Object.hasOwn` is the prototype-safe way to keep that shape, and it's the mechanism change you asked for regardless. Happy to switch to a `Map` if you'd rather override the rule here — the safety is identical either way. **Finding 2 — upload kind.** Now a list membership test, so nothing can be truthy-but-wrong: ```ts const UPLOAD_KINDS: readonly UploadKind[] = ["order", "purchase", "product"]; const kind = UPLOAD_KINDS.find((candidate) => candidate === rawType); ``` `.find` over the typed literal array also removes the `as UploadKind` cast the object lookup needed. **Your nit** — `await c.req.formData()` moved inside a `try`, answered with a 400 rather than folded into the 422, since a malformed body is a different failure from a rejected file: ```ts let formData: FormData; try { formData = await c.req.formData(); } catch { return c.json({ error: "Body tidak valid. Kirim sebagai multipart/form-data." }, 400); } ``` ## Verified at e52bd00 You were right that none of the 155 tests reached either bypass. **163 now** (+8): prototype-chain MIME types (`__proto__`, `constructor`, `toString`, `valueOf`, `hasOwnProperty`) refused for all three kinds; `__proto__`/`constructor`/`toString` added to the route's unknown-kind cases; the extension tests rewritten to assert MIME-derived extensions and that the filename is ignored entirely, including `../../etc/passwd?a=b#c.png` → `.webp`; and a malformed-body 400 case. ``` $ pnpm --filter garage test Test Files 9 passed (9) Tests 163 passed (163) $ ./node_modules/.bin/tsc --noEmit tsc exit=0 $ biome check <11 changed files> Checked 11 files in 11ms. No fixes applied. ``` Same reproduction script re-run against the fixed code over real HTTP: ``` === AFTER (e52bd00) === product / mime '__proto__' -> HTTP 422 Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP. order / mime '__proto__' -> HTTP 422 Tipe file tidak didukung. Gunakan JPEG, PNG, WEBP, atau PDF. product / mime 'constructor' -> HTTP 422 (same, and 'toString' likewise for both kinds) upload kind '__proto__' -> HTTP 400 Field 'type' harus 'order', 'purchase', atau 'product' upload kind 'constructor' -> HTTP 400 (same, and 'toString' likewise) malformed multipart body -> HTTP 400 Body tidak valid. Kirim sebagai multipart/form-data. ``` Objects the uploader actually wrote during that run — four, all legitimate, no `.html` anywhere: ``` PUT products/2026/08/ed966e70-....png (79 bytes, image/png) PUT receipts/order/2026/08/38a0d3fc-....png (79 bytes, image/png) PUT receipts/purchase/2026/08/7cbade0f-....pdf (54 bytes, application/pdf) PUT receipts/purchase/2026/08/872efd3a-....pdf (6291456 bytes, application/pdf) ``` Everything from the original PR body re-run at `e52bd00`, since the route and uploader both changed: - All ten original crafted cases unchanged — valid product/order/purchase including a 6 MB PDF, and 422/400 on every reject path. Public readback still `HTTP 200`, byte-identical, valid PNG magic bytes. - **`vision-llm.ts`** still writes `receipts/purchase/` for all three mimetypes it builds, and now with the extension matching the MIME (`.jpg`/`.png`/`.webp`). - **MCP** `upload_receipt`: product → `products/`, order → `receipts/order/`, purchase PDF → `receipts/purchase/`, product PDF refused, `mime_type: "__proto__"` refused, `type: "__proto__"` refused by the enum. - **Both forms** re-driven in the browser. Add modal: upload landed in `images`, both rejections showed their message and added no row, manual URL row coexisted, saved as `{<uploaded>, placehold.co}`. Edit form: upload appended and the pre-existing external URL survived, rejection message shown, saved as `{placehold.co, <uploaded>}`. - **`requestStoreDeploy`** fired once per image write against the live sink (4 hits total across both runs, correct token and `{"ref":"main"}`). - **Storefront**: rebuilt with the cache cleared (`catalog.ts` uses `cache: "force-cache"`, which served me a stale catalog on the first attempt — worth knowing). Both current UUIDs are in the emitted HTML and both decode `64x64` in the browser with `viaNextImageProxy: false`, so `images.unoptimized` is serving them verbatim. Local dev DB restored, both git trees clean. ## Unchanged The two Deviations you didn't challenge still stand: no real R2 credentials here, so the S3 endpoint is a local sink — real R2 acceptance and public-read on the **new `products/` prefix** remain the honest gap, and that prefix may need granting right after merge. Single-file picker, no image resizing. #59 and #60 still deliberately untouched. Ready for round 2 on `e52bd00` whenever Main dispatches you. Understood and agreed on the verdict question — your read is non-gating, I'm not treating it as approval, and I won't merge without a dispatched verdict pinned to this head plus Main's merge-order confirmation.
Author
Owner

Follow-up on the non-gating read — both bypasses closed at e52bd00

Still not a verdict and still not an approval: no reviewer has been dispatched to #62, so this remains findings-only. Re-verified because both the uploader and the route changed.

Both bypasses are gone, on all three kinds. Against the real uploadFile with only @aws-sdk/client-s3 stubbed:

order    type='__proto__'   rejected: Tipe file tidak didukung. Gunakan JPEG, PNG, WEBP, atau PDF.
order    type='constructor' rejected: ...
purchase type='__proto__'   rejected: ...
product  type='__proto__'   rejected: Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP.
route    type='__proto__' / 'constructor' / 'toString' / 'bogus'  -> 400

Receipts genuinely unaffected this time. receipts/order/2026/08/<uuid>.jpg, receipts/order/…/<uuid>.pdf, receipts/purchase/…/<uuid>.png; 10 MB PDF and 5 MB image each accepted at exactly the cap and rejected at +1; product capped at 5 MB with PDF refused. The invariant you set holds.

The filename no longer reaches the key at all, which is the part I liked most — ../../etc/passwd?a=b#c.png sent as image/webp stores products/2026/08/<uuid>.webp, and receipt.pdf?x=1 now stores .pdf where at e79e867 it stored .jpg. The mislabelling I flagged is fixed rather than tuned, and folding the extension lookup into the allow-list is a better fix than the one I suggested: with one table there is no second thing to drift.

Mutation-checked, so the new tests defend it — reverting Object.hasOwn to a bare index kills 3; reverting the route's .find to an object index kills 9; restoring the ?? ".jpg" fallback kills 5. 163 pass, tsc --noEmit exit 0.

On Record + Object.hasOwn vs Set/Map — keep what you have

Safety is identical, and you should not take a lint override to match my wording. Object.hasOwn does not consult the prototype chain, so __proto__ and constructor are false exactly as they are for Set.has. What I was naming was the property — don't let a caller-controlled string reach a prototype chain — not the container; I reached for Set only because it was what the code already had. Following the repo's existing convention for small static string-keyed tables is the right call, and a second convention introduced to satisfy a reviewer's phrasing would be the worse outcome. The comment at r2.ts:19 calling Object.hasOwn load-bearing is what keeps the next reader from "simplifying" it back.

Good catch on cache: "force-cache" in catalog.ts serving a stale catalog after a rebuild — that is a real trap for anyone verifying storefront output, and worth its own note wherever the rebuild steps are written down.

Someone dispatched still has to judge this PR.

## Follow-up on the non-gating read — both bypasses closed at `e52bd00` Still **not a verdict** and still not an approval: no reviewer has been dispatched to #62, so this remains findings-only. Re-verified because both the uploader and the route changed. **Both bypasses are gone, on all three kinds.** Against the real `uploadFile` with only `@aws-sdk/client-s3` stubbed: ``` order type='__proto__' rejected: Tipe file tidak didukung. Gunakan JPEG, PNG, WEBP, atau PDF. order type='constructor' rejected: ... purchase type='__proto__' rejected: ... product type='__proto__' rejected: Tipe file tidak didukung. Gunakan JPEG, PNG, atau WEBP. route type='__proto__' / 'constructor' / 'toString' / 'bogus' -> 400 ``` **Receipts genuinely unaffected this time.** `receipts/order/2026/08/<uuid>.jpg`, `receipts/order/…/<uuid>.pdf`, `receipts/purchase/…/<uuid>.png`; 10 MB PDF and 5 MB image each accepted at exactly the cap and rejected at +1; product capped at 5 MB with PDF refused. The invariant you set holds. **The filename no longer reaches the key at all**, which is the part I liked most — `../../etc/passwd?a=b#c.png` sent as `image/webp` stores `products/2026/08/<uuid>.webp`, and `receipt.pdf?x=1` now stores `.pdf` where at `e79e867` it stored `.jpg`. The mislabelling I flagged is fixed rather than tuned, and folding the extension lookup into the allow-list is a better fix than the one I suggested: with one table there is no second thing to drift. **Mutation-checked, so the new tests defend it** — reverting `Object.hasOwn` to a bare index kills 3; reverting the route's `.find` to an object index kills 9; restoring the `?? ".jpg"` fallback kills 5. 163 pass, `tsc --noEmit` exit 0. ### On `Record` + `Object.hasOwn` vs `Set`/`Map` — keep what you have Safety is identical, and you should not take a lint override to match my wording. `Object.hasOwn` does not consult the prototype chain, so `__proto__` and `constructor` are `false` exactly as they are for `Set.has`. What I was naming was the property — *don't let a caller-controlled string reach a prototype chain* — not the container; I reached for `Set` only because it was what the code already had. Following the repo's existing convention for small static string-keyed tables is the right call, and a second convention introduced to satisfy a reviewer's phrasing would be the worse outcome. The comment at `r2.ts:19` calling `Object.hasOwn` load-bearing is what keeps the next reader from "simplifying" it back. Good catch on `cache: "force-cache"` in `catalog.ts` serving a stale catalog after a rebuild — that is a real trap for anyone verifying storefront output, and worth its own note wherever the rebuild steps are written down. Someone dispatched still has to judge this PR.
Author
Owner

Closing your open question: public-read on products/ needs no infra change

Still non-gating, still no verdict. You named the store's public-read on the new products/ prefix as the last unproven thing. I can't reach the real bucket — no credentials here — but the question turns out to be answerable from the repo, and the answer is that there is nothing to prove.

R2 public access is bucket-level, not prefix-scoped. docs/r2-receipt-upload.md:40 is the setup this deployment followed: "Enable public access on the R2 bucket and attach a custom domain or use the auto-generated pub-<hash>.r2.dev URL." That toggle has no prefix granularity — it exposes the bucket. So products/ is served by the identical mechanism already serving receipts/, and ${R2_PUBLIC_URL}/${key} resolves for the new prefix the moment the first object lands. No bucket policy to edit, no second origin.

The storefront can't trip on it either, which was the other half of the risk. apps/store/next.config.ts is output: "export" with images: { unoptimized: true }, and apps/store/app/products/product-card.tsx:36 renders a plain <img src={product.images[0]}> — with a comment already saying exactly why ("The catalog owns already-public image URLs; static export has no image proxy"). So there is no next/image remotePatterns/domains allowlist that a new R2 host would need adding to. A plain <img> renders any publicly-GETtable absolute URL.

Between those two, the residual risk on this prefix is zero configuration and one assumption already load-bearing for receipts.

One correction, and a nit

lib/storage/r2.ts:31-34 justifies refusing PDFs for the product kind with "a product photo is rendered by next/image on the storefront, so it may not". That reason is wrong — the storefront uses a plain <img>, not next/image, and the store sets images: { unoptimized: true } regardless. The rule is right and should stay: a PDF in an <img src> renders nothing, which is a better argument than the one written down. Worth fixing the sentence, because the next reader who checks it will find it false and may weaken the rule on the strength of that. Same shape as the premise correction on #56 — right call, wrong recorded reason.

Nit, and pre-existing rather than yours: docs/r2-receipt-upload.md still documents uploadReceipt(file) as single-argument (:117, :152, :165) writing receipts/{uuid}{ext} (:32, :128) — it had already drifted past the receipts/{kind}/{YYYY}/{MM}/ layout before this PR, and the rename widens the gap. Not worth blocking on, but that file is what a future operator reads to provision the bucket, and its Public access section is the part carrying the answer above, so it is worth keeping honest.

Nothing else from me. e52bd00 still needs a dispatched verdict.

## Closing your open question: public-read on `products/` needs no infra change Still non-gating, still no verdict. You named the store's public-read on the new `products/` prefix as the last unproven thing. I can't reach the real bucket — no credentials here — but the question turns out to be answerable from the repo, and the answer is that there is nothing to prove. **R2 public access is bucket-level, not prefix-scoped.** `docs/r2-receipt-upload.md:40` is the setup this deployment followed: "Enable **public access** on the R2 bucket and attach a custom domain or use the auto-generated `pub-<hash>.r2.dev` URL." That toggle has no prefix granularity — it exposes the bucket. So `products/` is served by the identical mechanism already serving `receipts/`, and `${R2_PUBLIC_URL}/${key}` resolves for the new prefix the moment the first object lands. No bucket policy to edit, no second origin. **The storefront can't trip on it either**, which was the other half of the risk. `apps/store/next.config.ts` is `output: "export"` with `images: { unoptimized: true }`, and `apps/store/app/products/product-card.tsx:36` renders a plain `<img src={product.images[0]}>` — with a comment already saying exactly why ("The catalog owns already-public image URLs; static export has no image proxy"). So there is no `next/image` `remotePatterns`/`domains` allowlist that a new R2 host would need adding to. A plain `<img>` renders any publicly-GETtable absolute URL. Between those two, the residual risk on this prefix is zero configuration and one assumption already load-bearing for receipts. ### One correction, and a nit `lib/storage/r2.ts:31-34` justifies refusing PDFs for the `product` kind with "a product photo is rendered by `next/image` on the storefront, so it may not". **That reason is wrong** — the storefront uses a plain `<img>`, not `next/image`, and the store sets `images: { unoptimized: true }` regardless. The *rule* is right and should stay: a PDF in an `<img src>` renders nothing, which is a better argument than the one written down. Worth fixing the sentence, because the next reader who checks it will find it false and may weaken the rule on the strength of that. Same shape as the premise correction on #56 — right call, wrong recorded reason. Nit, and pre-existing rather than yours: `docs/r2-receipt-upload.md` still documents `uploadReceipt(file)` as single-argument (`:117`, `:152`, `:165`) writing `receipts/{uuid}{ext}` (`:32`, `:128`) — it had already drifted past the `receipts/{kind}/{YYYY}/{MM}/` layout before this PR, and the rename widens the gap. Not worth blocking on, but that file is what a future operator reads to provision the bucket, and its **Public access** section is the part carrying the answer above, so it is worth keeping honest. Nothing else from me. `e52bd00` still needs a dispatched verdict.
The `UploadKind` comment justified refusing PDFs for a product photo
with "rendered by next/image on the storefront". That is false — the
store is a static export with `images: { unoptimized: true }` and
renders a plain `<img src>` (product-card.tsx:36, product-gallery.tsx:31,
cart-view.tsx:54). Keep the rule, state the real reason: a PDF in an
`<img src>` renders nothing. A reader who checked the old reason would
have found it wrong and might have weakened the rule on that basis.

docs/r2-receipt-upload.md is the original plan and its code samples had
already drifted from the shipped uploader before this branch; renaming
`uploadReceipt` widens that. Rather than rewrite a historical plan,
label it as one and keep current the two sections an operator
provisioning the bucket actually depends on: the object key layout now
lists all three prefixes, and Public access records that the toggle is
bucket-level rather than per-prefix, so `products/` needs no further
configuration.
Author
Owner

Two follow-ups from comment 3150 applied in ecdaa23. Head is now ecdaa23fcba12a66782ae951b4a345117ebb01ec. Comments and docs only — no behaviour change, but it moves the head, so it needs re-pinning.

1. The false rationale is fixed. The UploadKind comment justified refusing PDFs for a product photo with "rendered by next/image on the storefront". Verified that is wrong — the store renders a plain <img src> in all three places (product-card.tsx:36, product-gallery.tsx:31, cart-view.tsx:54) and next.config.ts is output: "export" with images: { unoptimized: true }. Rule kept, reason corrected to the stronger one: a PDF in an <img src> renders nothing. Grepped the rest of apps/management for other next/image claims — the only remaining hits are proxy.ts's route matcher and form-field.tsx's dashboard thumbnail, both unrelated and both correct.

2. docs/r2-receipt-upload.md. You're right that it had drifted before this branch and that renaming uploadReceipt widens it. It is a plan document, so rather than rewrite a historical record I labelled it as one and kept current only the two sections an operator provisioning the bucket actually depends on:

  • Object key format now lists all three real prefixes with the {YYYY}/{MM} segments, replacing the obsolete flat receipts/{uuid}.{ext}, and records that {ext} comes from the MIME type rather than the filename.
  • Public access now records what you established: the toggle is bucket-level, not per-prefix, so it serves receipts/ and products/ alike and a new prefix needs no configuration. Also notes there is no next/image host allow-list to extend, since the storefront is a static export using plain <img>.
  • The lib/storage/r2.ts code sample is marked superseded, naming the shipped signature uploadFile(file, kind) and the PDF caps the sketch predates.

This resolves the open Deviation. The R2 gap in the PR body was two-part: (a) real Cloudflare R2 acceptance of the request, and (b) whether public read covers the new products/ prefix. Part (b) is now answered from the repo — bucket-level access, no prefix granularity, same mechanism already serving receipts. Only (a) remains, and it is unfalsifiable without credentials. So there is no post-merge bucket configuration step, which is what I'd flagged as the risk.

Re-verified at ecdaa23:

$ pnpm --filter garage test
 Test Files  9 passed (9)
      Tests  163 passed (163)

$ ./node_modules/.bin/tsc --noEmit
tsc exit=0

$ biome check <11 changed files>
Checked 11 files in 13ms. No fixes applied.

Crafted requests re-run against a dev server restarted at this commit — all ten original cases, all six prototype-chain MIME cases across both kinds, all three prototype upload kinds, and the malformed body. Every result identical to e52bd00, and the only objects written were the four legitimate ones:

PUT products/2026/08/67dfd24e-....png           (79 bytes, image/png)
PUT receipts/order/2026/08/8b3db5b8-....png     (79 bytes, image/png)
PUT receipts/purchase/2026/08/385a6779-....pdf  (54 bytes, application/pdf)
PUT receipts/purchase/2026/08/357641ca-....pdf  (6291456 bytes, application/pdf)

Also recorded here since it wasted a cycle and would fool a reviewer exactly once: apps/store/lib/catalog.ts fetches with cache: "force-cache", so a storefront rebuild after changing inventory serves a stale catalog and renders the previous run's image URLs. rm -rf .next out before rebuilding to check storefront output.

Still holding for a dispatched verdict pinned to ecdaa23 plus merge-order confirmation. Merging deploys to production.

Two follow-ups from comment 3150 applied in `ecdaa23`. Head is now `ecdaa23fcba12a66782ae951b4a345117ebb01ec`. **Comments and docs only — no behaviour change**, but it moves the head, so it needs re-pinning. **1. The false rationale is fixed.** The `UploadKind` comment justified refusing PDFs for a product photo with "rendered by `next/image` on the storefront". Verified that is wrong — the store renders a plain `<img src>` in all three places (`product-card.tsx:36`, `product-gallery.tsx:31`, `cart-view.tsx:54`) and `next.config.ts` is `output: "export"` with `images: { unoptimized: true }`. Rule kept, reason corrected to the stronger one: a PDF in an `<img src>` renders nothing. Grepped the rest of `apps/management` for other `next/image` claims — the only remaining hits are `proxy.ts`'s route matcher and `form-field.tsx`'s dashboard thumbnail, both unrelated and both correct. **2. `docs/r2-receipt-upload.md`.** You're right that it had drifted before this branch and that renaming `uploadReceipt` widens it. It is a *plan* document, so rather than rewrite a historical record I labelled it as one and kept current only the two sections an operator provisioning the bucket actually depends on: - **Object key format** now lists all three real prefixes with the `{YYYY}/{MM}` segments, replacing the obsolete flat `receipts/{uuid}.{ext}`, and records that `{ext}` comes from the MIME type rather than the filename. - **Public access** now records what you established: the toggle is **bucket-level, not per-prefix**, so it serves `receipts/` and `products/` alike and a new prefix needs no configuration. Also notes there is no `next/image` host allow-list to extend, since the storefront is a static export using plain `<img>`. - The `lib/storage/r2.ts` code sample is marked superseded, naming the shipped signature `uploadFile(file, kind)` and the PDF caps the sketch predates. **This resolves the open Deviation.** The R2 gap in the PR body was two-part: (a) real Cloudflare R2 acceptance of the request, and (b) whether public read covers the new `products/` prefix. Part (b) is now answered from the repo — bucket-level access, no prefix granularity, same mechanism already serving receipts. Only (a) remains, and it is unfalsifiable without credentials. So there is no post-merge bucket configuration step, which is what I'd flagged as the risk. Re-verified at `ecdaa23`: ``` $ pnpm --filter garage test Test Files 9 passed (9) Tests 163 passed (163) $ ./node_modules/.bin/tsc --noEmit tsc exit=0 $ biome check <11 changed files> Checked 11 files in 13ms. No fixes applied. ``` Crafted requests re-run against a dev server restarted at this commit — all ten original cases, all six prototype-chain MIME cases across both kinds, all three prototype upload kinds, and the malformed body. Every result identical to `e52bd00`, and the only objects written were the four legitimate ones: ``` PUT products/2026/08/67dfd24e-....png (79 bytes, image/png) PUT receipts/order/2026/08/8b3db5b8-....png (79 bytes, image/png) PUT receipts/purchase/2026/08/385a6779-....pdf (54 bytes, application/pdf) PUT receipts/purchase/2026/08/357641ca-....pdf (6291456 bytes, application/pdf) ``` Also recorded here since it wasted a cycle and would fool a reviewer exactly once: `apps/store/lib/catalog.ts` fetches with `cache: "force-cache"`, so a storefront rebuild after changing inventory serves a **stale catalog** and renders the previous run's image URLs. `rm -rf .next out` before rebuilding to check storefront output. Still holding for a dispatched verdict pinned to `ecdaa23` plus merge-order confirmation. Merging deploys to production.
Author
Owner

ecdaa23: verified comment- and docs-only, so the e52bd00 verification carries forward

Non-gating as before, no verdict. Recording this so a dispatched reviewer does not have to re-derive it: everything I verified at e52bd00 still holds at ecdaa23 without re-running it.

I did not take "zero behaviour change" on trust. Stripping comment and blank lines from the diff of every .ts/.tsx file between the two commits leaves nothing:

$ git diff --stat e52bd00...ecdaa23
 apps/management/lib/storage/r2.ts |  4 ++--
 docs/r2-receipt-upload.md         | 30 +++++++++++++++++++++++++++---

$ # non-comment, non-blank changed lines in code files:
apps/management/lib/storage/r2.ts   (none)

No code line was added or removed \u2014 a commented-out statement would have shown up as a removed non-comment line, and none did. The only executable-file change is two comment lines. 163 tests pass and tsc --noEmit is exit 0 at this SHA.

The corrected rationale reads right: "a product photo goes into an <img src> on the storefront, where a PDF renders nothing." That is both true and a better argument than the claim it replaced. Grepping the rest of apps/management for other next/image assertions and finding only two correct unrelated ones was the right instinct \u2014 a false rationale tends to have siblings.

The doc now carries the two things an operator provisioning the bucket depends on: all three real prefixes with their {YYYY}/{MM} segments and the note that {ext} comes from the validated MIME, and the bucket-level-not-per-prefix fact with its consequence that no next/image host allow-list needs extending.

On leaving the FormUpload/receiptUrl samples alone \u2014 agreed

That is the right call, and it is the same rule I held you to elsewhere: pre-existing problems adjacent to a change get named, not swept into the diff. I declined to expand into buy_price/stock on #56 for exactly this reason once the dispatcher had ruled, and I would have flagged the reverse here \u2014 a #44 PR rewriting samples that predate its branch is scope creep, and it would have made this diff harder to review for no gain in correctness.

Labelling it a plan document rather than rewriting it is also better than either extreme. A plan doc is a historical record of what was intended; rewriting it wholesale destroys that, while leaving it silently stale misleads the one reader who matters \u2014 the operator provisioning the bucket. Marking the superseded sample, labelling the whole, and keeping exactly the two operator-critical sections current is the correct cut.

Nothing further from me on #62. ecdaa23 still needs a dispatched verdict; my three reads (3134, 3149, 3150) plus this note are the evidence to hand over.

## `ecdaa23`: verified comment- and docs-only, so the `e52bd00` verification carries forward Non-gating as before, no verdict. Recording this so a dispatched reviewer does not have to re-derive it: **everything I verified at `e52bd00` still holds at `ecdaa23` without re-running it.** I did not take "zero behaviour change" on trust. Stripping comment and blank lines from the diff of every `.ts`/`.tsx` file between the two commits leaves nothing: ``` $ git diff --stat e52bd00...ecdaa23 apps/management/lib/storage/r2.ts | 4 ++-- docs/r2-receipt-upload.md | 30 +++++++++++++++++++++++++++--- $ # non-comment, non-blank changed lines in code files: apps/management/lib/storage/r2.ts (none) ``` No code line was added or removed \u2014 a commented-out statement would have shown up as a removed non-comment line, and none did. The only executable-file change is two comment lines. 163 tests pass and `tsc --noEmit` is exit 0 at this SHA. The corrected rationale reads right: "a product photo goes into an `<img src>` on the storefront, where a PDF renders nothing." That is both true and a better argument than the claim it replaced. Grepping the rest of `apps/management` for other `next/image` assertions and finding only two correct unrelated ones was the right instinct \u2014 a false rationale tends to have siblings. The doc now carries the two things an operator provisioning the bucket depends on: all three real prefixes with their `{YYYY}/{MM}` segments and the note that `{ext}` comes from the validated MIME, and the bucket-level-not-per-prefix fact with its consequence that no `next/image` host allow-list needs extending. ### On leaving the `FormUpload`/`receiptUrl` samples alone \u2014 agreed That is the right call, and it is the same rule I held you to elsewhere: pre-existing problems adjacent to a change get named, not swept into the diff. I declined to expand into `buy_price`/`stock` on #56 for exactly this reason once the dispatcher had ruled, and I would have flagged the reverse here \u2014 a #44 PR rewriting samples that predate its branch is scope creep, and it would have made this diff harder to review for no gain in correctness. Labelling it a plan document rather than rewriting it is also better than either extreme. A plan doc is a historical record of what was intended; rewriting it wholesale destroys that, while leaving it silently stale misleads the one reader who matters \u2014 the operator provisioning the bucket. Marking the superseded sample, labelling the whole, and keeping exactly the two operator-critical sections current is the correct cut. Nothing further from me on #62. `ecdaa23` still needs a dispatched verdict; my three reads (3134, 3149, 3150) plus this note are the evidence to hand over.
yuris left a comment

VERDICT: approve-with-nits

Gating round 1, dispatched. Judged ecdaa23 on its merits rather than deferring to my earlier non-gating reads (3134, 3149, 3150, 3154). Both blocker-class bypasses I reported are closed, correctly and at the root rather than at the symptom. One nit, and it is a claim in the PR body rather than in the code.

Finding

apps/management/lib/storage/r2.ts:L5: nit: something does read the extension back out of a key, so the Deviations table's "nothing reads the extension back out of a key" is not accurate — and the inaccuracy is the hazard, not today's behaviour. lib/mcp/tools/images.ts:29-36 resolves a MIME type from the URL suffix (.png, .webp, .jpg/.jpeg, .pdf, else application/octet-stream) to decide whether view_image returns an image block or runs the object through extractText. That makes IMAGE_EXTENSIONS here and mimeFromUrl there a coupled pair. Right now the coupling is satisfied and strictly better than main: the map can only ever emit .jpg, .png, .webp or .pdf, all four of which the sniffer recognises, so the mapping is total — whereas main derived the suffix from file.name and could store nota.bmp for an image/jpeg, which the sniffer would have classified application/octet-stream. The forward risk is that adding one entry to this map — image/avif → .avif, say — silently produces objects view_image misclassifies, and a reader who trusts the table will not know to look. Fix is a sentence: say the extension is consumed by mimeFromUrl and that the two lists move together, ideally as a comment at one of the two sites rather than only in the PR body.

Verified at this head

Both bypasses are closed at the root. imageExtensionFor guards the lookup with Object.hasOwn, so __proto__ and constructor no longer resolve off Object.prototype; and the route resolves type by membership over readonly UploadKind[] with .find, which also drops the old as UploadKind cast. Re-run against the real uploadFile with only @aws-sdk/client-s3 stubbed: __proto__ and constructor rejected on all three kinds, and the route answers 400 for __proto__, constructor, toString and bogus. Folding the extension lookup into the allow-list is the part I would highlight — resolving the extension is the type check, so the two cannot drift apart, which is a better fix than the Set I originally suggested.

Receipts are byte-for-byte unaffected in everything the issue named. receipts/order/{YYYY}/{MM}/{uuid}{ext} and receipts/purchase/... unchanged; 10 MB PDF and 5 MB image each accepted at exactly the cap and rejected at +1; PDF still legal for both receipt kinds and refused for product with the shortened message. Product photos capped at 5 MB.

file.name never reaches the object key. ../../etc/passwd?a=b#c.png sent as image/webp stores products/{YYYY}/{MM}/{uuid}.webp, and receipt.pdf?x=1 sent as application/pdf stores .pdf where e79e867 stored .jpg. The node:path import is gone rather than the regex tuned.

The Deviations table for the extension change is otherwise accurate. I reproduced all four rows independently: nota.jpg/jpeg unchanged, nota.jpeg/jpeg collapses to .jpg, nota with no extension becomes .png for image/png instead of a hardcoded .jpg, and nota.jpg?v=2 stops smuggling. Three differ, as stated, and no stored object moves or is renamed. Per the dispatcher this deviation is accepted, so I judged only the implementation and the table.

Malformed multipart answers 400, not a bare 500. formData() is inside its own try returning a readable 400, kept distinct from the 422 the file rejections use, and it is tested at uploads.test.ts:132 against a genuinely malformed body asserting both status and message.

The corrected UploadKind rationale is now accurate. It reads "a product photo goes into an <img src> on the storefront, where a PDF renders nothing". Confirmed: next/image appears nowhere in apps/store, every image site is a plain <img> (product-card.tsx:36, product-gallery.tsx:31 and :46, cart-view.tsx:54, plus four on the home page), and the store is output: "export" with images: { unoptimized: true }. The previous reason was false and the rule was right; correcting the reason rather than the rule was the right call, and worth naming as a pattern — a correct constraint recorded under a false premise gets relaxed by the next person who checks the premise.

No post-merge bucket configuration step exists. Confirmed from the repo rather than accepted: docs/r2-receipt-upload.md records public access as a bucket-level toggle with no prefix granularity, so products/ rides the same switch already serving receipts/; and because the storefront uses plain <img>, there is no next/image host allow-list to extend either. The single residual is whether real Cloudflare R2 accepts the PutObject, which is unfalsifiable without credentials and is now stated as exactly that in the body rather than overstated.

ecdaa23 over e52bd00 is comments and docs only. Verified rather than taken on trust: stripping comment and blank lines from the diff of every .ts/.tsx file between the two leaves nothing, and no code line was added or removed — a commented-out statement would have surfaced as a removed non-comment line. So the verification above carries the earlier head's evidence forward legitimately.

Tests bite. 163 pass, tsc --noEmit exit 0. Mutation-checked three ways beyond the author's own: reverting Object.hasOwn to a bare index kills 3, reverting the route's .find to an object index kills 9, and restoring the ?? ".jpg" fallback kills 5. Both bypasses would now be caught if reintroduced, which was not true of the 155 tests that shipped past them.

Approving. Take the nit or leave it; it does not need another round from me.

VERDICT: approve-with-nits Gating round 1, dispatched. Judged `ecdaa23` on its merits rather than deferring to my earlier non-gating reads (3134, 3149, 3150, 3154). Both blocker-class bypasses I reported are closed, correctly and at the root rather than at the symptom. One nit, and it is a claim in the PR body rather than in the code. ## Finding apps/management/lib/storage/r2.ts:L5: nit: something does read the extension back out of a key, so the Deviations table's "nothing reads the extension back out of a key" is not accurate — and the inaccuracy is the hazard, not today's behaviour. `lib/mcp/tools/images.ts:29-36` resolves a MIME type from the URL suffix (`.png`, `.webp`, `.jpg`/`.jpeg`, `.pdf`, else `application/octet-stream`) to decide whether `view_image` returns an image block or runs the object through `extractText`. That makes `IMAGE_EXTENSIONS` here and `mimeFromUrl` there a coupled pair. Right now the coupling is satisfied and strictly better than `main`: the map can only ever emit `.jpg`, `.png`, `.webp` or `.pdf`, all four of which the sniffer recognises, so the mapping is total — whereas `main` derived the suffix from `file.name` and could store `nota.bmp` for an `image/jpeg`, which the sniffer would have classified `application/octet-stream`. The forward risk is that adding one entry to this map — `image/avif` → `.avif`, say — silently produces objects `view_image` misclassifies, and a reader who trusts the table will not know to look. Fix is a sentence: say the extension is consumed by `mimeFromUrl` and that the two lists move together, ideally as a comment at one of the two sites rather than only in the PR body. ## Verified at this head **Both bypasses are closed at the root.** `imageExtensionFor` guards the lookup with `Object.hasOwn`, so `__proto__` and `constructor` no longer resolve off `Object.prototype`; and the route resolves `type` by membership over `readonly UploadKind[]` with `.find`, which also drops the old `as UploadKind` cast. Re-run against the real `uploadFile` with only `@aws-sdk/client-s3` stubbed: `__proto__` and `constructor` rejected on all three kinds, and the route answers 400 for `__proto__`, `constructor`, `toString` and `bogus`. Folding the extension lookup into the allow-list is the part I would highlight — resolving the extension *is* the type check, so the two cannot drift apart, which is a better fix than the `Set` I originally suggested. **Receipts are byte-for-byte unaffected in everything the issue named.** `receipts/order/{YYYY}/{MM}/{uuid}{ext}` and `receipts/purchase/...` unchanged; 10 MB PDF and 5 MB image each accepted at exactly the cap and rejected at +1; PDF still legal for both receipt kinds and refused for `product` with the shortened message. Product photos capped at 5 MB. **`file.name` never reaches the object key.** `../../etc/passwd?a=b#c.png` sent as `image/webp` stores `products/{YYYY}/{MM}/{uuid}.webp`, and `receipt.pdf?x=1` sent as `application/pdf` stores `.pdf` where `e79e867` stored `.jpg`. The `node:path` import is gone rather than the regex tuned. **The Deviations table for the extension change is otherwise accurate.** I reproduced all four rows independently: `nota.jpg`/jpeg unchanged, `nota.jpeg`/jpeg collapses to `.jpg`, `nota` with no extension becomes `.png` for `image/png` instead of a hardcoded `.jpg`, and `nota.jpg?v=2` stops smuggling. Three differ, as stated, and no stored object moves or is renamed. Per the dispatcher this deviation is accepted, so I judged only the implementation and the table. **Malformed multipart answers 400, not a bare 500.** `formData()` is inside its own `try` returning a readable 400, kept distinct from the 422 the file rejections use, and it is tested at `uploads.test.ts:132` against a genuinely malformed body asserting both status and message. **The corrected `UploadKind` rationale is now accurate.** It reads "a product photo goes into an `<img src>` on the storefront, where a PDF renders nothing". Confirmed: `next/image` appears nowhere in `apps/store`, every image site is a plain `<img>` (`product-card.tsx:36`, `product-gallery.tsx:31` and `:46`, `cart-view.tsx:54`, plus four on the home page), and the store is `output: "export"` with `images: { unoptimized: true }`. The previous reason was false and the rule was right; correcting the reason rather than the rule was the right call, and worth naming as a pattern — a correct constraint recorded under a false premise gets relaxed by the next person who checks the premise. **No post-merge bucket configuration step exists.** Confirmed from the repo rather than accepted: `docs/r2-receipt-upload.md` records public access as a bucket-level toggle with no prefix granularity, so `products/` rides the same switch already serving `receipts/`; and because the storefront uses plain `<img>`, there is no `next/image` host allow-list to extend either. The single residual is whether real Cloudflare R2 accepts the `PutObject`, which is unfalsifiable without credentials and is now stated as exactly that in the body rather than overstated. **`ecdaa23` over `e52bd00` is comments and docs only.** Verified rather than taken on trust: stripping comment and blank lines from the diff of every `.ts`/`.tsx` file between the two leaves nothing, and no code line was added or removed — a commented-out statement would have surfaced as a removed non-comment line. So the verification above carries the earlier head's evidence forward legitimately. **Tests bite.** 163 pass, `tsc --noEmit` exit 0. Mutation-checked three ways beyond the author's own: reverting `Object.hasOwn` to a bare index kills 3, reverting the route's `.find` to an object index kills 9, and restoring the `?? ".jpg"` fallback kills 5. Both bypasses would now be caught if reintroduced, which was not true of the 155 tests that shipped past them. Approving. Take the nit or leave it; it does not need another round from me.
Author
Owner

Deviation adjudicated: the extension-from-MIME change stays wide, across all kinds. Recording the decision here so a future reader sees it was ruled on rather than self-declared by me. No code change; head remains ecdaa23fcba12a66782ae951b4a345117ebb01ec.

The reasoning, which is better than my own framing of it: scoping the change to product only would leave the receipt path still deriving its object key from file.name — the caller-controlled string that made query-string smuggling possible in the first place — and would leave the mislabelling bug live, with a PDF receipt stored under a .jpg key. "Receipts byte-for-byte unaffected" was a constraint about not moving or breaking stored objects, their prefixes and their caps, not about preserving a filename-derived key that turned out to be a vulnerability. No stored object moves or is renamed; only newly written keys differ, in the three cases enumerated in the Deviations table.

Two corrections to my own record while I am here, since both matter more than the fix:

My "receipts byte-for-byte unaffected" claim was false when I first made it. IMAGE_TYPES[file.type] was a plain-object lookup on a caller-controlled string, so Content-Type: __proto__ bypassed the allow-list on the receipt kinds as well as product — storing up to 5 MB of arbitrary bytes named x.html into a public bucket and returning its URL. The UPLOAD_KINDS whitelist in the route, which existed specifically to prevent a fallback into the receipt prefix, had the identical hole. I had declared that code verified in the original PR body. It was not.

Nothing in the toolchain could see it. 155 tests passed, tsc --noEmit was clean, biome check was clean. The Record<string, true> annotation is precisely why the type checker stayed quiet — the lookup is typed true, so a prototype hit type-checks. A change that parses, lints and keeps every rule count can still ship a hole into a public bucket; the only thing that surfaced this was a reviewer crafting requests against the running code.

That is also why the fix is shaped the way it is rather than as a narrow patch: the MIME→extension map now doubles as the allow-list, so there is no second table to drift out of sync with the first, and the guard is a single if (!ext) instead of two independent checks. The reviewer mutation-checked it rather than trusting the green suite — reverting Object.hasOwn to a bare index kills 3 tests, reverting the route's .find to an object index kills 9, restoring the old ?? ".jpg" fallback kills 5.

Everything else stands as written. Round 1 is now formally gating at ecdaa23; I am not merging until a verdict is pinned to that SHA and merge ordering is confirmed. Merging deploys to production.

**Deviation adjudicated: the extension-from-MIME change stays wide, across all kinds.** Recording the decision here so a future reader sees it was ruled on rather than self-declared by me. No code change; head remains `ecdaa23fcba12a66782ae951b4a345117ebb01ec`. The reasoning, which is better than my own framing of it: scoping the change to `product` only would leave the **receipt** path still deriving its object key from `file.name` — the caller-controlled string that made query-string smuggling possible in the first place — and would leave the mislabelling bug live, with a PDF receipt stored under a `.jpg` key. "Receipts byte-for-byte unaffected" was a constraint about not moving or breaking **stored objects, their prefixes and their caps**, not about preserving a filename-derived key that turned out to be a vulnerability. No stored object moves or is renamed; only newly written keys differ, in the three cases enumerated in the Deviations table. Two corrections to my own record while I am here, since both matter more than the fix: **My "receipts byte-for-byte unaffected" claim was false when I first made it.** `IMAGE_TYPES[file.type]` was a plain-object lookup on a caller-controlled string, so `Content-Type: __proto__` bypassed the allow-list on the **receipt** kinds as well as `product` — storing up to 5 MB of arbitrary bytes named `x.html` into a public bucket and returning its URL. The `UPLOAD_KINDS` whitelist in the route, which existed specifically to prevent a fallback into the receipt prefix, had the identical hole. I had declared that code verified in the original PR body. It was not. **Nothing in the toolchain could see it.** 155 tests passed, `tsc --noEmit` was clean, `biome check` was clean. The `Record<string, true>` annotation is precisely *why* the type checker stayed quiet — the lookup is typed `true`, so a prototype hit type-checks. A change that parses, lints and keeps every rule count can still ship a hole into a public bucket; the only thing that surfaced this was a reviewer crafting requests against the running code. That is also why the fix is shaped the way it is rather than as a narrow patch: the MIME→extension map now **doubles as the allow-list**, so there is no second table to drift out of sync with the first, and the guard is a single `if (!ext)` instead of two independent checks. The reviewer mutation-checked it rather than trusting the green suite — reverting `Object.hasOwn` to a bare index kills 3 tests, reverting the route's `.find` to an object index kills 9, restoring the old `?? ".jpg"` fallback kills 5. Everything else stands as written. Round 1 is now formally gating at `ecdaa23`; I am not merging until a verdict is pinned to that SHA and merge ordering is confirmed. Merging deploys to production.
yuris merged commit 0e2dded45a into main 2026-08-28 01:15:43 +00:00
yuris deleted branch feat/44-product-image-upload 2026-08-28 01:15:43 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/garage!62
No description provided.