VayuPress Docs

ADR-0075 — Draft/Publish Workflow and Public-Surface Isolation

Status: Accepted
Date: 2026-06-22
Deciders: Ankush Choudhary Johal


Context

VayuPress had no native draft concept before v1.7.0. Operators working on in-progress articles were forced to either publish immediately or keep content out of the system entirely. There was also no systematic protection preventing a draft — once written to the DB — from leaking through the API, the on-disk render cache, or the comment API.

Three specific leak paths were identified during the v1.7.0 audit:

  • LEAK-1 (Critical): GET /api/v1/articles/{slug} returned the full article payload including draft content to any caller with the slug.
  • LEAK-2 (High): The write-queue worker pre-rendered and cached the HTML for any article job, including drafts, making draft content accessible at the static cache path even after an explicit unpublish.
  • LEAK-3 (Low): The comment submission and listing endpoints looked up the article by slug only, allowing the existence of a draft to be confirmed by observing the difference between a 404 (missing slug) and a 200/empty response (valid slug, no comments).

Decision

1. Schema — articles.status column (migration 030)

Add a status TEXT NOT NULL DEFAULT 'published' column to articles. The only accepted values are 'published' and 'draft'. An index on (status, created_at DESC) accelerates the public-list query. All existing rows default to 'published' — the upgrade is non-breaking.

2. Public surface contract

Every query that feeds a public surface must filter COALESCE(status,'published')='published'. Covered surfaces:

  • Article list API (/api/v1/articles)
  • Article page renderer (/{slug})
  • RSS feed (/feed.xml)
  • Sitemap (/sitemap.xml)
  • Full-text search index build
  • Related articles widget
  • Homepage article list

3. API visibility rule (LEAK-1 fix)

handleGetArticle returns 404 for any article whose status is 'draft' unless the request is authenticated (isAdminRequest). This makes drafts indistinguishable from missing articles for all external callers.

4. Render-cache guard (LEAK-2 fix)

The write-queue worker queries COALESCE(status,'published') from the DB immediately before writing the rendered HTML to the on-disk cache. Only 'published' articles are cached. A draft that transitions published → draft triggers a cache purge via render.CachePurge; the worker then skips the cache write for any subsequent job while the article remains a draft.

5. Comment-API guard (LEAK-3 fix)

Both handleCommentSubmit and handleCommentList resolve the article ID via a query that includes AND COALESCE(status,'published')='published'. A request targeting a draft slug receives 404 — the same response as a non-existent slug.

6. VayuOS post manager

A new operator page (/os/posts) lists all articles (published and draft) with status pills. Each row provides Edit and Publish/Unpublish actions. Status transitions call POST /os/api/posts/status (CSRF-protected), which updates the DB, purges the relevant render caches (posts/<slug>.html, homepage, tag pages, sitemap, feed), and logs an audit entry.


Consequences

  • Drafts are invisible to all public surfaces; only authenticated operators can observe them.
  • The on-disk cache never contains draft HTML; a cache-poisoning attack via a publish-then-draft sequence is not possible.
  • The comment API cannot be used to enumerate draft slug existence.
  • Migration 030 is backwards-compatible (all existing rows default to published).
  • Three integration tests (TestDraftNotLeakedVia*) permanently guard the three fixed leak paths.

Alternatives Considered

Separate drafts table — Rejected. Adds join complexity on every public query and complicates the audit trail. A status column is simpler and equally expressive for the two-state model needed.

Client-side draft storage — Rejected. Operator data would not survive browser sessions or device switches; no audit trail.