Skip to content

Add gumroad user page commands for the profile landing page#170

Merged
ershad merged 1 commit into
mainfrom
user-page-custom-html
Jun 24, 2026
Merged

Add gumroad user page commands for the profile landing page#170
ershad merged 1 commit into
mainfrom
user-page-custom-html

Conversation

@ershad

@ershad ershad commented Jun 24, 2026

Copy link
Copy Markdown
Member

What

Adds the gumroad user page command group so a seller can author their profile custom HTML landing page from the CLI, mirroring the existing gumroad products page group:

Command Method + endpoint Notes
gumroad user page preview [path] POST /user/preview_custom_html Dry-run sanitize; prints the sanitization_report without writing.
gumroad user page publish [path] PUT /user/custom_html Stores the page; reads stdin with -. Prints "Live at <profile_url>".
gumroad user page clear PUT /user/custom_html (empty body) Confirms first (--yes to skip).
gumroad user page url GET /user/custom_html Prints the public profile URL and its /landing/embed URL.

[path] defaults to ./landing.html. All global flags (--json, --jq, --dry-run, --plain, --quiet, --no-input) work as for products 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 to view_profile/edit_profile). That PR returns profile_url from the page endpoints specifically so publish can echo where the page is live and url can resolve it from the same endpoint — exactly how products page uses landing_url.

Naming: the issue that spawned this called it gumroad profile page, but the CLI has no profile group — the user group already holds profile operations (user update edits the profile name/bio), and the API path is /v2/user/.... So this lands as gumroad user page, mirroring products page (<noun> page) and keeping one group per entity. Easy to alias later if maintainers prefer profile.

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 in internal/pageutil and 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 — new internal/cmd/user package at 92.5% (gate 85%); full suite green (46 packages).

internal/cmd/user        92.5%   (preview/publish/clear/url: request shape, paths,
internal/pageutil        ...      stdin input, dry-run, JSON passthrough, plain output,
                                  rate-limit messages, confirmation gating, registration)

gofmt, go vet, staticcheck, and golangci-lint (v1.64.8, the CI-pinned version) all clean. Man pages regenerate via make man (the man/ dir is gitignored). Docs added to skills/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 pageutil patterns; behavior depends on the backend profile custom HTML API being deployed.

Overview
Adds gumroad user page under the existing user command so sellers can manage profile custom HTML from the CLI, parallel to gumroad products page but without a product id.

preview, publish, and clear reuse pageutil for HTML input (file, ./landing.html default, or stdin via -), sanitization output, and form params. They call POST /user/preview_custom_html and PUT /user/custom_html. clear is confirmation-gated (--yes). url GETs the same resource and prints profile_url plus a derived /landing/embed URL (with --plain tab output).

internal/pageutil gains ProfileTarget, profile response types, ProfileEmbedURL, and profile-specific rate-limit hint strings. skills/gumroad/SKILL.md documents 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.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

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-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

Adds gumroad user page (preview/publish/clear/url) for authoring a seller's profile custom HTML landing page, mirroring the existing gumroad products page group. All shared sanitization, HTML I/O, and rate-limit logic is reused from internal/pageutil; only the profile-specific types, target, embed-URL helper, and rate-limit constants are new.

  • New internal/cmd/user subcommands (page_preview, page_publish, page_clear, page_url) follow the established cobra + cmdutil.RunRequestDecoded pattern with 92.5% test coverage.
  • internal/pageutil/api.go gains ProfileTarget, ProfileUpdateResponse, ProfileShowResponse, ProfilePreviousHTML, ProfileEmbedURL, and three profile rate-limit constants.
  • skills/gumroad/SKILL.md is updated with trigger terms, response-key docs, and copy-paste usage examples for the new commands.

Confidence Score: 4/5

The 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

Filename Overview
internal/pageutil/api.go Adds ProfileTarget, ProfileUpdateResponse, ProfileShowResponse, ProfilePreviousHTML, ProfileEmbedURL, and three rate-limit constants. Two of the new constants are exact duplicates of existing ones; two new explanatory doc-comments violate the repo style guide.
internal/cmd/user/page.go Adds the page sub-command group wiring and shared arg/path helpers. Clean, mirrors the products/page.go pattern exactly.
internal/cmd/user/page_preview.go Implements user page preview via POST /user/preview_custom_html. Correctly delegates to pageutil helpers; no issues found.
internal/cmd/user/page_publish.go Implements user page publish via PUT /user/custom_html. Mirrors products publish; no issues found.
internal/cmd/user/page_clear.go Implements user page clear with confirmation guard, empty PUT, and rendered sanitization result. Correct and consistent with products/page_clear.go.
internal/cmd/user/page_url.go Implements user page url via GET /user/custom_html; prints profile URL and embed URL. Handles missing profile_url error; consistent with products/page_url.go (no rate-limit translation on read paths).
internal/cmd/user/user.go Registers newPageCmd() and adds an example; minimal and correct change.
skills/gumroad/SKILL.md Adds trigger terms, profile page rules, response key docs, and usage examples. Accurate and well-structured.

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}"
Loading
%%{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}"
Loading

Reviews (1): Last reviewed commit: "Add `gumroad user page` commands for the..." | Re-trigger Greptile

Comment thread internal/pageutil/api.go
Comment on lines +18 to +20
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."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

Comment thread internal/pageutil/api.go
Comment on lines +85 to +92
// 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"),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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!

ershad added a commit to antiwork/gumroad that referenced this pull request Jun 24, 2026
…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>
@ershad ershad merged commit a449903 into main Jun 24, 2026
6 checks passed
@ershad ershad deleted the user-page-custom-html branch June 24, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant