commit 75719cefc0d593bafe27d1ab0923b4ea76a443c4 Author: Joshua Higgins Date: Fri Aug 21 20:44:06 2026 -0400 init diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..487dfa1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 t3dotgg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..93e6bfa --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Postplan + +Postplan is a small service and CLI for publishing static HTML drafts from agents. + +## CLI + +Upload a draft: + +```sh +npx postplan upload ./plan.html +``` + +Attach 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: + +```sh +npx postplan upload ./plan.html --description "Q3 warehouse migration plan" +``` + +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): + +```sh +npx postplan auth login +``` + +Or set a key directly: + +```sh +npx postplan auth set +``` + +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: + +```sh +npx postplan list +``` + +The CLI stores optional credentials and draft mappings in `~/.postplan`. + +## Environment + +Required service variables: + +- `DATABASE_URL` +- `POSTPLAN_BOOTSTRAP_API_KEY` +- `AWS_ENDPOINT_URL` +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` +- `AWS_S3_BUCKET_NAME` +- `AWS_DEFAULT_REGION` + +Optional service variables: + +- `POSTPLAN_PUBLIC_BASE_URL` - set to a normal base URL for `/d/` URLs, or a wildcard URL such as `https://*.postplan.dev` for draft subdomains. +- `POSTPLAN_SESSION_SECRET` - together with `POSTPLAN_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 (default `https://shoo.dev`). +- `MAX_HTML_BYTES` +- `UPLOAD_IP_RATE_LIMIT_WINDOW_MS` +- `UPLOAD_IP_RATE_LIMIT_MAX` +- `UPLOAD_RATE_LIMIT_WINDOW_MS` +- `UPLOAD_RATE_LIMIT_MAX` + +Uploads are public by default. Bearer API keys are still used for admin endpoints and authenticated ownership flows. The bootstrap key is inserted into Postgres on startup if present. + +## 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](https://github.com/pingdotgg/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. + +Uploaded HTML may contain inline classic JavaScript (``). External script sources, module scripts, inline event-handler attributes, JavaScript URLs, forms, iframes/embeds, and meta-refresh redirects are rejected at upload time. That upload-time policy is the safeguard; once stored, a draft is served verbatim. + +## 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; it only constrains what the page may do if a human opens it in a browser — `script-src 'none'` blocks script execution, `connect-src 'none'` blocks network requests, and `form-action 'none'` blocks form posts. + +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//raw`). The `/raw` suffix is an alias kept for the API and CLI; it serves the same bytes as the canonical URL: + +- `https://.postplan.dev/` (or `/raw`) +- `https://.postplan.dev/v//raw` +- `https://postplan.dev/d//raw` +- `https://postplan.dev/d//v//raw` + +## Railway Provisioning + +After `railway login` succeeds, provision and deploy the first-pass stack: + +```sh +chmod +x scripts/provision-railway.sh +scripts/provision-railway.sh +``` + +The 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: + +```sh +POSTPLAN_URL=https://your-railway-domain \ +POSTPLAN_API_KEY=your-bootstrap-or-cli-key \ +scripts/verify-deployment.sh +``` + +Create a named key from the bootstrap key: + +```sh +curl -X POST "$POSTPLAN_URL/api/api-keys" \ + -H "Authorization: Bearer $POSTPLAN_BOOTSTRAP_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"local-cli"}' +``` diff --git a/bin/postplan.js b/bin/postplan.js new file mode 100755 index 0000000..24b8473 --- /dev/null +++ b/bin/postplan.js @@ -0,0 +1,408 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { validateHtml } from "../src/html-policy.js"; + +// Single source of truth for the version: package.json. CI bumps it on every +// merge to main, so a hardcoded copy here would immediately drift. +const { version: VERSION } = createRequire(import.meta.url)("../package.json"); +const DEFAULT_API_URL = "https://postplan.dev"; +const POSTPLAN_DIR = path.join(os.homedir(), ".postplan"); +const CONFIG_PATH = path.join(POSTPLAN_DIR, "config.json"); +const CREDENTIALS_PATH = path.join(POSTPLAN_DIR, "credentials.json"); +const DRAFTS_PATH = path.join(POSTPLAN_DIR, "drafts.json"); + +class CliError extends Error {} + +const program = new Command(); + +program + .name("postplan") + .description("Upload static HTML drafts to Postplan.") + .version(VERSION); + +const authCommand = program.command("auth").description("Manage CLI authentication."); + +authCommand + .command("set") + .argument("", "Postplan API key") + .option("--api-url ", "Override the default Postplan API base URL") + .action((apiKey, options) => { + saveCredentials(apiKey, options.apiUrl); + console.log("Postplan credentials saved."); + }); + +authCommand + .command("login") + .description("Log in by pasting an API key from the browser. Works over SSH.") + .option("--api-url ", "Override the default Postplan API base URL") + .action(async (options) => { + const { apiUrl } = readAuth(options.apiUrl, { requireApiKey: false }); + + console.log("Open this in your browser (any device):\n"); + console.log(` ${apiUrl}/cli/auth\n`); + console.log("Sign in, generate a key, then paste it below.\n"); + + const readline = await import("node:readline/promises"); + const { once } = await import("node:events"); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + let apiKey; + try { + // rl.question never resolves if stdin closes (EOF/ctrl-d) — race the + // close event so that path hits the "No key entered" error below + // instead of exiting 0 silently. + apiKey = ( + await Promise.race([ + rl.question("Paste your API key: "), + once(rl, "close").then(() => "") + ]) + ).trim(); + } finally { + rl.close(); + } + + if (!apiKey) { + throw new CliError("No key entered. Nothing saved."); + } + + const response = await fetch(`${apiUrl}/api/me`, { + headers: { Authorization: `Bearer ${apiKey}` } + }); + const body = await response.json(); + if (!response.ok) { + throw new CliError(body.error || "That key was rejected. Nothing saved."); + } + + saveCredentials(apiKey, options.apiUrl); + console.log(`\nLogged in as ${body.accountName} (key: ${body.apiKeyName}).`); + }); + +program + .command("whoami") + .description("Check the configured Postplan credentials.") + .action(async () => { + const { apiUrl, apiKey } = readAuth(); + const response = await fetch(`${apiUrl}/api/me`, { + headers: { Authorization: `Bearer ${apiKey}` } + }); + const body = await response.json(); + if (!response.ok) { + throw new CliError(body.error || "Authentication failed."); + } + console.log(`Account: ${body.accountName} (${body.accountId})`); + console.log(`API key: ${body.apiKeyName} (${body.apiKeyId})`); + }); + +program + .command("upload") + .argument("", "HTML file path") + .option("--draft ", "Update a specific draft") + .option("--new", "Always create a new draft") + .option("--description ", "Set a short description for the draft") + .option("--api-url ", "Override the default Postplan API base URL") + .description("Upload or update an HTML draft.") + .action(async (file, options) => { + const resolvedFile = path.resolve(file); + const { apiUrl, apiKey } = readAuth(options.apiUrl, { requireApiKey: false }); + + if (!fs.existsSync(resolvedFile)) { + throw new CliError(`File does not exist: ${resolvedFile}`); + } + + const html = fs.readFileSync(resolvedFile, "utf8"); + const validation = validateHtml(html); + + if (!validation.ok) { + throw new CliError(`HTML failed Postplan validation:\n- ${validation.errors.join("\n- ")}`); + } + + const drafts = readDrafts(); + const knownDraft = drafts.files?.[resolvedFile]; + const draftId = options.new ? null : options.draft || knownDraft?.draftId || null; + + const payload = { + html, + filename: path.basename(resolvedFile), + draftId, + description: options.description, + metadata: { + ...collectGitMetadata(path.dirname(resolvedFile)), + ...collectCiMetadata(), + cliVersion: VERSION, + fileSha256: sha256(html) + } + }; + + const headers = { + "Content-Type": "application/json", + "User-Agent": `postplan/${VERSION}` + }; + if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const response = await fetch(`${apiUrl}/api/uploads`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + + const body = await response.json(); + if (!response.ok) { + const details = body.errors?.length ? `\n- ${body.errors.join("\n- ")}` : ""; + throw new CliError(`${body.error || "Upload failed."}${details}`); + } + + drafts.files ||= {}; + drafts.files[resolvedFile] = { + draftId: body.draftId, + publicUrl: body.publicUrl, + rawUrl: body.rawUrl || `${body.publicUrl.replace(/\/+$/, "")}/raw`, + latestVersionNumber: body.versionNumber, + updatedAt: new Date().toISOString() + }; + writeJson(DRAFTS_PATH, drafts, 0o600); + + console.log(draftId ? "Updated draft" : "Uploaded draft"); + console.log(`URL: ${body.publicUrl}`); + console.log(`Raw HTML: ${body.rawUrl || `${body.publicUrl.replace(/\/+$/, "")}/raw`}`); + console.log(`Draft ID: ${body.draftId}`); + console.log(`Version: ${body.versionNumber}`); + for (const warning of body.warnings || []) { + console.warn(`Warning: ${warning}`); + } + }); + +program + .command("list") + .description("List the drafts published to your account.") + .option("--api-url ", "Override the default Postplan API base URL") + .option("--json", "Print the raw JSON response") + .action(async (options) => { + const { apiUrl, apiKey } = readAuth(options.apiUrl); + const response = await fetch(`${apiUrl}/api/drafts`, { + headers: { Authorization: `Bearer ${apiKey}` } + }); + const body = await response.json(); + if (!response.ok) { + throw new CliError(body.error || "Failed to list drafts."); + } + + const drafts = body.drafts || []; + + if (options.json) { + console.log(JSON.stringify(drafts, null, 2)); + return; + } + + if (!drafts.length) { + console.log("No drafts yet. Publish one with: postplan upload "); + return; + } + + console.log(`Drafts (${drafts.length})\n`); + for (const draft of drafts) { + const repo = draft.repoOrg && draft.repoName ? `${draft.repoOrg}/${draft.repoName}` : "no repo"; + const version = draft.latestVersionNumber ? `v${draft.latestVersionNumber}` : "no versions"; + const count = `${draft.versionCount} version${draft.versionCount === 1 ? "" : "s"}`; + const disabled = draft.disabled ? " · disabled" : ""; + + console.log(draft.title || "Untitled Draft"); + console.log(` ${repo} · ${version} · ${count} · updated ${timeAgo(draft.updatedAt)}${disabled}`); + console.log(` ${draft.publicUrl}`); + if (draft.description) { + console.log(` ${draft.description}`); + } + console.log(""); + } + }); + +program.exitOverride(); + +program.parseAsync(process.argv).catch((error) => { + if (error instanceof CliError) { + console.error(error.message); + process.exit(1); + } + + if (error.code === "commander.helpDisplayed" || error.code === "commander.version") { + process.exit(0); + } + + console.error(error.message || error); + process.exit(1); +}); + +function readAuth(apiUrlOverride, { requireApiKey = true } = {}) { + const config = readJson(CONFIG_PATH, {}); + const credentials = readJson(CREDENTIALS_PATH, {}); + const apiUrl = ( + apiUrlOverride || + process.env.POSTPLAN_API_URL || + config.apiUrl || + DEFAULT_API_URL + ).replace(/\/+$/, ""); + const apiKey = process.env.POSTPLAN_API_KEY || credentials.apiKey; + + if (requireApiKey && !apiKey) { + throw new CliError("Missing API key. Run: postplan auth set "); + } + + return { apiUrl, apiKey }; +} + +function ensureStateDir() { + fs.mkdirSync(POSTPLAN_DIR, { recursive: true, mode: 0o700 }); +} + +function saveCredentials(apiKey, apiUrlOverride) { + ensureStateDir(); + + if (apiUrlOverride) { + writeJson(CONFIG_PATH, { + ...readJson(CONFIG_PATH, {}), + apiUrl: apiUrlOverride.replace(/\/+$/, "") + }); + } + + writeJson( + CREDENTIALS_PATH, + { + apiKey, + updatedAt: new Date().toISOString() + }, + 0o600 + ); +} + +function readDrafts() { + return readJson(DRAFTS_PATH, { files: {} }); +} + +function readJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return fallback; + } +} + +function writeJson(file, value, mode = 0o600) { + ensureStateDir(); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode }); + fs.chmodSync(file, mode); +} + +function collectGitMetadata(cwd) { + const repoRoot = git(["rev-parse", "--show-toplevel"], cwd); + const remote = git(["config", "--get", "remote.origin.url"], cwd); + const parsedRemote = parseRemote(remote); + const status = git(["status", "--porcelain"], cwd); + + return { + repoOrg: parsedRemote.org || inferOrgFromRoot(repoRoot), + repoName: parsedRemote.name || (repoRoot ? path.basename(repoRoot) : null), + repoHost: parsedRemote.host || null, + gitBranch: git(["rev-parse", "--abbrev-ref", "HEAD"], cwd), + gitCommitSha: git(["rev-parse", "HEAD"], cwd), + gitCommitSubject: git(["log", "-1", "--format=%s"], cwd), + // null when not a git repo; true/false when a working tree is present. + gitDirty: status === null ? null : status.length > 0 + }; +} + +// Best-effort CI provenance. GitHub Actions is detected precisely (with a run +// URL); other CI systems are flagged generically. Nothing here is trusted for +// authorization — it is metadata for the dashboard and audit trail only. +function collectCiMetadata() { + const env = process.env; + if (env.GITHUB_ACTIONS === "true") { + const server = env.GITHUB_SERVER_URL || "https://github.com"; + const repo = env.GITHUB_REPOSITORY; + const runId = env.GITHUB_RUN_ID; + return { + ciProvider: "github_actions", + ciRunUrl: repo && runId ? `${server}/${repo}/actions/runs/${runId}` : null, + ciActor: env.GITHUB_ACTOR || null + }; + } + if (env.CI) { + return { ciProvider: "unknown" }; + } + return {}; +} + +function git(args, cwd) { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + } catch { + return null; + } +} + +function parseRemote(remote) { + if (!remote) return {}; + + const cleaned = remote.replace(/\.git$/, ""); + const sshMatch = cleaned.match(/^[^@]+@([^:]+):([^/]+)\/(.+)$/); + if (sshMatch) { + return { host: sshMatch[1], org: sshMatch[2], name: path.basename(sshMatch[3]) }; + } + + try { + const url = new URL(cleaned); + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length >= 2) { + return { host: url.hostname, org: parts[0], name: parts.at(-1) }; + } + } catch { + // Fall through to path parsing. + } + + const parts = cleaned.split("/").filter(Boolean); + if (parts.length >= 2) { + return { org: parts.at(-2), name: parts.at(-1) }; + } + + return {}; +} + +function inferOrgFromRoot(repoRoot) { + if (!repoRoot) return null; + return path.basename(path.dirname(repoRoot)); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function timeAgo(value) { + if (!value) return "unknown"; + const then = new Date(value).getTime(); + if (Number.isNaN(then)) return "unknown"; + + const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000)); + const units = [ + ["year", 31_536_000], + ["month", 2_592_000], + ["week", 604_800], + ["day", 86_400], + ["hour", 3_600], + ["minute", 60] + ]; + + for (const [name, secs] of units) { + const amount = Math.floor(seconds / secs); + if (amount >= 1) return `${amount} ${name}${amount === 1 ? "" : "s"} ago`; + } + return "just now"; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a853e5 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "postplan", + "version": "0.0.4", + "description": "Static HTML draft publishing for agents.", + "type": "module", + "license": "MIT", + "files": [ + "bin/", + "src/", + "skills/", + "README.md" + ], + "bin": { + "postplan": "./bin/postplan.js" + }, + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.835.0", + "commander": "^14.0.0", + "express": "^5.1.0", + "jose": "^6.2.3", + "nanoid": "^5.1.5", + "parse5": "^7.3.0", + "pg": "^8.16.2" + } +} diff --git a/skills/postplan/SKILL.md b/skills/postplan/SKILL.md new file mode 100644 index 0000000..c348fb8 --- /dev/null +++ b/skills/postplan/SKILL.md @@ -0,0 +1,64 @@ +--- +name: postplan +description: Create and upload safe static HTML drafts to Postplan, or read and implement plans supplied as postplan.dev URLs. Use whenever a user provides a Postplan URL or asks to publish a plan, proposal, brief, architecture note, or similar artifact with Postplan. +--- + +# Postplan + +## Read a Postplan URL + +When a user supplies a `postplan.dev` URL, fetch the uploaded HTML immediately with the shell. Do not use web search or a browser to retrieve it. + +1. Remove a trailing slash, then append `/raw` unless the URL already ends in `/raw`. +2. Run `curl --fail --silent --show-error --location --max-time 30 --output /tmp/postplan.html ''`. +3. Read `/tmp/postplan.html` as the user's artifact and continue the requested task. + +A web-search refusal is not evidence that Postplan rejected the request. If `curl` fails, report its actual status or network error; do not substitute search results. + +## Document Rules + +Create one complete static HTML document. + +Allowed: + +- Semantic HTML. +- Inline CSS or a ` + +${header}${body} +`; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..05eccb8 --- /dev/null +++ b/src/render.js @@ -0,0 +1,81 @@ +export function renderHome({ publicBaseUrl }) { + return htmlPage({ + title: "Postplan", + body: ` +
+

