fix(ghost): send source=html on html writes and repair scrutiny round-1 findings

Admin API writes carrying an html payload (create-post, update-post,
create-page) now attach the docs-required ?source=html query flag;
_post/_put gained params plumbing, and the create-post dry-run plan
includes params for plan/request parity. Regression tests pin the param
whenever an html payload is present (mocked request capture and dry-run
plans) and assert its absence on mobiledoc/lexical writes.

Docs: fix the jq interpolation typo in worked-recipes recipe 2 (missing
backslash made the scheduled-posts line exit 5), replace the blanket
"exit code 2" claim in admin-auth-and-basics with the script's actual
2-5 failure-class mapping, and state the source=html requirement in the
SKILL.md --html gotcha.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Magnus Hedemark
2026-08-29 19:53:50 -04:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 83e07b9ac2
commit a20b66e6c1
5 changed files with 136 additions and 12 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ JSON shapes worth knowing:
- **HS256 only, decoded-secret keying.** Tokens signed with HS512 are refused ("invalid algorithm"); signing without first hex-decoding the secret half produces "valid-looking" garbage that 401s. The CLI handles both rules. - **HS256 only, decoded-secret keying.** Tokens signed with HS512 are refused ("invalid algorithm"); signing without first hex-decoding the secret half produces "valid-looking" garbage that 401s. The CLI handles both rules.
- **`Authorization: Ghost`, not Bearer.** `Bearer` scheme answers 401 `INVALID_AUTH_HEADER`. - **`Authorization: Ghost`, not Bearer.** `Bearer` scheme answers 401 `INVALID_AUTH_HEADER`.
- **Edits require collision guards.** PUTs without the post's current `updated_at` fail with 409; relation arrays (`tags`, `authors`) replace wholesale rather than merge. - **Edits require collision guards.** PUTs without the post's current `updated_at` fail with 409; relation arrays (`tags`, `authors`) replace wholesale rather than merge.
- **HTML ingestion is lossy without cards.** Send proper Lexical, or wrap fixed markup in HTML card comments when using `--html`. - **`--html` requires `source=html` and stays lossy.** Every write carrying an `html` payload (create-post, update-post, create-page) must send the `?source=html` query flag — the CLI attaches it automatically, and dry-run plans show it in `params` — or Ghost parses the body as mobiledoc/lexical. Even with the flag, conversion is lossy: send proper Lexical, or wrap fixed markup in HTML card comments.
- **Pagination caps at 100** since Ghost 6 removed `limit=all`; oversized limits silently return ≤100 rows, so always loop by `next`. - **Pagination caps at 100** since Ghost 6 removed `limit=all`; oversized limits silently return ≤100 rows, so always loop by `next`.
- **Deletion is permanent** and takes effect on the public site immediately. - **Deletion is permanent** and takes effect on the public site immediately.
+1 -1
View File
@@ -88,7 +88,7 @@ Ghost returns JSON errors shaped like `{"errors": [{"message", "context", "type"
| Malformed token JSON/base64, `INVALID_JWT` | 400 | Structurally undecodable token | | Malformed token JSON/base64, `INVALID_JWT` | 400 | Structurally undecodable token |
| No auth at all → `Authorization failed`, type `NoPermissionError` | 403 | Missing `Authorization` header entirely | | No auth at all → `Authorization failed`, type `NoPermissionError` | 403 | Missing `Authorization` header entirely |
The CLI maps each of these to exit code 2 with the server message plus a hint. The CLI surfaces each of these on stderr with the server message plus a hint, mapped to exit codes by failure class: `401` and `403` exit `2` (auth/permission), `404` exits `3` (missing resource), `409` exits `4` (update collision), and `429` exits `5` (rate limited).
## Request conventions ## Request conventions
+1 -1
View File
@@ -40,7 +40,7 @@ Drafts, scheduled, and published live on different filters; one call per status:
```bash ```bash
ghost posts --status draft --limit 50 --json | jq -r '.posts[] | "\(.title)\t\(.slug)"' ghost posts --status draft --limit 50 --json | jq -r '.posts[] | "\(.title)\t\(.slug)"'
ghost posts --status scheduled --json | jq -r '.posts[] | "\(.title)\t(.published_at // "-")"' ghost posts --status scheduled --json | jq -r '.posts[] | "\(.title)\t\(.published_at // "-")"'
``` ```
Pair it with an authoring-wide sanity check via jq types before feeding slugs onward: Pair it with an authoring-wide sanity check via jq types before feeding slugs onward:
+19 -9
View File
@@ -210,11 +210,11 @@ class GhostClient:
def _get(self, path, params=None): def _get(self, path, params=None):
return self._request("get", path, params=params) return self._request("get", path, params=params)
def _post(self, path, json_data): def _post(self, path, json_data, params=None):
return self._request("post", path, json_data=json_data) return self._request("post", path, params=params, json_data=json_data)
def _put(self, path, json_data): def _put(self, path, json_data, params=None):
return self._request("put", path, json_data=json_data) return self._request("put", path, params=params, json_data=json_data)
def _delete(self, path): def _delete(self, path):
return self._request("delete", path) return self._request("delete", path)
@@ -241,10 +241,16 @@ class GhostClient:
post["slug"] = slug post["slug"] = slug
if published_at: if published_at:
post["published_at"] = published_at post["published_at"] = published_at
return self._post("/posts", {"posts": [post]}) # html payloads need the docs-required source=html flag; Ghost otherwise
# parses the body as mobiledoc/lexical and rejects or mangles it.
params = {"source": "html"} if html else None
return self._post("/posts", {"posts": [post]}, params=params)
def update_post(self, post_id, **kwargs): def update_post(self, post_id, **kwargs):
return self._put(f"/posts/{post_id}", {"posts": [kwargs]}) # html payloads need the docs-required source=html flag; Ghost otherwise
# parses the body as mobiledoc/lexical and rejects or mangles it.
params = {"source": "html"} if kwargs.get("html") else None
return self._put(f"/posts/{post_id}", {"posts": [kwargs]}, params=params)
def delete_post(self, post_id): def delete_post(self, post_id):
return self._delete(f"/posts/{post_id}") return self._delete(f"/posts/{post_id}")
@@ -261,7 +267,10 @@ class GhostClient:
page["html"] = html page["html"] = html
if slug: if slug:
page["slug"] = slug page["slug"] = slug
return self._post("/pages", {"pages": [page]}) # html payloads need the docs-required source=html flag; Ghost otherwise
# parses the body as mobiledoc/lexical and rejects or mangles it.
params = {"source": "html"} if html else None
return self._post("/pages", {"pages": [page]}, params=params)
def get_tags(self, limit=50, page=None): def get_tags(self, limit=50, page=None):
params: Dict[str, Any] = {"limit": limit, "include": "count.posts"} params: Dict[str, Any] = {"limit": limit, "include": "count.posts"}
@@ -339,9 +348,10 @@ def cmd_create_post(client, args):
slug=parsed.slug, published_at=parsed.published_at) or {} slug=parsed.slug, published_at=parsed.published_at) or {}
if data.get("dry_run"): if data.get("dry_run"):
# Plan and real request share one code path, so the previewed URL, # Plan and real request share one code path, so the previewed URL,
# method, and JSON envelope are exactly what execution would send. # method, params, and JSON envelope are exactly what execution would send.
plan = {"dry_run": True, "method": data.get("method"), plan = {"dry_run": True, "method": data.get("method"),
"url": data.get("url"), "json": data.get("json"), **vars(parsed)} "url": data.get("url"), "params": data.get("params"),
"json": data.get("json"), **vars(parsed)}
return emit(f"[dry-run] Create post '{parsed.title}' " return emit(f"[dry-run] Create post '{parsed.title}' "
f"-> {data.get('method', 'POST').upper()} {data.get('url')}", plan) f"-> {data.get('method', 'POST').upper()} {data.get('url')}", plan)
posts = data.get("posts", []) posts = data.get("posts", [])
+114
View File
@@ -123,6 +123,59 @@ class GhostCliTests(unittest.TestCase):
self.assertEqual(fields["updated_at"], "2026-08-26T12:00:00.000Z") self.assertEqual(fields["updated_at"], "2026-08-26T12:00:00.000Z")
self.assertEqual(fields["status"], "published") self.assertEqual(fields["status"], "published")
def test_create_post_dry_run_sends_source_html_only_with_html_payload(self):
# Ghost parses html write payloads as mobiledoc/lexical unless the
# docs-required ?source=html query flag rides along; the plan must
# preview exactly that request.
result = self.run_cli("--dry-run", "--json", "create-post",
"--title", "Doc", "--html", "<p>Hi</p>")
self.assertEqual(result.returncode, 0)
payload = json.loads(result.stdout)
self.assertEqual(payload["params"], {"source": "html"})
self.assertIn("html", payload["json"]["posts"][0])
no_html = self.run_cli("--dry-run", "--json", "create-post",
"--title", "Doc")
self.assertEqual(no_html.returncode, 0)
no_html_payload = json.loads(no_html.stdout)
self.assertNotIn("html", no_html_payload["json"]["posts"][0])
self.assertNotIn("source=html", no_html_payload["url"])
self.assertIsNone(no_html_payload["params"])
def test_update_post_dry_run_sends_source_html_only_with_html_payload(self):
result = self.run_cli("--dry-run", "--json", "update-post", "abc123",
"--html", "<p>Edited</p>",
"--updated-at", "2026-08-26T12:00:00.000Z")
self.assertEqual(result.returncode, 0)
payload = json.loads(result.stdout)
self.assertEqual(payload["params"], {"source": "html"})
self.assertIn("html", payload["fields"])
no_html = self.run_cli("--dry-run", "--json", "update-post", "abc123",
"--status", "draft",
"--updated-at", "2026-08-26T12:00:00.000Z")
self.assertEqual(no_html.returncode, 0)
no_html_payload = json.loads(no_html.stdout)
self.assertNotIn("html", no_html_payload["fields"])
self.assertNotIn("source=html", no_html_payload["url"])
self.assertIsNone(no_html_payload["params"])
def test_create_page_dry_run_sends_source_html_only_with_html_payload(self):
result = self.run_cli("--dry-run", "--json", "create-page",
"--title", "About", "--html", "<p>About us</p>")
self.assertEqual(result.returncode, 0)
payload = json.loads(result.stdout)
self.assertEqual(payload["params"], {"source": "html"})
self.assertIn("html", payload["json"]["pages"][0])
no_html = self.run_cli("--dry-run", "--json", "create-page",
"--title", "About")
self.assertEqual(no_html.returncode, 0)
no_html_payload = json.loads(no_html.stdout)
self.assertNotIn("html", no_html_payload["json"]["pages"][0])
self.assertNotIn("source=html", no_html_payload["url"])
self.assertIsNone(no_html_payload["params"])
def test_scheduled_post_requires_published_at_even_in_dry_run(self): def test_scheduled_post_requires_published_at_even_in_dry_run(self):
result = self.run_cli("--dry-run", "--json", "create-post", result = self.run_cli("--dry-run", "--json", "create-post",
"--title", "Later", "--status", "scheduled") "--title", "Later", "--status", "scheduled")
@@ -442,5 +495,66 @@ class MockedClientTests(unittest.TestCase):
self.assertEqual(called_url, "https://example.com/ghost/api/admin/site") self.assertEqual(called_url, "https://example.com/ghost/api/admin/site")
class HtmlSourceFlagTests(unittest.TestCase):
"""Regression: html write payloads must carry the docs-required
?source=html query flag; mobiledoc/lexical writes must not send it."""
def setUp(self):
self.cli = load_cli()
self.cli.GLOBAL_FLAGS = {"json": True, "dry_run": False,
"quiet": False, "verbose": False}
ok = Mock(status_code=201)
ok.json = Mock(return_value={"posts": []})
self.cli.requests.post = Mock(return_value=ok)
self.client = self.cli.GhostClient(url="https://example.com", key=FIXED_KEY)
def _put_ok(self):
ok = Mock(status_code=200)
ok.json = Mock(return_value={"posts": []})
self.cli.requests.put = Mock(return_value=ok)
return self.cli.requests.put
def test_create_post_with_html_sends_source_html_query_param(self):
self.client.create_post("Doc", html="<p>Hi</p>")
call = self.cli.requests.post.call_args
self.assertEqual(call.kwargs["params"], {"source": "html"})
self.assertIn("html", call.kwargs["json"]["posts"][0])
def test_create_post_without_html_omits_source_flag(self):
self.client.create_post("Doc")
call = self.cli.requests.post.call_args
self.assertNotIn("source", (call.kwargs.get("params") or {}))
self.assertNotIn("html", call.kwargs["json"]["posts"][0])
def test_update_post_with_html_sends_source_html_query_param(self):
put = self._put_ok()
self.client.update_post("abc123", html="<p>Edited</p>",
updated_at="2026-08-26T12:00:00.000Z")
call = put.call_args
self.assertEqual(call.kwargs["params"], {"source": "html"})
self.assertIn("html", call.kwargs["json"]["posts"][0])
def test_update_post_without_html_omits_source_flag(self):
put = self._put_ok()
self.client.update_post("abc123", status="published",
updated_at="2026-08-26T12:00:00.000Z")
call = put.call_args
self.assertNotIn("source", (call.kwargs.get("params") or {}))
self.assertNotIn("html", call.kwargs["json"]["posts"][0])
def test_create_page_with_html_sends_source_html_query_param(self):
self.client.create_page("About", html="<p>About us</p>")
call = self.cli.requests.post.call_args
self.assertEqual(call.kwargs["params"], {"source": "html"})
self.assertEqual(call.args[0], "https://example.com/ghost/api/admin/pages")
self.assertIn("html", call.kwargs["json"]["pages"][0])
def test_create_page_without_html_omits_source_flag(self):
self.client.create_page("About")
call = self.cli.requests.post.call_args
self.assertNotIn("source", (call.kwargs.get("params") or {}))
self.assertNotIn("html", call.kwargs["json"]["pages"][0])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()