Skip to content

Return the product thumbnail in the mobile API, and allow a local HTTPS protocol - #7422

Merged
gianfrancopiana merged 4 commits into
mainfrom
fix/mobile-products-thumbnail-and-local-protocol
Aug 28, 2026
Merged

gianfrancopiana merged 4 commits into
mainfrom
fix/mobile-products-thumbnail-and-local-protocol

Conversation

@gianfrancopiana

@gianfrancopiana gianfrancopiana commented Aug 28, 2026

Copy link
Copy Markdown
Member

What

Two fixes, both found while capturing App Store screenshots for the mobile app (antiwork/gumroad-mobile#358).

  1. Product thumbnails were missing from the mobile Products tab. thumbnail_url came back nil for every product, so each row rendered a placeholder.
  2. Local WebViews rendered blank over HTTPS. PROTOCOL had 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_json read props.dig("thumbnail", "url"). Every other key in that hash is a string, so this reads naturally — but Thumbnail#as_json returns symbol keys:

def as_json(*)
  { url:,
    guid:
  }
end

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_edit and can_destroy but never thumbnail_url, and the product schema declares the field as ["string", "null"], so nil validated.

PROTOCOL could not be overridden

CUSTOM_DOMAIN already exists to point a local backend at a registrable hostname, and the mobile app's dev config expects https://gumroad.dev. But CUSTOM_DOMAIN only changes the host — development stays on http, so Rails emits asset URLs like http://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_PROTOCOL override next to the existing CUSTOM_DOMAIN and ASSET_DOMAIN ones. Unset, PROTOCOL resolves exactly as before.

A third bug, surfaced by review

Review suggested ENV["CUSTOM_PROTOCOL"].presence. That cannot be used here: bin/vite requires config/domain.rb before Rails boots, so ActiveSupport is not loaded and presence raises NoMethodError.

Line 108 already called ENV["BRANCH_DEPLOYMENT"].present?, which means bin/vite has been crashing whenever CUSTOM_DOMAIN is set — the exact configuration this override pairs with:

$ CUSTOM_DOMAIN=gumroad.dev ruby -e '... require_relative "config/domain"'
NoMethodError: undefined method 'present?' for nil

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 failures
  • bundle exec rubocop on all changed files — no offenses
  • Every new example was checked against its bug by reverting the fix and confirming the failure:
    • reverting the key type fails "returns the thumbnail url"
    • reverting the blank guard fails the blank-CUSTOM_PROTOCOL example
    • restoring present? fails the pre-Rails example with NoMethodError
    • reverting strip fails the whitespace example

New coverage in spec/config/domain_spec.rb loads the file the way bin/vite does — bundler and vite_ruby, nothing else — because a bin/rails runner boots 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

  1. bundle exec rspec spec/controllers/api/mobile/products_controller_spec.rb spec/config/domain_spec.rb
  2. Unset CUSTOM_PROTOCOL and confirm development still resolves http
  3. For the WebView fix end to end: run this backend with CUSTOM_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 specs escalates here: bin/branch-specs cannot attribute specs to config/domain.rb, so it asks for the full suite. The run-all-specs label is applied for that reason.

I tried mapping config/domain.rb into CONFIG_SPEC_MAP to 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 — PROTOCOL alone is referenced in 37 files — while domain_spec.rb exercises 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 .presence fix was rejected after testing showed it breaks bin/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.

…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.
@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR restores thumbnail URLs in the mobile products response and adds a pre-Rails-safe protocol override for local HTTPS.

  • Reads the symbol-keyed thumbnail URL from the presenter payload.
  • Normalizes blank and whitespace-only protocol overrides to the environment default.
  • Replaces an ActiveSupport-dependent environment check with plain Ruby.
  • Adds focused controller and domain configuration coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

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

Comment thread config/domain.rb Outdated
Comment thread config/domain.rb Outdated
@gianfrancopiana gianfrancopiana self-assigned this Aug 28, 2026
…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>
@gianfrancopiana gianfrancopiana added the run-all-specs Run the full Fast/Slow test suite on this PR instead of the trimmed Test Relevant set label Aug 28, 2026
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.
@gianfrancopiana

Copy link
Copy Markdown
Member Author

@greptileai

Comment thread config/domain.rb Outdated
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.
@gianfrancopiana

Copy link
Copy Markdown
Member Author

@greptileai

@gianfrancopiana
gianfrancopiana enabled auto-merge (squash) August 28, 2026 19:25
@gianfrancopiana
gianfrancopiana merged commit d1b9ee4 into main Aug 28, 2026
99 checks passed
@gianfrancopiana
gianfrancopiana deleted the fix/mobile-products-thumbnail-and-local-protocol branch August 28, 2026 19:30
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-all-specs Run the full Fast/Slow test suite on this PR instead of the trimmed Test Relevant set

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants