Reach the whole draft settings panel from update_draft, cover image included - #21
Merged
Merged
Conversation
The Post settings panel holds nine settings; update_draft reaches one of them, and its audience enum refuses only_free, which the API accepts. Measured live on implementing.substack.com: the nine wire field names, the six fields that answer 200 and change nothing, the asymmetric validation (write_comment_permissions rejects without naming itself), and the fact that cover_image is not validated server-side at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven tasks: extract the image pipeline to a shared module, add an exact-hostname Substack check, the nine schema fields, the cover_image re-host, the derived no-fields message, the docs, and verification at both ends of the supported Node range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
update_draft needs the same download-and-encode path for cover_image, and a second copy of an SSRF guard is a second place to get it wrong. The error prefix becomes a neutral `image:` so update_draft does not report failures signed by a tool the caller never invoked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A cover read back from get_draft is already on substackcdn.com; re-hosting it would duplicate an asset Substack already serves. Exact match rather than a substring, or substackcdn.com.evil.example would pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine settings, each verified writable by a single-key PUT read back with a
GET. audience gains only_free, which the API accepts and the enum refused.
write_comment_permissions is an enum because Substack rejects a bad value with
{"error":"Something went wrong"}, naming neither the field nor the valid set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Substack stores whatever string it is given — "not-a-url-at-all" answered 200 — and server-fetches only its own bucket, so an external cover is accepted and never renders. The re-host runs before the PUT: a download failure must not leave the other fields written with the cover silently unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A scratch draft was driven through create_draft_post, update_draft with all nine settings and an external cover, get_draft and delete_draft using SUBSTACK_SESSION_TOKEN rather than the browser cookie. Every field read back as sent and the cover came back on Substack's S3 bucket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An earlier bad merge left "Two things follow from DELETE being shared with published posts:" introducing the publishing block, which is not about DELETE, while the two things it promised were welded onto the tail of an unrelated bullet and DELETE had lost its subject. The two facts move under their own premise and publishing becomes its own block. No technical claim changes: every word of the orphaned tail is preserved, and delete_draft still reads the draft to refuse an is_published target as described. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 changed
update_draftgoes from 3 settable fields to 11 — the draft editor's entire Post settings panel:audienceeveryone|only_paid|only_free|foundingwrite_comment_permissionseveryone|subscribers|only_paid|nonedefault_comment_sortbest_first|most_recent_first|oldest_firstcover_imagesocial_title,descriptionsearch_engine_title,search_engine_description,slugonly_freeis a bug fix, not an addition: the API accepts it and the editor offers it, but the enum refused it, so a legal audience was unreachable.The guarded-download pipeline moves out of
src/tools/upload_image.jsintosrc/api/substack/image.js, shared by both callers — a second copy of an SSRF guard is a second place to get it wrong. Its errors are prefixedimage:so acover_imagefailure is not signed by a tool the caller never invoked.upload_image.jsbecomes a thin tool over it and re-exportsisPrivateAddress/MAX_IMAGE_BYTESso its spec's imports keep working.Why it looks like this
Everything below was measured on
implementing.substack.comon 2026-08-08, one single-keyPUT /api/v1/drafts/:idat a time, each read back with aGET.postSchedules,language,email_from_name,is_draft_hidden,ai_detection_disabled,free_unlock_required. They stay off the schema sostrictObjecttells a model the key does not exist instead of letting it believe it scheduled a post. That is the seventh distinct silent-ignore in this API.audienceordefault_comment_sortanswers 400 naming the parameter; a badwrite_comment_permissionsanswers{"error":"Something went wrong"}with no field and no valid set. Its zod enum is the only diagnosis a caller will ever get.cover_imageis not validated at all — the literal string"not-a-url-at-all"was accepted with a 200 and stored. Since Substack server-fetches only its own bucket, an external cover is stored happily and never renders. So a URL onsubstack-post-media.s3.amazonaws.comorsubstackcdn.comis forwarded unchanged (re-hosting one would duplicate an asset Substack already serves) and anything else is downloaded and re-uploaded first.Deliberately out of scope, with reasons recorded in
CLAUDE.md: scheduling (not reachable through this endpoint at all),draft_section_id(the server validates the id, but this publication has no sections so the success path cannot be verified), andshould_send_email(publish_draftalready writes it as the publish intent — a second door onto the one flag that can mail the whole list is not worth the convenience).Test plan
npm testgreen on the.nvmrcruntime (Node 24) and on theenginesfloor (Node 22): 725 passing, 0 failingonly_freeaccepted — the regression the old enum had/api/v1/imagestrictObjectrejectspostSchedulesandlanguageby nameadditionalProperties: falsestill published ontools/list; the session token never reaches the log;npm packexcludes the new specLive-verified end to end, which also closes a gap
CLAUDE.mdrecorded as open: every earlier measurement used the browser session cookie, leavingSUBSTACK_SESSION_TOKENin a header throughSubstackApiuntested. A scratch draft was driven throughcreate_draft_post→update_draftwith all nine settings and an externalupload.wikimedia.orgcover →get_draft→delete_draftusing that env var. All 11 fields read back exactly as sent and the cover came back on Substack's S3 bucket. The scratch draft was deleted.For the reviewer
docs/superpowers/specs/2026-08-08-draft-settings-design.mdanddocs/superpowers/plans/2026-08-08-draft-settings.md.src/tools/upload_image.jsshrinks by ~120 lines with no changes to its spec — the four error fragments it asserts (/not an image/,/HEIC is not accepted/,/only http and https/,/over the .* limit/) all survive the prefix change. That is the check to be most sceptical of.POST /api/v1/imageis called withpost_id: nullfor the cover. The endpoint accepts apostId, but its effect is unconfirmed and was never measured against a draft, so it is not passed.CLAUDE.md's draft-lifecycle section promises "two things follow fromDELETEbeing shared with published posts" but an earlier bad merge welded two sentences together and one of the two lost its subject. Flagged separately rather than fixed here.🤖 Generated with Claude Code