Add gumroad user page commands for the profile landing page#170
Conversation
Mirrors the `products page` command group for the seller's own profile custom HTML, backed by the new profile custom_html API (antiwork/gumroad#5569): gumroad user page preview [path] POST /user/preview_custom_html (dry-run sanitize) gumroad user page publish [path] PUT /user/custom_html (reads stdin with `-`) gumroad user page clear PUT /user/custom_html with an empty body (--yes to skip confirm) gumroad user page url GET /user/custom_html, prints the profile URL + its /landing/embed URL It lives under the existing `user` group (alongside `user update`, which edits the same profile) rather than a new top-level `profile` group, mirroring `products page` and matching the `/v2/user/...` API path. A profile has no buy button, so these commands carry no checkout flags and the rate-limit hints point back at `user page preview`. The shared sanitization render/IO/report code in `pageutil` is reused; only the profile target, response types, URL helpers, and rate-limit messages are new. Docs added to skills/gumroad/SKILL.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryAdds
Confidence Score: 4/5The feature is safe to merge once the backend PR (antiwork/gumroad#5569) is deployed; until then the endpoints 404. The implementation faithfully mirrors products page and the shared pageutil utilities are reused unchanged. The two findings are a pair of duplicate rate-limit string constants and two doc-comments that conflict with the repo's no-comments rule — neither affects runtime behaviour. internal/pageutil/api.go — duplicate constants and explanatory comments to clean up. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant CLI as gumroad user page
participant API as Gumroad API
CLI->>API: POST /user/preview_custom_html (custom_html)
API-->>CLI: "{custom_html, sanitization_report}"
CLI->>API: PUT /user/custom_html (custom_html)
API-->>CLI: "{custom_html, previous_custom_html, profile_url, sanitization_report}"
CLI->>API: "PUT /user/custom_html (custom_html=empty)"
API-->>CLI: "{custom_html: empty, previous_custom_html, profile_url, sanitization_report}"
CLI->>API: GET /user/custom_html
API-->>CLI: "{custom_html, has_landing_page, profile_url}"
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant CLI as gumroad user page
participant API as Gumroad API
CLI->>API: POST /user/preview_custom_html (custom_html)
API-->>CLI: "{custom_html, sanitization_report}"
CLI->>API: PUT /user/custom_html (custom_html)
API-->>CLI: "{custom_html, previous_custom_html, profile_url, sanitization_report}"
CLI->>API: "PUT /user/custom_html (custom_html=empty)"
API-->>CLI: "{custom_html: empty, previous_custom_html, profile_url, sanitization_report}"
CLI->>API: GET /user/custom_html
API-->>CLI: "{custom_html, has_landing_page, profile_url}"
Reviews (1): Last reviewed commit: "Add `gumroad user page` commands for the..." | Re-trigger Greptile |
| ProfilePublishRateLimitMessage = "Hit Gumroad's rate limit (30 PUTs/min). Use `gumroad user page preview` to iterate without burning your publish budget." | ||
| ProfilePreviewRateLimitMessage = "Hit Gumroad's rate limit (60 previews/min). Wait a moment before previewing again." | ||
| ProfileClearRateLimitMessage = "Hit Gumroad's rate limit (30 PUTs/min). Wait a moment before trying again." |
There was a problem hiding this comment.
ProfilePreviewRateLimitMessage and ProfileClearRateLimitMessage are byte-for-byte identical to the already-existing PreviewRateLimitMessage and ClearRateLimitMessage. Having two constants with the same value means any future wording change needs to be applied twice; if one is updated and the other isn't, they silently diverge.
| ProfilePublishRateLimitMessage = "Hit Gumroad's rate limit (30 PUTs/min). Use `gumroad user page preview` to iterate without burning your publish budget." | |
| ProfilePreviewRateLimitMessage = "Hit Gumroad's rate limit (60 previews/min). Wait a moment before previewing again." | |
| ProfileClearRateLimitMessage = "Hit Gumroad's rate limit (30 PUTs/min). Wait a moment before trying again." | |
| ProfilePublishRateLimitMessage = "Hit Gumroad's rate limit (30 PUTs/min). Use `gumroad user page preview` to iterate without burning your publish budget." |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| // ProfileTarget points at the seller's own profile landing page. Unlike a | ||
| // product there is no id — the API derives the seller from the access token. | ||
| func ProfileTarget() Target { | ||
| return Target{ | ||
| Path: cmdutil.JoinPath("user", "custom_html"), | ||
| PreviewPath: cmdutil.JoinPath("user", "preview_custom_html"), | ||
| } | ||
| } |
There was a problem hiding this comment.
Explanatory comments added against the style guide — CONTRIBUTING.md says "Don't leave comments in the code" / "No explanatory comments please." Two new block comments are added here (
ProfileTarget and ProfileEmbedURL). The information they contain (no id, token-derived seller, embed suffix) is not needed to call the function and belongs nowhere in a codebase that prohibits explanatory comments.
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
…nder) (#5569) ## What Extends the polymorphic custom-HTML `pages` system to seller **profiles**, so a creator can ship a fully custom public profile at `/:username` authored through their agent + CLI. This is the **Phase 2a backend** of #5553 (the custom-HTML layer on top of the `/profile` editor shipped in #5513). Because `pages` is already polymorphic (`pageable_type`/`pageable_id`), there is **no migration** — `Page` simply gets a `User` pageable. - **Model** — `User#custom_html`: `has_one :page, as: :pageable, dependent: :destroy, autosave: true`, `delegate :custom_html`, the build-or-clear `custom_html=` setter (copied from `Link`), and `has_custom_landing_page?`. - **Public render** — `/:username/landing/embed` plus the subdomain and custom-domain variants (paralleling the three `/l/:id/landing/embed` registrations). The profile `show` renders a sandboxed wrapper iframe; the embed serves the seller's HTML on an opaque origin under the strict `CUSTOM_HTML_CSP`, with the `Pages::Interpolator` profile context (`name`, `bio`). - **Public API v2** (the seller's agent surface), profile-scoped to `view_profile`/`edit_profile`: - `GET /v2/user/custom_html` → `{ custom_html, has_landing_page }` - `PUT /v2/user/custom_html` → row-lock on the user, `Ai::PageSanitizer.sanitize_with_report`, blank→nil normalization, returns `{ custom_html, previous_custom_html, sanitization_report }` for one-shot rollback - `POST /v2/user/preview_custom_html` → dry-run sanitize, no write - **Shared concern** — the CSP, the sandbox-storage shim, the inlined Tailwind, the response headers, and the `stick_to_primary_for_landing_iframe` read-replica fix move out of `LinksController` into `RendersCustomHtmlPages`, included by both controllers. The shared `custom_html_length_error` pre-sanitizer guard moves into `Api::V2::BaseController`. - Gated behind the existing `custom_html_pages` Feature flag. ### Profile-specific differences from product custom HTML A profile has no native buy button, so the profile surface deliberately **drops every checkout affordance**: - no `gumroad:checkout` postMessage bridge in the iframe document - no `?wanted=true` fall-through on `show` - no `data-gumroad-action="buy"` href rewriting in the interpolator - no buy-affordance warning on the API ### SEO Profile custom pages stay indexable (no `x-robots`) — the public profile is meant to be crawled, unlike a discount-coded product URL. ## Why `pages` was built polymorphic precisely so this layer could be added without touching the schema or duplicating the sanitizer/CSP/render machinery. The one real risk is **drift between the product and profile surfaces on the security-critical pieces** (the sandbox CSP, the opaque-origin shim). Pulling those into `RendersCustomHtmlPages` and including it in both controllers means a future CSP change lands in both places at once, by construction. The existing product custom-HTML specs still pass unchanged against the extracted concern. The row-lock on `PUT` mirrors the product API: concurrent `custom_html` writes would otherwise race on the `pages` unique index via `build_page`. `previous_custom_html` gives the agent a one-shot undo after an overwrite. ## Verification I ran a multi-agent adversarial review of the diff (independent correctness/security/KISS/DRY/conventions/test lenses, each finding verified by 3 independent skeptics). Verdict: security model sound and well-tested, no correctness defects in the rendered output. It caught one real CI break and a few loose ends, all fixed in the second commit: - The presenter spec asserts `profile_settings` with an exact RSpec `match`; I'd added a `custom_html` key without updating it. Since **nothing in the frontend consumes it yet** and it would ship up to 500 KB of page HTML in every Settings payload (and isn't whitelisted on save), I reverted both the presenter line and the `ProfileSettings` parser field and moved that wiring to the Phase 2b editor PR that will actually consume it. - Tightened DRY (`custom_html_length_error` → base controller; `stick_to_primary_for_landing_iframe` → the concern) and dropped dead code (`can_preview_custom_html?` is unreachable for profiles since inactive accounts 404 upstream; the `og_image` ternary could never take its empty branch). ## Before / After⚠️ **Video pending** — implemented in a headless environment, so I could not record one. Before merge this needs a screen recording per the contributing guide: a profile with `custom_html_pages` enabled rendering the custom landing at the profile URL (light/dark, desktop/mobile), plus an agent/`curl` round-trip against the v2 endpoints. QA steps for the preview app: 1. `Feature.activate_user(:custom_html_pages, seller)`. 2. `PUT /v2/user/custom_html` (token scoped `edit_profile`) with a `<section>…</section>` body → response echoes sanitized `custom_html` + `sanitization_report`. 3. Visit the seller's profile URL → renders inside `iframe#gumroad-landing-frame`; the embed (`/landing/embed`, or `/:username/landing/embed` on the root domain) carries `Content-Security-Policy: …sandbox…` and `X-Frame-Options: SAMEORIGIN`. 4. `PUT /v2/user/custom_html` with `null` → page clears, profile falls back to the native sections layout. ## Test Results New + affected specs pass locally (144 examples, 0 failures), including the unchanged product custom-HTML suite (61) run against the extracted concern + base-controller helper: ``` spec/presenters/profile_presenter_spec.rb ✓ (the CI break, now fixed) spec/controllers/links_controller_custom_html_spec.rb 27 ✓ (unchanged, against extracted concern) spec/controllers/api/v2/links_controller_custom_html_spec.rb 34 ✓ (unchanged, against base-controller helper) spec/controllers/users_controller_custom_html_spec.rb 17 ✓ spec/controllers/api/v2/users_controller_custom_html_spec.rb 26 ✓ spec/requests/profile_custom_html_spec.rb 4 ✓ (real custom-domain routing) spec/models/user/custom_html_spec.rb 6 ✓ spec/services/pages/interpolator_spec.rb 19 ✓ ``` `rubocop -a` and `eslint` clean on the diff. ## Out of scope / deferred to follow-ups - **CLI** — shipped in the separate CLI repo as `gumroad user page preview|publish|url|clear` (antiwork/gumroad-cli#170). It lands under the `user` group (not a new `profile` group) to mirror `products page` and the `/v2/user/...` API path. That PR depends on this one. - **Agent-prompt UI** — the copy-prompt / live-status / reset affordance + display-only preview on the `/profile` editor (Phase 2b frontend). The `ProfileSettings.custom_html` parser field and the presenter prop that feed it move here too, so they ship with their consumer. - **Rollout** — staged `custom_html_pages` enablement, rollout analytics, and creator-facing docs. - The internal (session-authed) PUT/preview endpoints are deferred with the editor UI that would consume them. Part of #5553. Builds on #5513 (Phase 1), #5063/#5309 (product custom HTML). --- 🤖 AI disclosure: implemented and self-reviewed with Claude Opus 4.8 (1M context) via Claude Code, including a multi-agent adversarial verification pass over the diff. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What
Adds the
gumroad user pagecommand group so a seller can author their profile custom HTML landing page from the CLI, mirroring the existinggumroad products pagegroup:gumroad user page preview [path]POST /user/preview_custom_htmlsanitization_reportwithout writing.gumroad user page publish [path]PUT /user/custom_html-. Prints "Live at <profile_url>".gumroad user page clearPUT /user/custom_html(empty body)--yesto skip).gumroad user page urlGET /user/custom_html/landing/embedURL.[path]defaults to./landing.html. All global flags (--json,--jq,--dry-run,--plain,--quiet,--no-input) work as forproducts page.Why
This is the CLI half of profile custom HTML. The backend API it targets is added in antiwork/gumroad#5569 (
GET/PUT /v2/user/custom_html,POST /v2/user/preview_custom_html, scoped toview_profile/edit_profile). That PR returnsprofile_urlfrom the page endpoints specifically sopublishcan echo where the page is live andurlcan resolve it from the same endpoint — exactly howproducts pageuseslanding_url.Naming: the issue that spawned this called it
gumroad profile page, but the CLI has noprofilegroup — theusergroup already holds profile operations (user updateedits the profile name/bio), and the API path is/v2/user/.... So this lands asgumroad user page, mirroringproducts page(<noun> page) and keeping one group per entity. Easy to alias later if maintainers preferprofile.Profile ≠ product: a profile has no native buy button, so these commands carry no checkout flags, and the rate-limit hints point back at
user page preview(not the product command).DRY
The sanitization render, HTML IO (file/stdin), report formatting,
HTMLParams/ClearParams, and rate-limit translation already live ininternal/pageutiland are reused unchanged. Only the profile-specific bits are new:ProfileTarget(),ProfileUpdateResponse/ProfileShowResponse,ProfileEmbedURL,ProfilePreviousHTML, and three profile rate-limit message constants.Test Results
make test-cover— newinternal/cmd/userpackage at 92.5% (gate 85%); full suite green (46 packages).gofmt,go vet,staticcheck, andgolangci-lint(v1.64.8, the CI-pinned version) all clean. Man pages regenerate viamake man(theman/dir is gitignored). Docs added toskills/gumroad/SKILL.md.Dependency
Requires antiwork/gumroad#5569 (the profile custom_html API) to be deployed; without it the endpoints 404 / return "no access to custom HTML pages".
🤖 AI disclosure: implemented with Claude Opus 4.8 (1M context) via Claude Code.
Note
Low Risk
New CLI surface with tests and shared
pageutilpatterns; behavior depends on the backend profile custom HTML API being deployed.Overview
Adds
gumroad user pageunder the existingusercommand so sellers can manage profile custom HTML from the CLI, parallel togumroad products pagebut without a product id.preview,publish, andclearreusepageutilfor HTML input (file,./landing.htmldefault, or stdin via-), sanitization output, and form params. They callPOST /user/preview_custom_htmlandPUT /user/custom_html.clearis confirmation-gated (--yes).urlGETs the same resource and printsprofile_urlplus a derived/landing/embedURL (with--plaintab output).internal/pageutilgainsProfileTarget, profile response types,ProfileEmbedURL, and profile-specific rate-limit hint strings.skills/gumroad/SKILL.mddocuments the new commands and JSON shapes. Broad HTTP/integration tests cover dry-run, JSON, rate limits, and command registration.Reviewed by Cursor Bugbot for commit 63ee7fe. Bugbot is set up for automated code reviews on this repo. Configure here.
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.