postplan
v0.0.5
Published
Static HTML draft publishing for agents.
Readme
Postplan
Postplan is a small service and CLI for publishing static HTML drafts from agents.
CLI
Upload a draft:
npx postplan upload ./plan.htmlAttach an optional stable description (a short label shown in your dashboard and postplan list). Re-running with --description updates it; omitting it leaves the existing one untouched:
npx postplan upload ./plan.html --description "Q3 warehouse migration plan"Upload a folder of linked pages (it needs an index.html; pages link to each other with relative paths such as href="details.html"). Folder uploads need sign-in:
npx postplan auth login
npx postplan upload ./report/Publish at a custom URL (https://q3-plan.postplan.dev). The slug is the draft's identity: uploading with the same slug from any machine updates the same draft, and a free slug creates a new one. Add --draft <draft-id> to move an existing draft to the slug (its old slug, if any, is freed). --new cannot be combined with --draft or --slug. Custom URLs need the custom-urls account flag:
npx postplan upload ./plan.html --slug q3-plan
npx postplan upload ./plan.html --draft abc123def456 --slug q3-planAdd --json to get a machine-readable receipt: draft ID, slug, version, URL, raw URL, immutable version URL (built from the draft ID, so it survives slug changes), content hash, account, pages, and the exact updateCommand. Uploading identical content again returns the current version instead of creating a new one, so retries are safe.
Check readiness and validate a page without publishing anything. With no path it only reports the account, limits, and capabilities; with a path it also returns every issue with its line, column, and a short fix. --slug also reports whether a custom URL is free:
npx postplan check
npx postplan check ./plan.html --slug q3-plan --jsonIf the API cannot be reached, check falls back to the bundled markup validator and says so ("offline": true).
The CLI defaults to https://postplan.dev. Use --api-url http://localhost:3000 for a local or custom deployment.
API keys are optional for private/admin flows. Log in interactively (opens a browser page that mints a key you paste back — works over SSH, no localhost redirect):
npx postplan auth loginOr set a key directly:
npx postplan auth set <api-key>List the drafts published to your account (requires an API key). Each row shows the auto-linked git repo, latest version, total version count, and last-updated time:
npx postplan listThe CLI stores optional credentials and draft mappings in ~/.postplan. A mapping ties an absolute file path and API URL to a draft, so re-uploading the same path updates the same draft. Each mapping is its own file under ~/.postplan/drafts/, so concurrent uploads cannot overwrite each other's; mappings saved by older CLIs in ~/.postplan/drafts.json are still read. A corrupt state file is moved aside, not silently reset.
Environment
Required service variables:
DATABASE_URLPOSTPLAN_BOOTSTRAP_API_KEYAWS_ENDPOINT_URLAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_S3_BUCKET_NAMEAWS_DEFAULT_REGION
Optional service variables:
POSTPLAN_PUBLIC_BASE_URL- set to a normal base URL for/d/<draft-id>URLs, or a wildcard URL such ashttps://*.postplan.devfor draft subdomains.POSTPLAN_SESSION_SECRET- together withPOSTPLAN_PUBLIC_BASE_URL, enables web sign-in (the dashboard and/cli/auth). If either is absent, those routes return 503 and uploads/serving are unaffected.SHOO_BASE_URL- identity broker for web sign-in (defaulthttps://shoo.dev).MAX_HTML_BYTES- anonymous upload limit (default 512 KiB).MAX_ACCOUNT_HTML_BYTES- signed-in upload limit (default 4 MiB).MAX_LARGE_HTML_BYTES- limit for accounts with thelarge-uploadsflag (default 16 MiB).MAX_PAGES_PER_UPLOAD- pages per signed-in folder upload (default 50).UPLOAD_IP_RATE_LIMIT_WINDOW_MSUPLOAD_IP_RATE_LIMIT_MAXUPLOAD_RATE_LIMIT_WINDOW_MSUPLOAD_RATE_LIMIT_MAX
Uploads are public by default. Bearer API keys are still used for admin endpoints and authenticated ownership flows. An upload that presents a key the server rejects fails with 401 instead of falling back to an anonymous upload. The bootstrap key is inserted into Postgres on startup if present.
Size limits apply to the HTML of one upload (all pages combined) and depend on who uploads: anonymous, signed-in, or signed-in with the large-uploads flag.
Account flags
accounts.flags holds per-account features:
large-uploads- raises the upload limit toMAX_LARGE_HTML_BYTES.custom-urls- allows--slugcustom URLs.
Set them with SQL:
UPDATE accounts SET flags = ARRAY['large-uploads', 'custom-urls']
WHERE id IN (SELECT account_id FROM identities WHERE email = '[email protected]');postplan whoami and postplan check show an account's flags.
Dashboard & web sign-in
With POSTPLAN_SESSION_SECRET set, /dashboard lists the signed-in account's drafts (grouped by git repo, with descriptions and version history) and /cli/auth mints API keys for postplan auth login. Sign-in is delegated to shoo — postplan is auto-registered as a client by its origin, exchanges the OAuth code server-side (PKCE S256), verifies the ES256 id_token against shoo's JWKS, and keys accounts off the stable pairwise_sub claim (stored in the identities table). Postplan then runs its own 30-day HMAC-signed session cookie; it never sees Google credentials. Dashboard pages are apex-domain only — draft subdomains cannot serve them.
Sign-in requests shoo's pii consent, so each user approves sharing their email, name, and profile picture once. Those claims are stored on identities and overwritten from the token at every login — removing your picture or email at Google clears it here too (only the stable pii_subject identifier is retained across logins). The header shows the avatar and email. To find the email behind an upload, join draft_versions.created_by_api_key_id → api_keys.account_id → identities.email — nothing is denormalized onto version rows. Declining consent denies the sign-in.
An uploaded draft is attributed to whichever account's API key published it (anonymous uploads still work and stay public, but are not attributed to any account). GET /api/drafts returns the authenticated account's drafts — newest first, with each draft's description, auto-linked git repo, latest version number, and total version count — which is what postplan list and the dashboard read.
Each uploaded version also records provenance and audit metadata: the client IP (from Railway's X-Real-IP), the Railway request id (X-Railway-Request-Id, for correlating with network logs), git branch/commit/subject and whether the working tree was dirty, CI run URL and actor when published from CI, and content signals derived at upload time (whether the HTML contains inline script, and which external hosts its images load from). Git and CI values are self-reported by the client and are used for display and audit only — never for authorization.
Authenticated uploads may contain inline JavaScript: classic or type="module" <script> blocks and inline event-handler attributes such as onclick. Anonymous uploads containing JavaScript are rejected. Data blocks such as <script type="application/json"> never run, so they are allowed in any upload. External script sources, JavaScript URLs, forms, iframes/embeds, and meta-refresh redirects are rejected at upload time. <link> is allowed for https stylesheets, icons, and preconnects.
Run postplan auth login before uploading a scripted draft so the CLI sends an account API key.
Serving
Every draft URL serves the exact uploaded HTML, byte for byte, to every client — browsers, curl, agent fetch tools, and HTTP libraries alike. There is no browser detection, no wrapper page, and no consent interstitial: a draft URL is just its HTML, so an agent that fetches one always gets the content a user uploaded. Responses carry X-Postplan-Draft-Id and X-Postplan-Draft-Version headers.
Responses also set a Content-Security-Policy. The CSP never changes the bytes a client reads, so it does not gate content for curl or agents in any way. Script-free drafts keep script-src 'none'. Scripted drafts use sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox without allow-same-origin, so their inline code can update the document but runs with an opaque origin and cannot read Postplan cookies or storage. Links with target="_blank" can open new tabs, and destination pages do not inherit the draft's sandbox restrictions. Use rel="noopener noreferrer" on external links. External scripts, fetch/XHR, workers, frames, objects, and forms remain blocked in the draft.
Images, video, audio, and fonts may load from any https or data: URL, and stylesheets from any https URL, so assets hosted elsewhere embed directly. Postplan itself hosts only HTML.
The upload API returns both publicUrl and rawUrl, and the CLI prints the raw URL as Raw HTML. With wildcard draft domains the raw URL uses the stable apex form (https://postplan.dev/d/<draft-id>/raw). The /raw suffix is an alias kept for the API and CLI; it serves the same bytes as the canonical URL:
https://<draft-id>.postplan.dev/(or/raw)https://<draft-id>.postplan.dev/v/<n>/rawhttps://postplan.dev/d/<draft-id>/rawhttps://postplan.dev/d/<draft-id>/v/<n>/raw
A custom URL slug works anywhere a draft ID does (https://q3-plan.postplan.dev/, https://postplan.dev/d/q3-plan/raw). Deleting a draft frees its slug.
Pages of a multi-page draft are served below the draft root, by file name or clean URL: /details.html and /details serve details.html, and /docs/ serves docs/index.html. /v/<n>/details pins a version. Bare directory URLs such as /docs or /d/<draft-id> redirect to add a trailing slash, so relative links between pages resolve. Page paths cannot start with v, raw, api, or healthz.
Railway Provisioning
After railway login succeeds, provision and deploy the first-pass stack:
chmod +x scripts/provision-railway.sh
scripts/provision-railway.shThe script creates a Railway project, app service, Postgres service, object storage bucket, service variables, Railway domain, and first deployment. It stores the generated bootstrap API key under ~/.postplan/deployments/.
Verify the deployed service:
POSTPLAN_URL=https://your-railway-domain \
POSTPLAN_API_KEY=your-bootstrap-or-cli-key \
scripts/verify-deployment.shCreate a named key from the bootstrap key:
curl -X POST "$POSTPLAN_URL/api/api-keys" \
-H "Authorization: Bearer $POSTPLAN_BOOTSTRAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"local-cli"}'