Skip to content

Reach the whole draft settings panel from update_draft, cover image included - #21

Merged
marcomoauro merged 10 commits into
mainfrom
draft-settings
Aug 8, 2026
Merged

marcomoauro merged 10 commits into
mainfrom
draft-settings

Conversation

@marcomoauro

Copy link
Copy Markdown
Owner

What changed

update_draft goes from 3 settable fields to 11 — the draft editor's entire Post settings panel:

Field Values
audience everyone | only_paid | only_free | founding
write_comment_permissions everyone | subscribers | only_paid | none
default_comment_sort best_first | most_recent_first | oldest_first
cover_image any URL — re-hosted if it is not already on Substack
social_title, description the social preview
search_engine_title, search_engine_description, slug SEO Options

only_free is 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.js into src/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 prefixed image: so a cover_image failure is not signed by a tool the caller never invoked. upload_image.js becomes a thin tool over it and re-exports isPrivateAddress/MAX_IMAGE_BYTES so its spec's imports keep working.

Why it looks like this

Everything below was measured on implementing.substack.com on 2026-08-08, one single-key PUT /api/v1/drafts/:id at a time, each read back with a GET.

  • Six neighbouring fields answer 200 and change nothing — postSchedules, language, email_from_name, is_draft_hidden, ai_detection_disabled, free_unlock_required. They stay off the schema so strictObject tells 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.
  • Validation is asymmetric, and worst where it matters most. A bad audience or default_comment_sort answers 400 naming the parameter; a bad write_comment_permissions answers {"error":"Something went wrong"} with no field and no valid set. Its zod enum is the only diagnosis a caller will ever get.
  • cover_image is 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 on substack-post-media.s3.amazonaws.com or substackcdn.com is forwarded unchanged (re-hosting one would duplicate an asset Substack already serves) and anything else is downloaded and re-uploaded first.
  • The re-host runs before the PUT. A download failure must not leave the other eight fields written with the cover silently unchanged.

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), and should_send_email (publish_draft already 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 test green on the .nvmrc runtime (Node 24) and on the engines floor (Node 22): 725 passing, 0 failing
  • Each of the 11 fields reaches the PUT body under its wire name
  • only_free accepted — the regression the old enum had
  • A Substack-hosted cover produces no call to /api/v1/image
  • An external cover produces the upload then the PUT, with the S3 URL in the body
  • A failed re-host leaves no PUT at all
  • strictObject rejects postSchedules and language by name
  • The no-fields message is derived from the schema, so it cannot rot when a field is added
  • Every new assertion was broken on purpose and confirmed failing before being trusted — seven mutations, each verified to have landed with a grep first
  • additionalProperties: false still published on tools/list; the session token never reaches the log; npm pack excludes the new spec

Live-verified end to end, which also closes a gap CLAUDE.md recorded as open: every earlier measurement used the browser session cookie, leaving SUBSTACK_SESSION_TOKEN in a header through SubstackApi untested. A scratch draft was driven through create_draft_post → update_draft with all nine settings and an external upload.wikimedia.org cover → get_draft → delete_draft using 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

  • The design and the task-by-task plan are committed alongside the code, in docs/superpowers/specs/2026-08-08-draft-settings-design.md and docs/superpowers/plans/2026-08-08-draft-settings.md.
  • src/tools/upload_image.js shrinks 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/image is called with post_id: null for the cover. The endpoint accepts a postId, but its effect is unconfirmed and was never measured against a draft, so it is not passed.
  • Unrelated and left alone: CLAUDE.md's draft-lifecycle section promises "two things follow from DELETE being 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

marcomoauro and others added 10 commits August 8, 2026 14:34
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>
@marcomoauro
marcomoauro merged commit 80df2b4 into main Aug 8, 2026
2 checks passed
@marcomoauro
marcomoauro deleted the draft-settings branch August 8, 2026 13:02
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