Symptom
Opening a preview of an unpublished article draft fails. The only affordances the editor surfaces for a draft are the clickable slug and the "View" link in the save snackbar; both point at the ?p=<id> permalink placeholder, which the headless frontend has no route for, so it renders the site homepage instead of the draft. (Repro draft: post 1326, title "Rebuilding the City, not the Tower".)
There is no Gutenberg "Preview in new tab" affordance in this editor, so the preview_post_link filter (which is wired correctly) is never reached by the user.
Root cause — two independent problems
1. Production frontend can't authenticate the draft fetch (hard blocker)
/api/preview?id=<id> works: it validates the secret, enables draft mode, sets the preview cookies, and 307-redirects to /blog/<id>. But /blog/<id> then 404s, because the page calls getPostPreview(id) → wpQuery(..., { draft: true }) and gets null.
Verified against production:
- Unauthenticated GraphQL
post(1326, idType: DATABASE_ID) → { post: null } (WPGraphQL hides drafts).
- The same query with WP Application Password (Basic auth) → returns the draft (
status: draft). So the backend and the /graphql app-password opt-in filter are working.
Therefore the frontend's { draft: true } request is going out unauthenticated → published-only → draft resolves to nothing → 404. lib/wordpress/client.ts falls back to Basic auth from the server-only WP_APP_USERNAME / WP_APP_PASSWORD env vars and logs Draft WPGraphQL request without credentials when they're absent.
Fix: set WP_APP_USERNAME / WP_APP_PASSWORD in the production frontend runtime environment (server-only; a WP Application Password for a user with edit_posts). This is a deployment/config change, not code. Until it's in place, no preview path can render a draft.
2. The draft's slug / "View" link never enters the preview flow
includes/frontend-permalinks.php → cdcf_frontend_path_for() returns null for any non-publish status, so cdcf_frontend_permalink passes the default ?p=<id> permalink through unchanged for drafts. Since the editor only exposes the slug and the snackbar "View" (both derived from get_permalink()), the user has no link that routes to /api/preview. The file comment assumes drafts are "handled by preview_post_link", but that filter only feeds the Preview button, which isn't surfaced here.
Fix (needs a decision): route unpublished routable posts (post/page) from get_permalink() into the preview entry point. Two approaches, differing only in how the shared WP_PREVIEW_SECRET is handled:
- (A) WP redirect endpoint (preferred). Draft permalink → a WP
admin-post.php action (cookie-authenticated). It checks current_user_can('edit_post', $id), then redirects to the frontend preview entry point with the secret. The shared secret stays server-side — never baked into get_permalink() / REST link output — and is capability-gated. ~1 handler + filter branch.
- (B) Embed the preview URL in the permalink.
cdcf_frontend_permalink returns the frontend preview URL (with the secret) for unpublished posts, reusing the existing preview_post_link construction. Smallest change, but the shared secret then appears anywhere a draft's get_permalink() is read (block editor + authenticated edit-context REST link).
Acceptance criteria
Notes
🤖 Filed from a debugging session with Claude Code
Symptom
Opening a preview of an unpublished article draft fails. The only affordances the editor surfaces for a draft are the clickable slug and the "View" link in the save snackbar; both point at the
?p=<id>permalink placeholder, which the headless frontend has no route for, so it renders the site homepage instead of the draft. (Repro draft: post1326, title "Rebuilding the City, not the Tower".)There is no Gutenberg "Preview in new tab" affordance in this editor, so the
preview_post_linkfilter (which is wired correctly) is never reached by the user.Root cause — two independent problems
1. Production frontend can't authenticate the draft fetch (hard blocker)
/api/preview?id=<id>works: it validates the secret, enables draft mode, sets the preview cookies, and 307-redirects to/blog/<id>. But/blog/<id>then 404s, because the page callsgetPostPreview(id)→wpQuery(..., { draft: true })and getsnull.Verified against production:
post(1326, idType: DATABASE_ID)→{ post: null }(WPGraphQL hides drafts).status: draft). So the backend and the/graphqlapp-password opt-in filter are working.Therefore the frontend's
{ draft: true }request is going out unauthenticated → published-only → draft resolves to nothing → 404.lib/wordpress/client.tsfalls back to Basic auth from the server-onlyWP_APP_USERNAME/WP_APP_PASSWORDenv vars and logsDraft WPGraphQL request without credentialswhen they're absent.Fix: set
WP_APP_USERNAME/WP_APP_PASSWORDin the production frontend runtime environment (server-only; a WP Application Password for a user withedit_posts). This is a deployment/config change, not code. Until it's in place, no preview path can render a draft.2. The draft's slug / "View" link never enters the preview flow
includes/frontend-permalinks.php→cdcf_frontend_path_for()returnsnullfor any non-publishstatus, socdcf_frontend_permalinkpasses the default?p=<id>permalink through unchanged for drafts. Since the editor only exposes the slug and the snackbar "View" (both derived fromget_permalink()), the user has no link that routes to/api/preview. The file comment assumes drafts are "handled bypreview_post_link", but that filter only feeds the Preview button, which isn't surfaced here.Fix (needs a decision): route unpublished routable posts (
post/page) fromget_permalink()into the preview entry point. Two approaches, differing only in how the sharedWP_PREVIEW_SECRETis handled:admin-post.phpaction (cookie-authenticated). It checkscurrent_user_can('edit_post', $id), then redirects to the frontend preview entry point with the secret. The shared secret stays server-side — never baked intoget_permalink()/ RESTlinkoutput — and is capability-gated. ~1 handler + filter branch.cdcf_frontend_permalinkreturns the frontend preview URL (with the secret) for unpublished posts, reusing the existingpreview_post_linkconstruction. Smallest change, but the shared secret then appears anywhere a draft'sget_permalink()is read (block editor + authenticated edit-context RESTlink).Acceptance criteria
get_permalink()/ REST output; the redirect is capability-gated.Notes
🤖 Filed from a debugging session with Claude Code