Return the product thumbnail in the mobile API, and allow a local HTTPS protocol - #7422
Merged
gianfrancopiana merged 4 commits intoAug 28, 2026
Merged
Conversation
…PS protocol
Two fixes found while capturing App Store screenshots for the mobile app.
Api::Mobile::ProductsController read the thumbnail as props.dig("thumbnail",
"url"), but Thumbnail#as_json returns symbol keys while every other key in that
hash is a string. The inner lookup always missed, so thumbnail_url was nil for
every product and the app's Products tab showed a placeholder on every row.
No test caught it: the controller spec never asserted the field, and the product
schema declares it as ["string", "null"], so nil validated. Add assertions for a
product with a thumbnail and one without.
config/domain.rb hardcoded the development protocol, so CUSTOM_DOMAIN changed the
host but left the scheme as http. Rails then emitted asset URLs pointing at
http://app.localhost:3000, and every page the mobile app embeds in an HTTPS
WebView rendered blank with no error on either side. Add a CUSTOM_PROTOCOL
override next to the existing CUSTOM_DOMAIN and ASSET_DOMAIN ones. With the
variable unset, PROTOCOL resolves exactly as before.
Merged
7 tasks
Contributor
Greptile SummaryThe PR restores thumbnail URLs in the mobile products response and adds a pre-Rails-safe protocol override for local HTTPS.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| app/controllers/api/mobile/products_controller.rb | Correctly reads the symbol-keyed thumbnail URL while preserving nil for products without thumbnails. |
| config/domain.rb | Adds a normalized protocol override and keeps pre-Rails loading independent of ActiveSupport. |
| spec/config/domain_spec.rb | Covers unset, blank, whitespace, HTTPS, and pre-Rails custom-domain configurations. |
| spec/controllers/api/mobile/products_controller_spec.rb | Verifies both present and absent thumbnail behavior in the mobile API response. |
Reviews (3): Last reviewed commit: "Fall back when CUSTOM_PROTOCOL is only w..." | Re-trigger Greptile
…ActiveSupport-free Addresses review feedback on the CUSTOM_PROTOCOL override. An empty CUSTOM_PROTOCOL is truthy in Ruby, so it assigned "" to PROTOCOL and produced malformed URLs such as ://gumroad.com. Fall back to the environment default when the variable is unset or blank. The obvious fix, ENV["CUSTOM_PROTOCOL"].presence, cannot be used here. bin/vite requires this file before Rails boots, so ActiveSupport is not loaded and both presence and present? raise NoMethodError. That is also a pre-existing bug: bin/vite already crashes with "undefined method 'present?' for nil" whenever CUSTOM_DOMAIN is set, which is exactly the configuration this override is for. Replace that call with plain Ruby too, and record the constraint in a comment so the next edit does not reintroduce it.
gianfrancopiana
added a commit
to antiwork/gumroad-mobile
that referenced
this pull request
Aug 28, 2026
) ## What Replaces the 2015 App Store listing (Utilities, “watch what you buy”) with copy and **real iOS Simulator screenshots** that lead with product creation. Addresses [antiwork/gumroad-private#2320](antiwork/gumroad-private#2320). - Subtitle: `Create and sell on mobile` (26 chars) - Description leads with create / publish, then library playback. Fee line is 10% + 50¢. - Five 1290×2796 frames for the iPhone 6.7" slot. The phone UI in each is a live Simulator capture of the store build, not an HTML mock. - README notes moving the iOS category Utilities → Business (App Store Connect, not git) The App Store holds one screenshot set per device size per locale, so this **replaces** the live set rather than adding to it. Does not upload to App Store Connect or Play — assets only. Receipt / “open in the app” mailer copy is an `antiwork/gumroad` change, not this repo. ## The frames Each frame is a flat Gumroad brand color, one headline in ABC Favorit Bold (the app's own typeface, loaded from `assets/fonts/`), and a Simulator capture in a black bezel bleeding off the bottom edge. | Frame | Screen | Headline | Color | |---|---|---|---| | `01-create-product.png` | Products tab | Create a product from your phone. | `#FF90E8` pink | | `02-name-price.png` | Create product WebView | Name it, price it, go live. | `#FFC900` yellow | | `03-today.png` | Dashboard, top | See today before you open a laptop. | `#23A094` green | | `04-sales.png` | Dashboard, sale rows | Every sale, as it lands. | `#90A8ED` purple | | `05-library.png` | Library tab | Everything you bought, in one place. | `#F4F4F0` cream | Headlines end in a period, matching the voice on gumroad.com (“Place small bets.”, “Share your work.”). ## Why the frames changed The earlier set had three problems, all fixed here. **Every frame now shows a different screen.** Frames 1 and 2 previously shared one Products capture, and frames 3 and 4 shared one Dashboard capture — so frame 2 sold the create flow while showing a product list. **The data is presentable.** The old captures showed e2e seed data: “Mobile Test Product 1”, `mobile_buyer_do_not_edit@gumroad.com`, and $10 from 2 sales. Captures now come from a purpose-built marketing seller, `marketing_capture_seller@gumroad.com` (“Sable Studio”): five products priced $12–$49 with real cover art, **56 sales today totalling $1,392**, 119 sales across the past week, and three Library purchases from a second creator. Buyer emails and product names are invented. The `mobile_*_do_not_edit` accounts are deliberately untouched. **The frame design carries less chrome.** The logo and eyebrow chip are gone; one large headline reads faster at App Store thumbnail size. Type is the app's real ABC Favorit rather than a substitute. ## Reproducing `screenshots/CAPTURE.md` has the full procedure: local backend setup, seeding, Release-configuration build, per-screen capture, and rendering. `marketing-seed.rb` is the seed script. Every headline and color lives in `screenshots/frame.html`; `screenshots/render.sh` drives headless Chrome and writes the five PNGs. Two gotchas are written down because they cost real time: the create-product screen needs its “create with AI” promo banner dismissed and the screen reopened before the Name validation clears, and `expo run:ios` needs `LANG=en_US.UTF-8` or CocoaPods crashes. ## Backend bugs found on the way Capturing needed the app pointed at a local `antiwork/gumroad` backend. Two real problems surfaced, both confirmed on `antiwork/gumroad` `main`. They are fixed in [antiwork/gumroad#7422](antiwork/gumroad#7422), not this repo. Details in `store-listing/CAPTURE-BACKEND-NOTES.md`. 1. **`thumbnail_url` is always `nil` in the mobile products API.** `Thumbnail#as_json` returns symbol keys, but `Api::Mobile::ProductsController#product_json` reads `props.dig("thumbnail", "url")` with a string key. Every row in the app's Products tab renders the placeholder icon instead of the product cover. The test suite cannot catch it — the controller spec never asserts the field and the JSON schema permits `null`. 2. **`PROTOCOL` cannot be overridden, so local WebViews render blank.** `config/domain.rb` hardcodes `http` in development, so `CUSTOM_DOMAIN=gumroad.dev` still emits `http://app.localhost:3000` asset URLs. Inside the app's HTTPS WebView every embedded page renders blank, with no error in the app and none in the Rails log. ## Test Results - All five frames are 1290×2796 (App Store 6.7" slot) - `render.sh` verified end to end from the checked-in `simulator-raw/` captures and `assets/fonts/` - Frame 1 shows five products with cover art; frames 3 and 4 show $1,392 from 56 sales with buyer rows; frame 5 shows three Library purchases with creator avatars ## QA steps 1. Open the five PNGs — each shows a different screen, and none contains e2e seed data 2. Run `store-listing/screenshots/render.sh` and confirm it reproduces them 3. After merge: paste into App Store Connect and Play Console; flip the iOS category to Business ## Status - [x] Scope - [x] Design - [x] Build - [x] QA — all five frames reviewed at full size - [ ] Ship - [ ] Market - [ ] Sell Draft. Assets only — not uploaded to stores. --------- Co-authored-by: Sahil Lavingia <sahil@gumroad.com> Co-authored-by: Gianfranco Piana <gianfrancopiana@users.noreply.github.com>
The protocol override had no automated coverage: it was verified by hand. Add four examples to the existing config/domain.rb spec, covering an unset, blank, and set CUSTOM_PROTOCOL, plus a load with CUSTOM_DOMAIN present. They load the file the way bin/vite does — bundler and vite_ruby, nothing else — rather than through bin/rails runner. A runner boots Rails, which pulls in ActiveSupport and would hide the constraint these examples protect. Each fails without its fix: reverting the blank guard fails the blank example, and restoring present? fails the pre-Rails example with NoMethodError. These assert the resolved constant, not the URLs built from it. Verifying that a WebView loads its assets over HTTPS end to end still needs a running backend, so that stays a manual QA step.
Member
Author
Addresses further review feedback. `" "` passed the previous emptiness check and became the URL scheme, so absolute URLs came out as " ://gumroad.dev". Strip the value once when reading it. That also tolerates a stray space around an otherwise valid scheme.
Member
Author
gianfrancopiana
enabled auto-merge (squash)
August 28, 2026 19:25
gianfrancopiana
deleted the
fix/mobile-products-thumbnail-and-local-protocol
branch
August 28, 2026 19:30
7 tasks
gumclaw
added a commit
that referenced
this pull request
Sep 12, 2026
Premerge review: clean @ 1856a09 Draft. Round-2 mapper fix: importer-traced unions, boot-global config stays escalating. ## Checklist - [x] Scope — mapper-only: FANOUT/config/ignore in `bin/branch-specs`. Unknown still escalates. Not raising MAX_SELECTED_FILES. - [x] Design — a mapping is only safe if the selected specs cover every flow that imports/renders/reads the source. Harvest counts that matched a PR's other files are not evidence the new rule pointed at the right flow. Unions over 120 escalate. - [x] Build — harvest mappings, Greptile P1s, then Astra+Fable BLOCK findings on under-mapped consumers and boot-global config. - [x] QA — `ruby spec/bin/branch_specs_test.rb` → 99 checks passed, including importer-derived pins (`data/paypal` → `checkout/payment_spec.rb`, boot-global config ESCALATE). - [x] Shipped — CI green at this head. - [ ] Market — n/a, CI selector, no user-facing surface. - [ ] Sell — n/a. What `bin/branch-specs` maps a branch diff to the specs CI should run. When it cannot, it exits 3 and the PR has to carry `run-all-specs` (full Fast/Slow). This change teaches it the recurring, safe gaps from PRs labeled `run-all-specs` since 2026-08-01 — then corrects those maps so they follow importers, not the harvested PR's incidental files. Why Sahil, 2026-09-12: "Update run-all-specs based on prior runs to improve it and need it less." ## Review round 3 Shared components whose importer-complete union spans more than one flow (or exceeds 120 specs) escalate: RichTextEditor, TiptapExtensions, ImageUploader, ReviewForm / product_reviews / ReviewVideoPlayer, DateRangePicker, useRecaptcha, data/search.ts, `_email.scss`. Users/Show and Dashboard page mappings escalate. custom_html_analytics includes `spec/requests/profile_analytics_spec.rb`. Kept mappings: paypal→checkout, Discord union, Settings including OAuth/passkeys, EmailsPage including followers, Payouts, Coffee including tipping, articles.yml, Pages/Passwords, CONFIG_SPEC_MAP limited to rack_attack/alterity/instant_ddl_first/active_storage_jobs. ## Review round 2 Two independent reviewers (Astra, Fable) both BLOCKED round 1. The shared principle: "harvested from PR X" means the count reproduced, not that coverage is right. Several After counts came from the PR's other files, not from the new rule. What they caught and what changed: - `data/paypal` was mapped to Settings; it is the checkout billing-agreement module imported only by Checkout/PaymentForm. Now CHECKOUT_FLOW_SPECS. Pin: selects `checkout/payment_spec.rb`. - `custom_html_analytics` was seller analytics; the entrypoint is buyer product/profile custom HTML. Mapped to those specs plus `products/show`. - Generic `pages/<X>/` → `spec/requests/<x>*` dropped. Allowlisted verified dirs. `UrlRedirects/` → download_page. `Pages/` → pages_controller + landing embed. `User/Passwords/` → password_reset + login. Unlisted pages (e.g. Signup) escalate. - RichTextEditor/Tiptap → product + download + emails + workflows + profile + pages_controller (80 files, under 120). - DiscordButton / discord_integration → download + checkout (Receipt) + product Discord integrations spec. - ImageUploader also profile/settings avatar upload. Review family also library + customers. search also product_panel. Settings components also oauth applications pages. EmailsPage also followers. Users/Coffee also purchase coffee_spec. articles.yml also ArticleText + v2 help API. - Boot-global config reverted to ESCALATE: domain.rb, currencies.json, secure_headers, test_redis_isolation (required from application.rb), devise pwned-password Warden hook, filter_parameter_logging, sentry, OmniAuth Apple. CONFIG_SPEC_MAP kept only for local initializers (rack_attack, alterity, instant_ddl_first, active_storage_jobs). - docker/: ignore only production nginx / startup scripts / local compose. Test-image inputs (Dockerfile.test, compose-test-and-ci, docker/ci, fixture manifests) escalate so they cannot hide behind the user_spec floor. Honest harvest replay of the same 22 PRs against the corrected mapper: 19 of 118 no longer escalate (was 22). Three go back to full suite, which is the safe direction. Before → after (origin/main ESCALATE → this head) | PR | Before | After (round 1 claim) | After (importer-traced) | |---|---|---|---| | #7207 RichTextEditor | ESCALATE | 50 | 80 specs | | #7229 currencies.json | ESCALATE | 82 | ESCALATE again (boot-global pricing registry) | | #7259 DateRangePicker | ESCALATE | 11 | 11 specs | | #7260 docker/web/server.sh | ESCALATE | ignored | ignored (production startup, not rspec) | | #7267 data/search.ts | ESCALATE | 48 | 48 specs (now includes product_panel when that is the only change) | | #7332 ReviewForm | ESCALATE | 54 | 56 specs | | #7341 parsers/profile.ts | ESCALATE | 49 | 49 specs | | #7355 data/paypal.ts | ESCALATE | 92 | 77 checkout specs (92 was checkout files in that PR plus the wrong Settings map) | | #7375 articles.yml | ESCALATE | 3 | 5 specs | | #7404 Tiptap + _email.scss | ESCALATE | 74 | 104 specs | | #7422 config/domain.rb | ESCALATE | 2 | ESCALATE again (boot-global hosts/CORS/mailer) | | #7438 Payouts TSX | ESCALATE | 14 | 16 specs | | #7468 docker/nginx | ESCALATE | ignored | ignored (production nginx) | | #7473 Settings PayPalEmailSection | ESCALATE | 15 | 16 specs | | #7495 Payouts TSX | ESCALATE | 32 | 33 specs | | #7504 custom_html_analytics.ts | ESCALATE | 37 | 45 specs (buyer custom-HTML, not seller analytics) | | #7527 docker/nginx | ESCALATE | ignored | ignored | | #7535 TiptapExtensions | ESCALATE | 50 | 80 specs | | #7578 Emails/Published | ESCALATE | 6 | 6 specs | | #7580 Emails/Published | ESCALATE | 4 | 4 specs | | #7581 EmailsPage/shared | ESCALATE | 4 | 5 specs | | #7589 005_apple.rb + pwned initializer | ESCALATE | 3 | ESCALATE again (global OmniAuth / Warden hook) | #7229's 82 was from its Checkout/*.tsx files, not from mapping currencies.json. A currencies.json change still escalates even when checkout files sit beside it. Deliberately still escalating - `config/` except the four local initializers in CONFIG_SPEC_MAP - `components/Select.tsx` / `ui/Select.tsx` / Footer / inertia entry — union exceeds 120 - Unlisted `pages/` dirs (Signup, Affiliates, …) - docker test-image inputs - `spec/support/*` helpers other than dynamodb.rb - `db/*`, Gemfile, tests.yml Post-merge miss #7070 (checkout presenter → checkout/purchases/subscription request specs) is unchanged and still pinned. This PR only touches `bin/branch-specs` and `spec/bin/branch_specs_test.rb`, which the selector skips (standalone ruby test runs on every PR). It should not itself need `run-all-specs`. ## QA ``` cd <worktree> ruby spec/bin/branch_specs_test.rb # 99 checks passed ``` No user-facing surface. No preview. --- AI disclosure: Grok 4.6. Prompt: harvest run-all-specs PRs since 2026-08-01, classify mapper gaps from evidence, add FANOUT maps, keep unknown → ESCALATE. Round 2: fix Astra+Fable BLOCK findings by tracing importers and reverting boot-global config / CI docker inputs to escalate.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two fixes, both found while capturing App Store screenshots for the mobile app (antiwork/gumroad-mobile#358).
thumbnail_urlcame backnilfor every product, so each row rendered a placeholder.PROTOCOLhad no override, so pointing the mobile app at a local backend broke every embedded page.Why
The thumbnail was read with the wrong key type
Api::Mobile::ProductsController#product_jsonreadprops.dig("thumbnail", "url"). Every other key in that hash is a string, so this reads naturally — butThumbnail#as_jsonreturns symbol keys:The outer lookup succeeds, the inner one misses, and the result is always
nil.This is not environment-specific. The web dashboard reads the same hash, but only after Inertia serializes it to JSON, where symbol keys become strings — so only the mobile API, which dereferences it in Ruby, is affected.
No test caught it. The controller spec asserted
name,permalink,status,can_editandcan_destroybut neverthumbnail_url, and the product schema declares the field as["string", "null"], sonilvalidated.PROTOCOL could not be overridden
CUSTOM_DOMAINalready exists to point a local backend at a registrable hostname, and the mobile app's dev config expectshttps://gumroad.dev. ButCUSTOM_DOMAINonly changes the host — development stays onhttp, so Rails emits asset URLs likehttp://app.localhost:3000/vite-dev/entrypoints/base.ts.Inside the app's HTTPS WebView those are unreachable. Every embedded page — sign-in, create product, settings — renders blank, with no error in the app and none in the Rails log. The page returns 200; only its assets fail.
This adds a
CUSTOM_PROTOCOLoverride next to the existingCUSTOM_DOMAINandASSET_DOMAINones. Unset,PROTOCOLresolves exactly as before.A third bug, surfaced by review
Review suggested
ENV["CUSTOM_PROTOCOL"].presence. That cannot be used here:bin/viterequiresconfig/domain.rbbefore Rails boots, so ActiveSupport is not loaded andpresenceraisesNoMethodError.Line 108 already called
ENV["BRANCH_DEPLOYMENT"].present?, which meansbin/vitehas been crashing wheneverCUSTOM_DOMAINis set — the exact configuration this override pairs with:Both checks are now plain Ruby, with a comment recording the constraint so it is not reintroduced.
Before / After
Not user-visible on the web. The mobile-side effect is in antiwork/gumroad-mobile#358: its frame 01 shows the Products tab with cover art, which required this fix — before it, every row showed a placeholder box.
Test Results
bundle exec rspec spec/controllers/api/mobile/products_controller_spec.rb spec/config/domain_spec.rb— 18 examples, 0 failuresbundle exec rubocopon all changed files — no offensesCUSTOM_PROTOCOLexamplepresent?fails the pre-Rails example withNoMethodErrorstripfails the whitespace exampleNew coverage in
spec/config/domain_spec.rbloads the file the waybin/vitedoes — bundler and vite_ruby, nothing else — because abin/rails runnerboots Rails and would hide the ActiveSupport constraint.These assert the resolved constant, not the URLs built from it. Confirming a WebView loads assets over HTTPS end to end needs a running backend, so that stays manual.
QA steps
bundle exec rspec spec/controllers/api/mobile/products_controller_spec.rb spec/config/domain_spec.rbCUSTOM_PROTOCOLand confirm development still resolveshttpCUSTOM_DOMAIN=gumroad.dev CUSTOM_PROTOCOL=https ASSET_DOMAIN=gumroad.dev, front it with an HTTPS proxy on 443, then open Products → New in the mobile app. The create-product page renders instead of coming up blank.Note on CI
Compute relevant specsescalates here:bin/branch-specscannot attribute specs toconfig/domain.rb, so it asks for the full suite. Therun-all-specslabel is applied for that reason.I tried mapping
config/domain.rbintoCONFIG_SPEC_MAPto avoid the label, then reverted it. That mapping would drop the full-suite guard for every future change to the file, and its constants fan out widely —PROTOCOLalone is referenced in 37 files — whiledomain_spec.rbexercises no consumer specs (CSP, CORS, WebAuthn origins, URL generation). Escalating is correct here; the label is the intended mechanism.🤖 Written with Claude Fable 5 (Claude Code).
The model investigated both bugs, wrote the fixes and specs, and verified each new example fails without its fix. Two of the three bugs were found by it while setting up a local backend to capture App Store screenshots; the whitespace and blank-value guards came from Greptile review feedback, which it evaluated before accepting. Its proposed
.presencefix was rejected after testing showed it breaksbin/vite, and a self-inflicted change that would have weakened CI coverage was caught in review and reverted. All test results above were produced by real runs, not asserted.