Postplan

+

Authenticated static HTML draft publishing for agents.

+
npx postplan upload ./plan.html
+

My drafts · CLI setup

+

Health: /healthz

+

Public base URL: ${escapeHtml(publicBaseUrl || "not configured")}

+
+ ` + }); +} + +export function renderNotFound() { + return htmlPage({ + title: "Draft not found", + body: ` +
+

Draft not found

+

The requested draft is unavailable.

+
+ ` + }); +} + +function htmlPage({ title, body }) { + return ` + + + + + ${escapeHtml(title)} + + +${body} +`; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..7e53fad --- /dev/null +++ b/src/server.js @@ -0,0 +1,20 @@ +import { createApp } from "./api.js"; +import { config } from "./config.js"; +import { ensureBootstrapApiKey, initDb } from "./db.js"; +import { assertStorageConfigured } from "./storage.js"; + +async function main() { + assertStorageConfigured(); + await initDb(); + await ensureBootstrapApiKey(); + + const app = createApp(); + app.listen(config.port, () => { + console.log(`Postplan listening on port ${config.port}`); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/shoo.js b/src/shoo.js new file mode 100644 index 0000000..e7f3ccf --- /dev/null +++ b/src/shoo.js @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { config } from "./config.js"; +import { randomToken } from "./crypto.js"; + +// shoo (shoo.dev) protocol facts, extracted from its source: +// - Clients are auto-registered by redirect_uri origin; client_id is always +// derived as `origin:` and never needs a secret. +// - /authorize requires redirect_uri, state, code_challenge (S256 only). +// - /token takes application/x-www-form-urlencoded, and redirect_uri must be +// byte-identical to the one sent to /authorize. Codes are single-use, 120s. +// - The id_token is ES256; aud is `origin:`; the stable per-site user +// id is `pairwise_sub` (deterministic, survives revoke + re-auth). +// - The only error shoo redirects back is ?error=access_denied — everything +// else renders on shoo itself. + +let jwksCache = null; +let issuerCache = null; + +export function buildPkce() { + const verifier = randomToken(32); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge, state: randomToken(24) }; +} + +export function buildAuthorizeUrl({ redirectUri, state, challenge }) { + const url = new URL(`${config.shooBaseUrl}/authorize`); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + // Request profile claims (email/email_verified/name/picture/pii_sub). shoo + // shows a one-time consent screen per user; declining it denies the sign-in. + url.searchParams.set("pii", "true"); + return url.toString(); +} + +export async function exchangeCode({ code, verifier, redirectUri }) { + const response = await fetch(`${config.shooBaseUrl}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + redirect_uri: redirectUri, + code, + code_verifier: verifier + }), + signal: AbortSignal.timeout(10_000) + }); + + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(`shoo token exchange failed: ${body.error || response.status}`); + } + if (typeof body.id_token !== "string") { + throw new Error("shoo token exchange returned no id_token."); + } + return body; +} + +// Verifies the ES256 id_token against shoo's JWKS and returns its claims. +// audOrigin must be this deployment's public origin (e.g. https://postplan.dev). +export async function verifyIdToken(idToken, { audOrigin }) { + const audience = `origin:${new URL(audOrigin).origin}`; + const { payload } = await jwtVerify(idToken, getJwks(), { + issuer: await getIssuer(), + audience, + algorithms: ["ES256"] + }); + if (typeof payload.pairwise_sub !== "string" || !payload.pairwise_sub) { + throw new Error("shoo id_token is missing pairwise_sub."); + } + return payload; +} + +function getJwks() { + jwksCache ||= createRemoteJWKSet( + new URL(`${config.shooBaseUrl}/.well-known/jwks.json`) + ); + return jwksCache; +} + +// The issuer string is whatever shoo's discovery document says (it may differ +// from the base URL), so fetch it once instead of assuming. +async function getIssuer() { + issuerCache ||= (async () => { + const response = await fetch( + `${config.shooBaseUrl}/.well-known/openid-configuration`, + { signal: AbortSignal.timeout(10_000) } + ); + if (!response.ok) { + throw new Error(`shoo discovery failed: ${response.status}`); + } + const body = await response.json(); + if (typeof body.issuer !== "string") { + throw new Error("shoo discovery document has no issuer."); + } + return body.issuer; + })().catch((error) => { + issuerCache = null; + throw error; + }); + return issuerCache; +} + +// Test hook: reset module caches (jwks/issuer) between test servers. +export function resetShooCaches() { + jwksCache = null; + issuerCache = null; +} diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..8e0f9b4 --- /dev/null +++ b/src/storage.js @@ -0,0 +1,59 @@ +import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { config, requireEnv } from "./config.js"; + +let client; + +function getClient() { + if (client) return client; + + client = new S3Client({ + endpoint: requireEnv("AWS_ENDPOINT_URL", config.s3.endpoint), + region: requireEnv("AWS_DEFAULT_REGION", config.s3.region), + forcePathStyle: config.s3.forcePathStyle, + credentials: { + accessKeyId: requireEnv("AWS_ACCESS_KEY_ID", config.s3.accessKeyId), + secretAccessKey: requireEnv("AWS_SECRET_ACCESS_KEY", config.s3.secretAccessKey) + } + }); + + return client; +} + +export function assertStorageConfigured() { + requireEnv("AWS_ENDPOINT_URL", config.s3.endpoint); + requireEnv("AWS_ACCESS_KEY_ID", config.s3.accessKeyId); + requireEnv("AWS_SECRET_ACCESS_KEY", config.s3.secretAccessKey); + requireEnv("AWS_S3_BUCKET_NAME", config.s3.bucketName); +} + +export async function putHtmlObject(key, html) { + assertStorageConfigured(); + await getClient().send( + new PutObjectCommand({ + Bucket: config.s3.bucketName, + Key: key, + Body: html, + ContentType: "text/html; charset=utf-8", + CacheControl: "no-store" + }) + ); +} + +export async function getHtmlObject(key) { + assertStorageConfigured(); + const result = await getClient().send( + new GetObjectCommand({ + Bucket: config.s3.bucketName, + Key: key + }) + ); + return streamToString(result.Body); +} + +async function streamToString(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/web-auth.js b/src/web-auth.js new file mode 100644 index 0000000..61942db --- /dev/null +++ b/src/web-auth.js @@ -0,0 +1,115 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { config } from "./config.js"; + +export const SESSION_COOKIE = "postplan_session"; +export const AUTH_STATE_COOKIE = "postplan_auth_state"; +const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; +const AUTH_STATE_TTL_SECONDS = 10 * 60; + +// Compact HMAC-signed tokens (base64url(JSON payload) + "." + HMAC-SHA256), +// the same shape shoo uses for its own sessions. Stateless: nothing to store +// or clean up server-side, and a restart invalidates nothing. +export function signToken(payload, secret, ttlSeconds) { + const body = Buffer.from( + JSON.stringify({ ...payload, exp: nowSeconds() + ttlSeconds }) + ).toString("base64url"); + return `${body}.${hmac(body, secret)}`; +} + +export function verifyToken(token, secret) { + if (typeof token !== "string" || !token.includes(".")) return null; + const [body, signature] = token.split("."); + const expected = hmac(body, secret); + const a = Buffer.from(signature || ""); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + + try { + const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); + if (!Number.isFinite(payload.exp) || payload.exp < nowSeconds()) return null; + return payload; + } catch { + return null; + } +} + +export function createSessionCookie({ accountId, accountName, email, pictureUrl }) { + const token = signToken( + { accountId, accountName, email: email ?? null, pictureUrl: pictureUrl ?? null }, + requireSecret(), + SESSION_TTL_SECONDS + ); + return serializeCookie(SESSION_COOKIE, token, { maxAge: SESSION_TTL_SECONDS }); +} + +export function clearSessionCookie() { + return serializeCookie(SESSION_COOKIE, "", { maxAge: 0 }); +} + +export function createAuthStateCookie(payload) { + const token = signToken(payload, requireSecret(), AUTH_STATE_TTL_SECONDS); + return serializeCookie(AUTH_STATE_COOKIE, token, { maxAge: AUTH_STATE_TTL_SECONDS }); +} + +export function clearAuthStateCookie() { + return serializeCookie(AUTH_STATE_COOKIE, "", { maxAge: 0 }); +} + +export function readSession(req) { + if (!config.sessionSecret) return null; + const token = readCookie(req, SESSION_COOKIE); + if (!token) return null; + const payload = verifyToken(token, config.sessionSecret); + return payload?.accountId ? payload : null; +} + +export function readAuthState(req) { + if (!config.sessionSecret) return null; + const token = readCookie(req, AUTH_STATE_COOKIE); + return token ? verifyToken(token, config.sessionSecret) : null; +} + +export function readCookie(req, name) { + const header = req.get("cookie") || ""; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) { + // A malformed value (bad percent-escape) must read as "no cookie", not + // throw — otherwise one bad cookie 500s every web page until cleared. + try { + return decodeURIComponent(part.slice(eq + 1).trim()); + } catch { + return null; + } + } + } + return null; +} + +function serializeCookie(name, value, { maxAge }) { + const attributes = [ + `${name}=${encodeURIComponent(value)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${maxAge}` + ]; + if (process.env.NODE_ENV !== "development") attributes.push("Secure"); + return attributes.join("; "); +} + +function hmac(value, secret) { + return createHmac("sha256", secret).update(value).digest("base64url"); +} + +function requireSecret() { + if (!config.sessionSecret) { + throw new Error("POSTPLAN_SESSION_SECRET is not configured."); + } + return config.sessionSecret; +} + +function nowSeconds() { + return Math.floor(Date.now() / 1000); +} diff --git a/src/web.js b/src/web.js new file mode 100644 index 0000000..e22e609 --- /dev/null +++ b/src/web.js @@ -0,0 +1,287 @@ +import { config } from "./config.js"; +import { findOrCreateAccountForIdentity, pool } from "./db.js"; +import { newInternalId } from "./ids.js"; +import { clientIp } from "./client-ip.js"; +import { createRateLimiter } from "./rate-limit.js"; +import { randomToken, sha256 } from "./crypto.js"; +import { getAccountDraftWithVersions, listAccountDrafts } from "./drafts.js"; +import { getDraftIdFromHost, getHomeUrl, getRequestBaseUrl } from "./public-url.js"; +import { buildAuthorizeUrl, buildPkce, exchangeCode, verifyIdToken } from "./shoo.js"; +import { + clearAuthStateCookie, + clearSessionCookie, + createAuthStateCookie, + createSessionCookie, + readAuthState, + readSession +} from "./web-auth.js"; +import { + renderAuthError, + renderCliAuth, + renderCliAuthKey, + renderDashboard, + renderDraftDetail, + renderSignIn +} from "./render-web.js"; + +// Server-rendered web UI: shoo sign-in, the drafts dashboard, and the /cli/auth +// key page. Apex-domain only — on draft subdomains these paths fall through to +// the 404 handler so a draft origin can never serve dashboard UI. +export function registerWebRoutes(app) { + const web = [onlyApex, requireConfigured]; + const keyMintRateLimit = createRateLimiter({ + windowMs: Number(process.env.KEY_MINT_RATE_LIMIT_WINDOW_MS || 3_600_000), + max: Number(process.env.KEY_MINT_RATE_LIMIT_MAX || 10), + keyPrefix: "key-mint", + key: (req) => readSession(req)?.accountId || clientIp(req) || "anonymous" + }); + + app.get("/auth/sign-in", ...web, (req, res) => { + const { verifier, challenge, state } = buildPkce(); + const next = safeNextPath(req.query.next); + res.append( + "Set-Cookie", + createAuthStateCookie({ state, verifier, next }) + ); + res.redirect(buildAuthorizeUrl({ redirectUri: callbackUrl(), state, challenge })); + }); + + app.get("/auth/callback", ...web, async (req, res, next) => { + try { + res.append("Set-Cookie", clearAuthStateCookie()); + + // The only error shoo redirects back is user consent denial. + if (req.query.error === "access_denied") { + return res + .status(403) + .type("html") + .send( + renderAuthError({ + message: + "Sign-in was cancelled or consent was declined. Postplan uses your email and profile picture to identify your account — retry and approve to continue." + }) + ); + } + + const authState = readAuthState(req); + const { code, state } = req.query; + if (!authState || typeof state !== "string" || state !== authState.state) { + return res + .status(400) + .type("html") + .send(renderAuthError({ message: "Sign-in expired or state mismatch. Please retry." })); + } + if (typeof code !== "string" || !code) { + return res + .status(400) + .type("html") + .send(renderAuthError({ message: "Missing authorization code." })); + } + + // Exchange/verification failures are expected OAuth outcomes (expired + // or replayed 120s codes, shoo hiccups) — render a retryable page, not + // the JSON 500 handler. + let claims; + try { + const tokens = await exchangeCode({ + code, + verifier: authState.verifier, + redirectUri: callbackUrl() + }); + claims = await verifyIdToken(tokens.id_token, { audOrigin: webOrigin() }); + } catch (error) { + console.error("shoo sign-in failed:", error.message); + return res + .status(502) + .type("html") + .send(renderAuthError({ message: "Sign-in could not be completed. Please retry." })); + } + + const account = await findOrCreateAccountForIdentity({ + provider: "shoo", + subject: claims.pairwise_sub, + // Profile claims are present only with pii consent, and each is + // individually optional (depends on the Google profile). Blank or + // whitespace-only strings mean "absent", never a stored value. + profile: { + email: claimText(claims.email), + emailVerified: typeof claims.email_verified === "boolean" ? claims.email_verified : null, + displayName: claimText(claims.name), + pictureUrl: claimText(claims.picture), + piiSubject: claimText(claims.pii_sub) + } + }); + + res.append("Set-Cookie", createSessionCookie(account)); + res.redirect(safeNextPath(authState.next)); + } catch (error) { + next(error); + } + }); + + app.post("/auth/sign-out", onlyApex, (req, res) => { + res.append("Set-Cookie", clearSessionCookie()); + res.redirect("/"); + }); + + app.get("/dashboard", ...web, async (req, res, next) => { + try { + const session = readSession(req); + if (!session) { + return res.type("html").send(renderSignIn({ next: "/dashboard" })); + } + const drafts = await listAccountDrafts(session.accountId, { + requestBaseUrl: getRequestBaseUrl(req) + }); + res.type("html").send(renderDashboard({ session, drafts })); + } catch (error) { + next(error); + } + }); + + app.get("/dashboard/drafts/:draftId", ...web, async (req, res, next) => { + try { + const session = readSession(req); + if (!session) { + return res.type("html").send(renderSignIn({ next: "/dashboard" })); + } + const result = await getAccountDraftWithVersions(session.accountId, req.params.draftId, { + requestBaseUrl: getRequestBaseUrl(req) + }); + if (!result) return next(); + res.type("html").send( + renderDraftDetail({ + session, + draft: result.draft, + versions: result.versions + }) + ); + } catch (error) { + next(error); + } + }); + + app.get("/cli/auth", ...web, async (req, res, next) => { + try { + const session = readSession(req); + if (!session) { + return res.type("html").send(renderSignIn({ next: "/cli/auth" })); + } + res.type("html").send( + renderCliAuth({ + session, + keys: await listAccountApiKeys(session.accountId) + }) + ); + } catch (error) { + next(error); + } + }); + + // Mints a fresh named key for the signed-in account and shows it once. + // POST + SameSite=Lax session cookie keeps cross-site requests out. + app.post("/cli/auth/keys", ...web, keyMintRateLimit, async (req, res, next) => { + try { + const session = readSession(req); + if (!session) { + return res.type("html").send(renderSignIn({ next: "/cli/auth" })); + } + + const token = `pp_${randomToken(32)}`; + const keyName = `CLI · ${new Date().toISOString().slice(0, 10)}`; + await pool.query( + "INSERT INTO api_keys (id, account_id, name, key_hash) VALUES ($1, $2, $3, $4)", + [newInternalId(), session.accountId, keyName, sha256(token)] + ); + + res.type("html").send( + renderCliAuthKey({ session, token, keyName }) + ); + } catch (error) { + next(error); + } + }); + + app.post("/cli/auth/keys/:apiKeyId/revoke", ...web, async (req, res, next) => { + try { + const session = readSession(req); + if (!session) { + return res.type("html").send(renderSignIn({ next: "/cli/auth" })); + } + await pool.query( + ` + UPDATE api_keys + SET revoked_at = now() + WHERE id = $1 AND account_id = $2 AND revoked_at IS NULL + `, + [req.params.apiKeyId, session.accountId] + ); + res.redirect("/cli/auth"); + } catch (error) { + next(error); + } + }); +} + +async function listAccountApiKeys(accountId) { + const result = await pool.query( + ` + SELECT id, name, created_at, last_used_at + FROM api_keys + WHERE account_id = $1 AND revoked_at IS NULL + ORDER BY created_at DESC + `, + [accountId] + ); + return result.rows; +} + +// Web sign-in needs a session secret and a configured public base URL (the +// shoo redirect_uri must be a stable, exact string — never request-derived). +function requireConfigured(req, res, next) { + if (!config.sessionSecret || !config.publicBaseUrl) { + return res + .status(503) + .type("html") + .send( + renderAuthError({ + message: + "Web sign-in is not configured on this deployment (POSTPLAN_SESSION_SECRET / POSTPLAN_PUBLIC_BASE_URL)." + }) + ); + } + next(); +} + +function onlyApex(req, res, next) { + const draftId = getDraftIdFromHost({ + publicBaseUrl: config.publicBaseUrl, + host: req.hostname || req.get("host") + }); + if (draftId) return next("route"); + next(); +} + +function webOrigin() { + return getHomeUrl({ publicBaseUrl: config.publicBaseUrl, requestBaseUrl: "" }); +} + +function callbackUrl() { + return `${webOrigin()}/auth/callback`; +} + +function claimText(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +// Only allow same-site relative paths as post-login destinations, so the +// `next` param can never become an open redirect. +function safeNextPath(value) { + if (typeof value !== "string") return "/dashboard"; + if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) { + return "/dashboard"; + } + return value; +}