Cloudflare Image Proxy and R2 Media Migration
Status
In progress: image proxy MVP deployed, app storage URL constants point at Cloudflare on develop, and normal-browser/dev-server
smoke passed for module panel and rack preview surfaces. Next safe slice is an upload/compression guardrail proposal; production release, R2 migration, and Supabase cleanup remain gated. Priority: HIGH.
Product area: infrastructure / storage / image delivery.
User intent
Supabase Free storage is becoming tight, especially from public image buckets. Before upgrading solely for image traffic,
Patcher should add free/low-cost infrastructure that reduces Supabase egress and creates a path to move bulky public media
away from Supabase Storage. module-panels are the largest bucket, but rack preview images live in the separate racks
storage bucket and should be cached/migrated deliberately too.
Current system analysis
- Supabase Free dashboard screenshot on 2026-07-07 showed:
- Egress: 924 MB / 5 GB.
- Database size: 140 MB / 500 MB.
- File storage: 585 MB / 1 GB.
- Read-only Supabase inspection on 2026-07-07 showed storage pressure is mostly
module-panels:
module-panels: 6,235 objects, about 497 MB.
racks: 142 objects, about 39 MB.
home-resources: 11 objects, about 29 MB.
- Database pressure is mostly Price Hub tables:
module_price_snapshots: about 66 MB.
module_store_listings: about 14 MB.
- Prior bandwidth plan concluded backend queries are already well optimized; image delivery remains the main hosting-plan
lever.
- Supabase image transformations require Pro, so Free-plan optimization should first use Cloudflare cache/R2 rather than
Supabase transforms.
Completed MVP checkpoint
- Added Cloudflare Worker source:
cloudflare/image-proxy/src/index.ts
cloudflare/image-proxy/wrangler.jsonc
- Deployed Worker:
- Worker:
patcher-image-proxy
- Custom domain:
images.patcher.xyz
- Latest fixed deployed version recorded in CLI output:
00418a4b-aab2-46dc-8e2f-0efd5a15b4de
- Updated
StorageUrls in src/app/features/backend/DatabaseStrings.ts to route public storage reads through:
https://images.patcher.xyz/module-panels/
https://images.patcher.xyz/racks/
https://images.patcher.xyz/manufacturer-logos/
https://images.patcher.xyz/module-collections/
https://images.patcher.xyz/patches/
- Added targeted tests:
scripts/tests/cloudflare-image-proxy.test.mjs
pnpm test:functions:cloudflare-image-proxy
DatabaseStrings.spec.ts storage URL expectations
- Added deploy script:
pnpm cloudflare:image-proxy:deploy
- Added no-delete storage audit tooling:
scripts/audits/audit-public-storage-media.mjs
scripts/tests/public-storage-media-audit.test.mjs
pnpm audit:public-storage-media
pnpm test:functions:public-storage-media-audit
- Added browser resilience for cached image surfaces:
- module panel cards first request
images.patcher.xyz, then retry the direct Supabase public URL after an image error
event.
- rack preview cards follow the same proxy-first/direct-fallback flow before showing "Preview unavailable".
- This keeps the optimized Cloudflare path as the default while avoiding blank UI from transient local DNS/browser/cache/edge
failures during the develop rollout.
- Added
referrerpolicy="no-referrer" to public storage image surfaces routed through images.patcher.xyz. Cloudflare hotlink
protection can reject JPEG requests from local development when the browser sends Referer: http://localhost:5556/; suppressing
the page referrer keeps local/develop image requests on the normal cached path.
- Added static regression coverage:
scripts/tests/public-storage-referrer-policy.test.mjs
pnpm test:functions:public-storage-referrer-policy
- wired into
pnpm test:functions
The test scans public storage image templates and programmatic image probes so future images.patcher.xyz surfaces must be
classified and must suppress referrers.
Worker behavior
- Allowlisted buckets only:
module-panels
racks
manufacturer-logos
module-collections
patches
- Rejects unknown buckets and traversal-like path segments.
- Strips query strings before forwarding to Supabase public storage.
- For successful image responses, returns:
Cache-Control: public, max-age=604800, s-maxage=2592000
Access-Control-Allow-Origin: *
X-Content-Type-Options: nosniff
- Uses split cache TTLs intentionally:
- Browser/client cache: one week (
max-age=604800).
- Shared Cloudflare cache / Worker origin fetch cache: one month (
s-maxage=2592000 and Worker cf.cacheTtl).
Module panel images rarely change after upload, but the TTL is still not immutable/year-long because module panel uploads
can overwrite filenames.
Validation results
- 2026-07-07T16:22+02:00 —
pnpm test:functions:cloudflare-image-proxy passed.
- 2026-07-07T16:22+02:00 —
pnpm test-headless --include="**/DatabaseStrings.spec.ts" passed.
- 2026-07-07T16:23+02:00 —
pnpm lint passed.
- 2026-07-07T16:25+02:00 —
pnpm build passed.
- 2026-07-07T16:24+02:00 — public resolvers
1.1.1.1, 8.8.8.8, and Cloudflare authoritative nameservers resolved
images.patcher.xyz to Cloudflare IPs.
- 2026-07-07T16:24+02:00 — live Worker request via resolved Cloudflare IP returned
200, content-type: image/webp,
and validated the proxy path for a real module-panels object.
- 2026-07-07T16:37+02:00 — final live Worker request with a fresh cache key returned
200, content-type: image/webp,
and cache-control: public, max-age=604800, s-maxage=2592000 for a real module-panels object.
- 2026-07-07T16:45+02:00 — live Worker request with fresh cache keys returned
200, correct image content types, and
cache-control: public, max-age=604800, s-maxage=2592000 for both module-panels and racks; unknown bucket returned
404.
- 2026-07-07T16:48+02:00 —
pnpm test:functions:public-storage-media-audit passed for the new no-delete audit helper.
- 2026-07-07T16:51+02:00 —
pnpm lint passed after adding the audit helper.
- 2026-07-07T16:53+02:00 — final targeted validation passed:
pnpm test:functions:public-storage-media-audit && pnpm test:functions:cloudflare-image-proxy, plus
node scripts/checks/check-docs.cjs.
- 2026-07-07T16:49+02:00 — live
pnpm audit:public-storage-media -- --quiet could not run locally because no
SUPABASE_SERVICE_ROLE_KEY / SUPABASE_SERVICE_KEY is present in this shell. Used Supabase MCP read-only SQL instead.
- 2026-07-07T16:50+02:00 — read-only storage audit snapshot:
module-panels: 6,235 objects, 497.13 MB, avg 81.65 KB, max 2.22 MB.
racks: 142 objects, 39.14 MB, avg 282.26 KB, max 0.95 MB.
- Oversized using current audit thresholds:
module-panels has 68 objects / 54.44 MB over 512 KB; racks has zero over
1 MB.
- Potential orphans by DB reference:
module-panels has 53 objects / 4.36 MB not referenced by module_panels.filename;
racks has 10 objects / 2.09 MB not referenced by racks.image.
- 2026-07-07T18:18+02:00 — after a local browser report of broken module/rack images, exact reported module panel files
returned 200 through the Worker. Added proxy-first/direct-Supabase fallback for module panel and rack preview components.
Targeted image component specs, DatabaseStrings.spec.ts, and pnpm lint passed.
- 2026-07-07T18:25+02:00 — root cause narrowed to Cloudflare hotlink/referrer protection on local development:
disting-ex.jpg returned 403 error code: 1011 only when requested with Referer: http://localhost:5556/, and Chrome
surfaced it as net::ERR_BLOCKED_BY_ORB. Added no-referrer image requests for proxied public storage surfaces. Targeted image
specs, Worker unit tests, pnpm lint, docs check, and pnpm build passed. The already-running local ng serve process did
not rebuild the template changes, so the visible browser/dev-server check requires restarting that process.
- 2026-07-07T18:28+02:00 — added and wired
public-storage-referrer-policy static regression tests. pnpm test:functions
passed with the new guard included.
- 2026-07-08T12:17+02:00 — normal-browser/dev-server smoke passed against a fresh local
pnpm start -- --port 5556 server.
Playwright snapshots of /modules/details/4524 and /racks/browser showed images.patcher.xyz module-panel and rack image
requests returning 200, with no proxy/referrer/ORB console errors.
Known caveats
- The Worker/custom domain is live infrastructure, but the production app will not use it until a production release includes
the StorageUrls change. Current published production remains on direct Supabase public storage URLs.
- Keep this on
develop until browser/dev-server verification is complete; do not push/release solely because the Worker
deployed successfully.
- This reduces Supabase egress pressure but does not reduce Supabase storage usage.
- Local macOS resolver still returned stale NXDOMAIN immediately after custom-domain creation; public resolvers were already
correct. If a later agent sees local DNS failure, re-test with public resolvers before assuming deployment failed.
- Existing Supabase public storage remains the origin. Do not delete or move objects until an R2 migration is implemented and
verified.
- Rack preview images are a separate bucket/table path from module panel images. Keep rack cache/migration checks explicit;
do not assume module-panel validation covers rack preview behavior.
- Module and rack cards now have a direct Supabase image fallback if the Cloudflare URL fails in the browser. This is only a
failure path, so normal successful traffic still goes through Cloudflare caching.
- Local dev images should no longer send
localhost as a referrer after the app rebuilds. If the current local browser still
shows Disting EX broken, restart the existing ng serve --port 5556 process so it serves the updated templates.
- There are unrelated pre-existing working-tree changes in Price Hub and workflow docs. Do not revert them while continuing
this infra task.
Remaining MVP/structural work
R2 migration runbook draft
This is the next cost/storage lever after the Cloudflare cache proxy. It is deliberately a proposal/runbook only until the
operator approves creating R2 buckets and copying data.
Target order
module-panels first because it is the largest bucket and the strongest storage-pressure source.
racks second because rack previews are user-generated and separately referenced by public.racks.image.
- Defer
manufacturer-logos, module-collections, and patches until the first two buckets prove the path.
Safety model
- R2 migration must be additive first: copy to R2, verify, switch reads, observe, then propose Supabase cleanup.
- Do not delete Supabase objects during the first R2 switch.
- Keep the Worker as the single image URL surface; app code should keep using
StorageUrls, not learn whether a bucket is
Supabase- or R2-backed.
- Rollback is DNS/code-free while Supabase files remain: change Worker bucket routing back to Supabase and redeploy.
Copy and parity checks
- Export object inventory from Supabase per bucket:
- object name
- byte size
- MIME type
- ETag / checksum metadata when available
- Copy exact object names into matching R2 prefixes or buckets.
- Verify:
- object count parity
- byte total parity
- sample content-type parity
- top oversized files present
- random sample HEAD/GET through Worker
- Keep a timestamped inventory artifact outside the repo or under
output/ if generated locally.
Approved staging operator tooling
- Script:
pnpm r2:stage-public-media.
- Default behavior is dry-run; real writes require
--execute.
- Required explicit destination naming:
R2_BUCKET_MODULE_PANELS=patcher-module-panels
R2_BUCKET_RACKS=patcher-rack-previews
- Required credentials/config for real staging:
SUPABASE_SERVICE_ROLE_KEY or SUPABASE_SERVICE_KEY for storage inventory.
CLOUDFLARE_ACCOUNT_ID plus CLOUDFLARE_API_TOKEN for --execute --create-buckets.
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY (or AWS-compatible aliases) for --execute --copy and --execute --verify.
- Safety behavior:
- preserves source object keys and content types
- writes only to the explicit R2 destination
- serializes execute-mode copy writes (
--concurrency=1) so the run fails before scheduling additional object writes
- refuses to overwrite a destination object unless it is byte-equivalent/idempotent
- stores checkpoint records by hashed key identifier, not raw object name
- verifies destination key, byte, content-type, and SHA-256 parity before considering a copied object complete
- writes aggregate summary/verification reports under
output/r2-public-media-staging
- requires
--allow-sample-execute before an execute-mode sampled run, so a partial sample cannot be mistaken for full parity
Current staging inventory
Read-only Supabase MCP snapshot on 2026-07-17T12:03+02:00, compared to the 2026-07-07 runbook snapshot:
| Bucket |
Current objects |
Current bytes |
Current MB |
2026-07-07 objects / MB |
Delta |
module-panels |
6,240 |
521,486,743 |
497.33 |
6,235 / 497.13 |
+5 objects, +0.20 MB |
racks |
142 |
41,042,929 |
39.14 |
142 / 39.14 |
no material change |
Additional non-secret checks from the same snapshot: both buckets have zero missing-size rows and zero non-image MIME rows;
module-panels still has 68 objects over 512 KB totaling 57,089,710 bytes, while racks still has zero objects over 1 MB.
No Supabase objects were modified or deleted during this staging attempt.
Worker routing direction
- Keep
images.patcher.xyz/{bucket}/{file} stable.
- Add optional R2 bindings per bucket only after R2 buckets exist.
- Route
module-panels to R2 first while leaving all other buckets on Supabase origin.
- Route
racks to R2 only after module-panel rollout is stable.
Cleanup gate
Supabase cleanup requires explicit maintainer approval after:
- at least one observation window with no broken images
- parity report reviewed
- rollback path confirmed
- production release has been verified if production is using the proxy
Upload/compression guardrail proposal
Actionable, maintainer-reviewable local plan for future upload-side compression and dimension guardrails. This section is
docs/planning only for the current loop. No upload behavior, thresholds, backend, schema, RLS, storage, or production
release changes are enforced until the maintainer approves the questions in "Approval questions and recommended defaults"
below.
Current upload code paths (as of 2026-07-08)
Reviewed to identify the smallest future implementation seam without changing today's behavior.
-
Module panels (largest storage pressure).
- UI entry:
src/app/components/module-parts/module-editor/module-editor.component.html uses
<lib-file-drag-host [isImageOnlyMode] [multipleFilesMode]=false acceptedFileType="image/jpeg,image/jpg,image/png,image/webp">
for the raw file drop, then feeds a cropper section and a final preview.
- Component wiring:
src/app/components/module-parts/module-editor/module-editor.component.ts reads
fileDragHostService.files$, drives preview/crop state, then onPanelImageCropped builds croppedPanelFile$ from the
cropper output. persistAllChanges$ finally passes croppedPanelFile$.value into the module-editor data service.
Current crop output: panelCropOutputQuality = 95 and WebP preferred when the browser supports it.
- Cropper:
src/app/components/module-parts/module-editor/module-editor-cropper.component.ts wraps ngx-image-cropper
with [output]="'blob'", [format], [imageQuality], [onlyScaleDown]=true, and a min crop of 120.
- Data service seam:
src/app/components/module-parts/module-editor/module-editor-data.service.ts — buildCroppedPanelFile
wraps the crop blob in a File, and private savePendingPanel$ reads file.arrayBuffer(), builds the filename, calls
backend.storage.uploadModulePanel(fileBuffer, filenameAndExtension, file.type), then backend.add.panel.
- Backend surface:
src/app/features/backend/supabase-storage.ts — uploadModulePanel(file, filenameAndExtension, contentType='image/jpeg') uploads with upsert to DbStoragePaths.module_panels.
-
Rack previews (app-generated, not user file uploads).
src/app/components/rack-parts/rack-detail-data.service.ts — generateRackJpegWithoutAnalysisOverlays$ produces a
Blob with type: 'image/jpeg', then calls backend.storage.uploadRackImage.
- Backend surface:
uploadRackImage in supabase-storage.ts.
-
Marketplace/listing media — not implemented; must not be shipped without guardrails from day one (see
"Non-goals and gates" below).
Key implication: for module panels there is a single, well-scoped seam — the croppedPanelFile$ output plus the
savePendingPanel$ upload step in module-editor-data.service.ts, plus the pre-crop file selection at
fileDragHostService.files$ in module-editor.component.ts. Any future client-side size/dimension guardrail can plug in at
those two points without touching SupabaseService or backend schema.
Approved implementation seam
The thresholds and UX below are approved for frontend implementation. R2 migration and existing-object cleanup remain
separately operator-gated.
- Introduce a small, pure helper (future PR, not this loop) that inspects a candidate
File/Blob and returns a structured
advisory result: { ok, warnings[], blocked, reason?, measuredBytes, measuredWidth?, measuredHeight? }.
- Location proposal:
src/app/shared-interproject/upload-guardrails/ (new folder), pure function, no Angular DI, no
Supabase, no side effects. Keeps the API service / data service / component layering rules from AGENTS.md §4 intact.
- Consumers:
module-editor.component.ts (raw pre-crop File) and module-editor-data.service.ts (post-crop File
inside savePendingPanel$ before the backend.storage.uploadModulePanel call).
- Enforce the approved module-panel thresholds through the helper and require explicit confirmation before uploading a
still-oversized panel.
- Rack previews consume the same measurement helper and stop before upload when the generated image exceeds 1 MB.
- No runtime flag is required for these approved frontend limits. R2 migration and existing-object cleanup remain separately
gated.
Approval questions and recommended defaults
Maintainer decisions required before any enforcement PR. All seven decisions were approved on 2026-07-17.
- Max module-panel file size after crop — approved: 512 KB (matches the existing audit threshold; 68/6,235
current objects are above it, so this catches the visible outliers without touching typical uploads). Alternatives worth
naming: 384 KB (aggressive), 768 KB (conservative).
- Max module-panel output dimensions — approved: max 5000 px on the long edge for module panels, with
onlyScaleDown = true already applied by the cropper.
- Output format preference — approved: keep the current WebP-preferred-with-JPEG-fallback flow already in
module-editor.component.ts (quality 95). Add a soft downgrade to 90 only for files still above the size threshold after a
first encode pass. Do not silently over-degrade artwork.
- User feedback when a file exceeds the threshold — approved: inline warning + explicit "compress anyway"
confirmation, not a hard block. Escape-hatch preserves rare high-detail panels while nudging most uploads under the
threshold.
- Rack preview enforcement — approved: hard limit at 1 MB.
- Existing oversized objects — approved: no automatic re-upload/recompression. Any cleanup remains a
maintainer-approved batch task later, per the existing "no-delete storage audit" direction in the Decision log.
- Marketplace/listing media constraints — approved baseline: define image-count, pre-processing file-size, and output-
dimension limits; strip EXIF; use JPEG/WebP only; exclude hosted audio/video from MVP; and use the image proxy/R2 path from
day one. Marketplace implementation remains out of scope here.
Implementation checklist
Ordered so each step is independently reviewable and preserves layering rules from AGENTS.md.
Validation strategy
For the seam / advisory-only PR (not this loop):
pnpm lint
pnpm test-headless --include="**/module-editor-data.service.spec.ts" (add if missing) and
**/upload-guardrails*.spec.ts.
node scripts/checks/check-docs.cjs
- Manual smoke: upload a small JPEG, a large JPEG, and a large PNG through the module editor on
develop; confirm the
advisory log fires and the upload still succeeds with identical bytes to a pre-seam baseline.
For the enforcement PR (later):
- Above, plus Playwright coverage of the warning + "compress anyway" UX in
src/app/components/module-parts/module-editor/.
- Re-run
pnpm audit:public-storage-media after a few real uploads land on develop to confirm new objects respect the
threshold.
- No backend/schema/RLS changes, so no
updateBackendTypes or advisor run is required.
Non-goals and gates
- No changes to
SupabaseService, supabase-storage.ts, backend schema, RLS, or storage buckets in this loop or in the
advisory-only PR.
- No enforcement of any size/dimension threshold until the maintainer approves the seven questions above.
- No automatic recompression or deletion of the 68 existing oversized
module-panels objects.
- No Marketplace/listing media implementation. When it eventually ships, it must land with:
maximum image count per listing, maximum accepted file size before processing, max output dimensions, EXIF stripping,
JPEG/WebP output decision, no hosted audio/video in MVP, and it must reuse the image proxy / R2 path from day one instead of
adding another Supabase-heavy public bucket without cache/migration strategy.
- No production release, no push, no force-push. This proposal must remain reviewable on
develop.
Non-goals
- No Supabase deletion during the image proxy checkpoint.
- No R2 migration in the same step as the Worker deploy.
- No Supabase plan downgrade/upgrade automation.
- No backend schema/RLS changes.
- No Marketplace media implementation.
Decision log
- 2026-07-08T15:35+02:00 — Staged as the next coordinator-loop task after Price Hub frontend cleanup. Safe scope is proposal/planning for upload-side compression and dimension guardrails only; do not create R2 buckets, copy/switch/delete storage objects, enforce maintainer-facing upload thresholds, release production, or push without explicit approval.
- 2026-07-07T16:12+02:00 — User approved Cloudflare Worker deployment via Wrangler browser login instead of an API token.
- 2026-07-07T16:18+02:00 — First deploy uploaded Worker but failed to publish because
workers_dev was enabled while no workers.dev subdomain existed; config was changed to workers_dev: false.
- 2026-07-07T16:19+02:00 — Worker deployed successfully to custom domain
images.patcher.xyz.
- 2026-07-07T16:22+02:00 — Cache TTL reduced from one year/immutable to 24h because module panel filenames can be overwritten; keep immutable caching behind a future versioned-filename checkpoint.
- 2026-07-07T16:27+02:00 — Production rollout explicitly gated: live production keeps direct Supabase URLs until the
StorageUrls change is released after browser/dev-server verification.
- 2026-07-07T16:28+02:00 — User approved increasing Worker cache TTL from 24h to one week because module images are rarely updated after upload. Deployed Worker version
853a42e5-1a96-452c-a1f0-bce4cd948790; fresh cache keys return Cache-Control: public, max-age=604800, while objects already cached under the previous deploy may keep the old 24h header until they expire.
- 2026-07-07T16:35+02:00 — Split browser/client and shared-cache TTLs: browser remains one week, Cloudflare shared cache and Worker origin fetch cache move to one month (
2592000) as the first longer-cache step. Final fixed deployed Worker version: 00418a4b-aab2-46dc-8e2f-0efd5a15b4de; live fresh-cache response returns Cache-Control: public, max-age=604800, s-maxage=2592000.
- 2026-07-07T16:42+02:00 — User explicitly confirmed rack preview images must also be cached. They are already routed through the deployed
racks allowlist/base URL, but follow-up smoke/R2 work must track the separate racks storage bucket explicitly instead of treating module panels as the only target.
- 2026-07-07T16:50+02:00 — No-delete audit direction clarified: do not delete or migrate from the orphan/oversized findings
autonomously. The safe next optimization is a compression/re-upload proposal for the 68 oversized module panels and a
maintainer-reviewed cleanup proposal for the small orphan set.
- 2026-07-07T18:18+02:00 — Local browser breakage report diagnosed against exact screenshot module filenames; Worker returned
valid image responses, so app-side proxy-failure fallback was added for module panels and rack previews without changing the
default Cloudflare-optimized path.
- 2026-07-07T18:25+02:00 — Chrome ORB failure traced to Cloudflare returning
403 error code: 1011 for proxied JPEGs when the
request includes the local dev Referer. Keep referrerpolicy="no-referrer" on all images.patcher.xyz storage image
elements unless Cloudflare hotlink protection is explicitly reconfigured.
- 2026-07-07T18:28+02:00 — Added static no-referrer regression test and included it in
pnpm test:functions so Cloudflare
referrer/hotlink regressions are caught before release.
- 2026-07-08T12:17+02:00 — Fresh local dev-server browser validation completed for module detail and rack browser image paths.
This clears the develop smoke checkpoint; R2 bucket creation/copy/switch/cleanup and any production release remain explicitly
operator-gated.
- 2026-07-08T14:13+02:00 — User chose Cloudflare dashboard/manual purge for rare overwritten module-panel filenames. Do not build an operator-only purge path unless manual purge becomes painful; revisit immutable/year-long TTL after filename versioning/content-addressing.
- 2026-07-08T15:35+02:00 — Expanded the upload/compression guardrail proposal into an actionable local plan (current upload paths, no-behavior-change seam under
src/app/shared-interproject/upload-guardrails/, seven maintainer approval questions with recommended defaults grounded in the 2026-07-07 audit snapshot, future implementation checklist, validation strategy, non-goals/gates). Docs/planning only; no upload behavior, storage, backend, schema, RLS, production release, or remote-object changes. R2 migration/copy/switch/cleanup and enforcement of any upload threshold remain operator-gated.
- 2026-07-17 — Product owner approved all seven upload-guardrail decisions: 512 KB post-crop module-panel threshold, 5000 px long edge, WebP/JPEG quality 95→90 fallback, inline "compress anyway" confirmation, a hard 1 MB rack-preview limit, no automatic recompression of existing objects, and the full Marketplace media baseline. R2 migration/copy/switch/cleanup remains separately operator-gated.
- 2026-07-17 — Product owner approved the staged R2 create/copy/verify phase only. Do not switch traffic, clean up source objects, or delete any Supabase object without another explicit approval.
- 2026-07-17T12:03+02:00 — R2 staging started from exact develop commit
45efeef0. Refreshed read-only Supabase inventory:
module-panels has 6,240 objects / 521,486,743 bytes and racks has 142 objects / 41,042,929 bytes. Added safe
default-dry-run R2 staging tooling and tests. Real bucket creation/copy/verification did not run because this shell has no
configured SUPABASE_SERVICE_ROLE_KEY/SUPABASE_SERVICE_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN,
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, or explicit R2_BUCKET_MODULE_PANELS/R2_BUCKET_RACKS destination names.
No traffic switch, Worker routing change, production release, cleanup, source overwrite, or Supabase object deletion occurred.
- 2026-07-17 — Product owner approved
patcher-module-panels and patcher-rack-previews as the explicit R2 destination bucket
names. Real create/copy/verify remains blocked only on local credential configuration.
- 2026-07-17 — Integrated frontend upload guardrails in commit
80c9a3ef: module-panel measurement/compression plus explicit
still-oversized confirmation, stale compression race protection, and a 1 MB rack-preview hard limit. Focused specs pass;
authenticated runtime rendering remains blocked by the existing module-detail 406 loading shell.
- 2026-07-17T12:26+02:00 — Resumed the approved create/copy/verify stage from local commit
ec3ea426. The shell still has no
configured SUPABASE_SERVICE_ROLE_KEY/SUPABASE_SERVICE_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN,
R2_ACCESS_KEY_ID, or R2_SECRET_ACCESS_KEY, so no R2 bucket creation/copy/verification was attempted. Tightened the
operator script so execute-mode preflight reports the full missing non-secret config list before any inventory fetch. No
traffic switch, Worker routing change, production release, cleanup, source overwrite, or Supabase object deletion occurred.
Source: internaldocs/workflow/plans/cloudflare-image-proxy-and-r2-media-migration.md
Migration status: migrated from local documentation
Cloudflare Image Proxy and R2 Media Migration
Status
In progress: image proxy MVP deployed, app storage URL constants point at Cloudflare on
develop, and normal-browser/dev-serversmoke passed for module panel and rack preview surfaces. Next safe slice is an upload/compression guardrail proposal; production release, R2 migration, and Supabase cleanup remain gated. Priority: HIGH.
Product area: infrastructure / storage / image delivery.
User intent
Supabase Free storage is becoming tight, especially from public image buckets. Before upgrading solely for image traffic,
Patcher should add free/low-cost infrastructure that reduces Supabase egress and creates a path to move bulky public media
away from Supabase Storage.
module-panelsare the largest bucket, but rack preview images live in the separateracksstorage bucket and should be cached/migrated deliberately too.
Current system analysis
module-panels:module-panels: 6,235 objects, about 497 MB.racks: 142 objects, about 39 MB.home-resources: 11 objects, about 29 MB.module_price_snapshots: about 66 MB.module_store_listings: about 14 MB.lever.
Supabase transforms.
Completed MVP checkpoint
cloudflare/image-proxy/src/index.tscloudflare/image-proxy/wrangler.jsoncpatcher-image-proxyimages.patcher.xyz00418a4b-aab2-46dc-8e2f-0efd5a15b4deStorageUrlsinsrc/app/features/backend/DatabaseStrings.tsto route public storage reads through:https://images.patcher.xyz/module-panels/https://images.patcher.xyz/racks/https://images.patcher.xyz/manufacturer-logos/https://images.patcher.xyz/module-collections/https://images.patcher.xyz/patches/scripts/tests/cloudflare-image-proxy.test.mjspnpm test:functions:cloudflare-image-proxyDatabaseStrings.spec.tsstorage URL expectationspnpm cloudflare:image-proxy:deployscripts/audits/audit-public-storage-media.mjsscripts/tests/public-storage-media-audit.test.mjspnpm audit:public-storage-mediapnpm test:functions:public-storage-media-auditimages.patcher.xyz, then retry the direct Supabase public URL after an imageerrorevent.
failures during the
developrollout.referrerpolicy="no-referrer"to public storage image surfaces routed throughimages.patcher.xyz. Cloudflare hotlinkprotection can reject JPEG requests from local development when the browser sends
Referer: http://localhost:5556/; suppressingthe page referrer keeps local/develop image requests on the normal cached path.
scripts/tests/public-storage-referrer-policy.test.mjspnpm test:functions:public-storage-referrer-policypnpm test:functionsThe test scans public storage image templates and programmatic image probes so future
images.patcher.xyzsurfaces must beclassified and must suppress referrers.
Worker behavior
module-panelsracksmanufacturer-logosmodule-collectionspatchesCache-Control: public, max-age=604800, s-maxage=2592000Access-Control-Allow-Origin: *X-Content-Type-Options: nosniffmax-age=604800).s-maxage=2592000and Workercf.cacheTtl).Module panel images rarely change after upload, but the TTL is still not immutable/year-long because module panel uploads
can overwrite filenames.
Validation results
pnpm test:functions:cloudflare-image-proxypassed.pnpm test-headless --include="**/DatabaseStrings.spec.ts"passed.pnpm lintpassed.pnpm buildpassed.1.1.1.1,8.8.8.8, and Cloudflare authoritative nameservers resolvedimages.patcher.xyzto Cloudflare IPs.200,content-type: image/webp,and validated the proxy path for a real
module-panelsobject.200,content-type: image/webp,and
cache-control: public, max-age=604800, s-maxage=2592000for a realmodule-panelsobject.200, correct image content types, andcache-control: public, max-age=604800, s-maxage=2592000for bothmodule-panelsandracks; unknown bucket returned404.pnpm test:functions:public-storage-media-auditpassed for the new no-delete audit helper.pnpm lintpassed after adding the audit helper.pnpm test:functions:public-storage-media-audit && pnpm test:functions:cloudflare-image-proxy, plusnode scripts/checks/check-docs.cjs.pnpm audit:public-storage-media -- --quietcould not run locally because noSUPABASE_SERVICE_ROLE_KEY/SUPABASE_SERVICE_KEYis present in this shell. Used Supabase MCP read-only SQL instead.module-panels: 6,235 objects, 497.13 MB, avg 81.65 KB, max 2.22 MB.racks: 142 objects, 39.14 MB, avg 282.26 KB, max 0.95 MB.module-panelshas 68 objects / 54.44 MB over 512 KB;rackshas zero over1 MB.
module-panelshas 53 objects / 4.36 MB not referenced bymodule_panels.filename;rackshas 10 objects / 2.09 MB not referenced byracks.image.returned
200through the Worker. Added proxy-first/direct-Supabase fallback for module panel and rack preview components.Targeted image component specs,
DatabaseStrings.spec.ts, andpnpm lintpassed.disting-ex.jpgreturned403 error code: 1011only when requested withReferer: http://localhost:5556/, and Chromesurfaced it as
net::ERR_BLOCKED_BY_ORB. Added no-referrer image requests for proxied public storage surfaces. Targeted imagespecs, Worker unit tests,
pnpm lint, docs check, andpnpm buildpassed. The already-running localng serveprocess didnot rebuild the template changes, so the visible browser/dev-server check requires restarting that process.
public-storage-referrer-policystatic regression tests.pnpm test:functionspassed with the new guard included.
pnpm start -- --port 5556server.Playwright snapshots of
/modules/details/4524and/racks/browsershowedimages.patcher.xyzmodule-panel and rack imagerequests returning
200, with no proxy/referrer/ORB console errors.Known caveats
the
StorageUrlschange. Current published production remains on direct Supabase public storage URLs.developuntil browser/dev-server verification is complete; do not push/release solely because the Workerdeployed successfully.
correct. If a later agent sees local DNS failure, re-test with public resolvers before assuming deployment failed.
verified.
do not assume module-panel validation covers rack preview behavior.
failure path, so normal successful traffic still goes through Cloudflare caching.
localhostas a referrer after the app rebuilds. If the current local browser stillshows
Disting EXbroken, restart the existingng serve --port 5556process so it serves the updated templates.this infra task.
Remaining MVP/structural work
module-panelsvia module detail/browser/rack editor surfaces.racksvia rack list/detail/linked patch preview surfaces.module-panelsandracksUI cards so local/browser/edge failures do not leave theprimary user-area cards blank while Supabase origin still works.
development.
referrerpolicy="no-referrer"orwhen a programmatic storage image probe assigns
srcbeforereferrerPolicy.module-panelsandracks.Keep all current public
StorageUrlsbuckets on the proxy (module-panels,racks,manufacturer-logos,module-collections,patches) so existing app call sites remain centralized. Prioritize smoke/R2 migration formodule-panelsandracksbecause they are the current storage pressure.versioned/content-addressed; do not build operator purge tooling by default.
module-panelsandracks:Cloudflare/R2/Supabase credentials are configured
module-panels, thenracksonly after separate approvalR2 migration runbook draft
This is the next cost/storage lever after the Cloudflare cache proxy. It is deliberately a proposal/runbook only until the
operator approves creating R2 buckets and copying data.
Target order
module-panelsfirst because it is the largest bucket and the strongest storage-pressure source.rackssecond because rack previews are user-generated and separately referenced bypublic.racks.image.manufacturer-logos,module-collections, andpatchesuntil the first two buckets prove the path.Safety model
StorageUrls, not learn whether a bucket isSupabase- or R2-backed.
Copy and parity checks
output/if generated locally.Approved staging operator tooling
pnpm r2:stage-public-media.--execute.R2_BUCKET_MODULE_PANELS=patcher-module-panelsR2_BUCKET_RACKS=patcher-rack-previewsSUPABASE_SERVICE_ROLE_KEYorSUPABASE_SERVICE_KEYfor storage inventory.CLOUDFLARE_ACCOUNT_IDplusCLOUDFLARE_API_TOKENfor--execute --create-buckets.R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY(or AWS-compatible aliases) for--execute --copyand--execute --verify.--concurrency=1) so the run fails before scheduling additional object writesoutput/r2-public-media-staging--allow-sample-executebefore an execute-mode sampled run, so a partial sample cannot be mistaken for full parityCurrent staging inventory
Read-only Supabase MCP snapshot on 2026-07-17T12:03+02:00, compared to the 2026-07-07 runbook snapshot:
module-panelsracksAdditional non-secret checks from the same snapshot: both buckets have zero missing-size rows and zero non-image MIME rows;
module-panelsstill has 68 objects over 512 KB totaling 57,089,710 bytes, whileracksstill has zero objects over 1 MB.No Supabase objects were modified or deleted during this staging attempt.
Worker routing direction
images.patcher.xyz/{bucket}/{file}stable.module-panelsto R2 first while leaving all other buckets on Supabase origin.racksto R2 only after module-panel rollout is stable.Cleanup gate
Supabase cleanup requires explicit maintainer approval after:
Upload/compression guardrail proposal
Actionable, maintainer-reviewable local plan for future upload-side compression and dimension guardrails. This section is
docs/planning only for the current loop. No upload behavior, thresholds, backend, schema, RLS, storage, or production
release changes are enforced until the maintainer approves the questions in "Approval questions and recommended defaults"
below.
Current upload code paths (as of 2026-07-08)
Reviewed to identify the smallest future implementation seam without changing today's behavior.
Module panels (largest storage pressure).
src/app/components/module-parts/module-editor/module-editor.component.htmluses<lib-file-drag-host [isImageOnlyMode] [multipleFilesMode]=false acceptedFileType="image/jpeg,image/jpg,image/png,image/webp">for the raw file drop, then feeds a cropper section and a final preview.
src/app/components/module-parts/module-editor/module-editor.component.tsreadsfileDragHostService.files$, drives preview/crop state, thenonPanelImageCroppedbuildscroppedPanelFile$from thecropper output.
persistAllChanges$finally passescroppedPanelFile$.valueinto the module-editor data service.Current crop output:
panelCropOutputQuality = 95and WebP preferred when the browser supports it.src/app/components/module-parts/module-editor/module-editor-cropper.component.tswrapsngx-image-cropperwith
[output]="'blob'",[format],[imageQuality],[onlyScaleDown]=true, and a min crop of 120.src/app/components/module-parts/module-editor/module-editor-data.service.ts—buildCroppedPanelFilewraps the crop blob in a
File, and privatesavePendingPanel$readsfile.arrayBuffer(), builds the filename, callsbackend.storage.uploadModulePanel(fileBuffer, filenameAndExtension, file.type), thenbackend.add.panel.src/app/features/backend/supabase-storage.ts—uploadModulePanel(file, filenameAndExtension, contentType='image/jpeg')uploads withupserttoDbStoragePaths.module_panels.Rack previews (app-generated, not user file uploads).
src/app/components/rack-parts/rack-detail-data.service.ts—generateRackJpegWithoutAnalysisOverlays$produces aBlobwithtype: 'image/jpeg', then callsbackend.storage.uploadRackImage.uploadRackImageinsupabase-storage.ts.Marketplace/listing media — not implemented; must not be shipped without guardrails from day one (see
"Non-goals and gates" below).
Key implication: for module panels there is a single, well-scoped seam — the
croppedPanelFile$output plus thesavePendingPanel$upload step inmodule-editor-data.service.ts, plus the pre-crop file selection atfileDragHostService.files$inmodule-editor.component.ts. Any future client-side size/dimension guardrail can plug in atthose two points without touching
SupabaseServiceor backend schema.Approved implementation seam
The thresholds and UX below are approved for frontend implementation. R2 migration and existing-object cleanup remain
separately operator-gated.
File/Bloband returns a structuredadvisory result:
{ ok, warnings[], blocked, reason?, measuredBytes, measuredWidth?, measuredHeight? }.src/app/shared-interproject/upload-guardrails/(new folder), pure function, no Angular DI, noSupabase, no side effects. Keeps the API service / data service / component layering rules from
AGENTS.md§4 intact.module-editor.component.ts(raw pre-cropFile) andmodule-editor-data.service.ts(post-cropFileinside
savePendingPanel$before thebackend.storage.uploadModulePanelcall).still-oversized panel.
gated.
Approval questions and recommended defaults
Maintainer decisions required before any enforcement PR. All seven decisions were approved on 2026-07-17.
current objects are above it, so this catches the visible outliers without touching typical uploads). Alternatives worth
naming: 384 KB (aggressive), 768 KB (conservative).
onlyScaleDown = truealready applied by the cropper.module-editor.component.ts(quality 95). Add a soft downgrade to 90 only for files still above the size threshold after afirst encode pass. Do not silently over-degrade artwork.
confirmation, not a hard block. Escape-hatch preserves rare high-detail panels while nudging most uploads under the
threshold.
maintainer-approved batch task later, per the existing "no-delete storage audit" direction in the Decision log.
dimension limits; strip EXIF; use JPEG/WebP only; exclude hosted audio/video from MVP; and use the image proxy/R2 path from
day one. Marketplace implementation remains out of scope here.
Implementation checklist
Ordered so each step is independently reviewable and preserves layering rules from
AGENTS.md.src/app/shared-interproject/upload-guardrails/with unit tests for the approved limits.module-editor.component.tson the pre-cropFileand intomodule-editor-data.service.tson thepost-crop
File.module-editor-data.service.tscovering measurement, compression, confirmation, andstale async result handling.
resolved; component/DOM regression specs cover the integrated behavior meanwhile.
develop, propose a maintainer-reviewed cleanup pass for the 68 existing oversizedmodule-panelsobjects. This remains gated with the existing R2 migration cleanup gate.Validation strategy
For the seam / advisory-only PR (not this loop):
pnpm lintpnpm test-headless --include="**/module-editor-data.service.spec.ts"(add if missing) and**/upload-guardrails*.spec.ts.node scripts/checks/check-docs.cjsdevelop; confirm theadvisory log fires and the upload still succeeds with identical bytes to a pre-seam baseline.
For the enforcement PR (later):
src/app/components/module-parts/module-editor/.pnpm audit:public-storage-mediaafter a few real uploads land ondevelopto confirm new objects respect thethreshold.
updateBackendTypesor advisor run is required.Non-goals and gates
SupabaseService,supabase-storage.ts, backend schema, RLS, or storage buckets in this loop or in theadvisory-only PR.
module-panelsobjects.maximum image count per listing, maximum accepted file size before processing, max output dimensions, EXIF stripping,
JPEG/WebP output decision, no hosted audio/video in MVP, and it must reuse the image proxy / R2 path from day one instead of
adding another Supabase-heavy public bucket without cache/migration strategy.
develop.Non-goals
Decision log
workers_devwas enabled while no workers.dev subdomain existed; config was changed toworkers_dev: false.images.patcher.xyz.StorageUrlschange is released after browser/dev-server verification.853a42e5-1a96-452c-a1f0-bce4cd948790; fresh cache keys returnCache-Control: public, max-age=604800, while objects already cached under the previous deploy may keep the old 24h header until they expire.2592000) as the first longer-cache step. Final fixed deployed Worker version:00418a4b-aab2-46dc-8e2f-0efd5a15b4de; live fresh-cache response returnsCache-Control: public, max-age=604800, s-maxage=2592000.racksallowlist/base URL, but follow-up smoke/R2 work must track the separateracksstorage bucket explicitly instead of treating module panels as the only target.autonomously. The safe next optimization is a compression/re-upload proposal for the 68 oversized module panels and a
maintainer-reviewed cleanup proposal for the small orphan set.
valid image responses, so app-side proxy-failure fallback was added for module panels and rack previews without changing the
default Cloudflare-optimized path.
403 error code: 1011for proxied JPEGs when therequest includes the local dev
Referer. Keepreferrerpolicy="no-referrer"on allimages.patcher.xyzstorage imageelements unless Cloudflare hotlink protection is explicitly reconfigured.
pnpm test:functionsso Cloudflarereferrer/hotlink regressions are caught before release.
This clears the develop smoke checkpoint; R2 bucket creation/copy/switch/cleanup and any production release remain explicitly
operator-gated.
src/app/shared-interproject/upload-guardrails/, seven maintainer approval questions with recommended defaults grounded in the 2026-07-07 audit snapshot, future implementation checklist, validation strategy, non-goals/gates). Docs/planning only; no upload behavior, storage, backend, schema, RLS, production release, or remote-object changes. R2 migration/copy/switch/cleanup and enforcement of any upload threshold remain operator-gated.45efeef0. Refreshed read-only Supabase inventory:module-panelshas 6,240 objects / 521,486,743 bytes andrackshas 142 objects / 41,042,929 bytes. Added safedefault-dry-run R2 staging tooling and tests. Real bucket creation/copy/verification did not run because this shell has no
configured
SUPABASE_SERVICE_ROLE_KEY/SUPABASE_SERVICE_KEY,CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY, or explicitR2_BUCKET_MODULE_PANELS/R2_BUCKET_RACKSdestination names.No traffic switch, Worker routing change, production release, cleanup, source overwrite, or Supabase object deletion occurred.
patcher-module-panelsandpatcher-rack-previewsas the explicit R2 destination bucketnames. Real create/copy/verify remains blocked only on local credential configuration.
80c9a3ef: module-panel measurement/compression plus explicitstill-oversized confirmation, stale compression race protection, and a 1 MB rack-preview hard limit. Focused specs pass;
authenticated runtime rendering remains blocked by the existing module-detail 406 loading shell.
ec3ea426. The shell still has noconfigured
SUPABASE_SERVICE_ROLE_KEY/SUPABASE_SERVICE_KEY,CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN,R2_ACCESS_KEY_ID, orR2_SECRET_ACCESS_KEY, so no R2 bucket creation/copy/verification was attempted. Tightened theoperator script so execute-mode preflight reports the full missing non-secret config list before any inventory fetch. No
traffic switch, Worker routing change, production release, cleanup, source overwrite, or Supabase object deletion occurred.
Source: internaldocs/workflow/plans/cloudflare-image-proxy-and-r2-media-migration.md
Migration status: migrated from local documentation