This commit is contained in:
2026-08-21 20:44:06 -04:00 Verified
commit 75719cefc0
22 changed files with 3005 additions and 0 deletions
+21
View File
@@ -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.
+117
View File
@@ -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 <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:
```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/<draft-id>` 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 (`<script>...</script>`). 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/<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>/raw`
- `https://postplan.dev/d/<draft-id>/raw`
- `https://postplan.dev/d/<draft-id>/v/<n>/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"}'
```
+408
View File
@@ -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("<api-key>", "Postplan API key")
.option("--api-url <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 <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("<file>", "HTML file path")
.option("--draft <draft-id>", "Update a specific draft")
.option("--new", "Always create a new draft")
.option("--description <text>", "Set a short description for the draft")
.option("--api-url <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 <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 <file>");
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 <api-key>");
}
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";
}
+33
View File
@@ -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"
}
}
+64
View File
@@ -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 '<raw-url>'`.
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 `<style>` block.
- Normal document metadata such as charset, viewport, and title.
- Links to ordinary HTTPS pages.
- Images from HTTPS or data URLs when necessary.
Do not include:
- JavaScript.
- `<script>` tags.
- Inline event handlers such as `onclick`, `onload`, or `onerror`.
- `javascript:` URLs.
- Forms.
- Iframes, embeds, objects, or applets.
- Meta refresh redirects.
- Secrets, tokens, private URLs, or local filesystem paths.
## Upload Flow
1. Write the HTML file locally.
2. Run:
```sh
npx postplan upload <file path>
```
3. Return the Postplan URL to the user.
The CLI prints both a draft URL and a `Raw HTML` URL. Either works for any client; hand the `Raw HTML` URL to another agent when you want the most explicit form.
If the same local file was uploaded before, the CLI updates the existing draft. To force a new draft, use:
```sh
npx postplan upload <file path> --new
```
Postplan stores CLI auth and draft mappings in `~/.postplan`.
## Viewer Behavior
Every Postplan URL serves the exact uploaded HTML, byte for byte, to every client — browsers, curl, and agent fetch tools alike. There is no wrapper page, sandbox, or consent step: fetching a Postplan URL always yields the draft content itself. The `/raw` suffix is an alias that returns the same bytes.
+575
View File
@@ -0,0 +1,575 @@
import express from "express";
import { contentHash, randomToken, sha256 } from "./crypto.js";
import { config } from "./config.js";
import { findApiKeyByToken, newEventId, pool, publicUploadAuth, withTransaction } from "./db.js";
import { newDraftId, newInternalId } from "./ids.js";
import { renderHome, renderNotFound } from "./render.js";
import { createRateLimiter } from "./rate-limit.js";
import { getHtmlObject, putHtmlObject } from "./storage.js";
import { validateHtml } from "./html-policy.js";
import { clientIp } from "./client-ip.js";
import { listAccountDrafts } from "./drafts.js";
import { registerWebRoutes } from "./web.js";
import {
getDraftIdFromHost,
getDraftPublicUrl,
getDraftRawUrl,
getHomeUrl,
getRequestBaseUrl
} from "./public-url.js";
export function createApp() {
const app = express();
app.set("trust proxy", true);
const uploadIpRateLimit = createRateLimiter({
windowMs: Number(process.env.UPLOAD_IP_RATE_LIMIT_WINDOW_MS || 60_000),
max: Number(process.env.UPLOAD_IP_RATE_LIMIT_MAX || 60),
keyPrefix: "upload-ip",
key: (req) => clientIp(req) || "anonymous"
});
const uploadKeyRateLimit = createRateLimiter({
windowMs: Number(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS || 60_000),
max: Number(process.env.UPLOAD_RATE_LIMIT_MAX || 30),
keyPrefix: "upload-key",
key: (req) => req.auth?.id || clientIp(req) || "anonymous"
});
// Scoped to /api so that draft GETs carrying a stray JSON body (some HTTP
// clients always send Content-Type: application/json) can never fail with a
// body-parser error instead of the draft HTML.
app.use("/api", express.json({ limit: process.env.UPLOAD_BODY_LIMIT || "2mb" }));
app.use(noStoreHeaders);
app.get("/", async (req, res, next) => {
try {
const draftId = getDraftIdFromRequest(req);
if (draftId) {
await renderDraft(req, res, { draftId });
return;
}
res.type("html").send(renderHome({ publicBaseUrl: getHomeUrlForRequest(req) }));
} catch (error) {
next(error);
}
});
app.get("/healthz", async (req, res) => {
try {
await pool.query("SELECT 1");
res.json({ ok: true });
} catch (error) {
res.status(503).json({ ok: false, error: error.message });
}
});
app.get("/api/me", requireAuth, (req, res) => {
res.json({
accountId: req.auth.account_id,
accountName: req.auth.account_name,
apiKeyId: req.auth.id,
apiKeyName: req.auth.name
});
});
// The "my docs" feed — shared with the dashboard (src/drafts.js).
app.get("/api/drafts", requireAuth, async (req, res, next) => {
try {
const drafts = await listAccountDrafts(req.auth.account_id, {
requestBaseUrl: getRequestBaseUrl(req)
});
res.json({ ok: true, drafts });
} catch (error) {
next(error);
}
});
app.post("/api/api-keys", requireAuth, async (req, res, next) => {
try {
const token = `pp_${randomToken(32)}`;
const apiKeyId = newInternalId();
const name = cleanText(req.body?.name) || "CLI API Key";
await pool.query(
`
INSERT INTO api_keys (id, account_id, name, key_hash)
VALUES ($1, $2, $3, $4)
`,
[apiKeyId, req.auth.account_id, name, sha256(token)]
);
res.status(201).json({
ok: true,
apiKey: {
id: apiKeyId,
name
},
token
});
} catch (error) {
next(error);
}
});
app.post("/api/api-keys/:apiKeyId/revoke", requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
`
UPDATE api_keys
SET revoked_at = now()
WHERE id = $1
AND account_id = $2
AND revoked_at IS NULL
RETURNING id
`,
[req.params.apiKeyId, req.auth.account_id]
);
if (!result.rowCount) {
return res.status(404).json({ ok: false, error: "API key not found." });
}
res.json({ ok: true });
} catch (error) {
next(error);
}
});
app.post("/api/uploads", uploadIpRateLimit, optionalUploadAuth, uploadKeyRateLimit, async (req, res, next) => {
try {
const { html, filename, metadata = {}, draftId, description } = req.body || {};
const validation = validateHtml(html, { maxBytes: config.maxHtmlBytes });
if (!validation.ok) {
return res.status(422).json({
ok: false,
errors: validation.errors,
warnings: validation.warnings
});
}
const byteLength = Buffer.byteLength(html, "utf8");
const nowHash = contentHash(html);
const sourceIp = clientIp(req);
// Railway's edge sets X-Railway-Request-Id and documents it for correlating
// against network logs, so we store it verbatim rather than minting our own.
const requestId = cleanText(req.get("x-railway-request-id"));
const stats = validation.stats || { hasInlineScript: false, externalImageHosts: [] };
const result = await withTransaction(async (client) => {
const existingDraft = draftId
? await findOwnedDraft(client, draftId, req.auth.account_id)
: null;
if (draftId && !existingDraft) {
const error = new Error("Draft not found.");
error.statusCode = 404;
throw error;
}
const draft = existingDraft || {
id: newDraftId(),
account_id: req.auth.account_id
};
const versionNumber = existingDraft
? Number(
(
await client.query(
"SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM draft_versions WHERE draft_id = $1",
[draft.id]
)
).rows[0].next_version
)
: 1;
const versionId = newInternalId();
const objectKey = `drafts/${draft.id}/versions/${versionId}.html`;
const title = validation.title || existingDraft?.title || filename || "Untitled Draft";
await putHtmlObject(objectKey, html);
if (!existingDraft) {
await client.query(
`
INSERT INTO drafts (id, account_id, title, description, repo_org, repo_name, repo_host)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
[
draft.id,
req.auth.account_id,
title,
cleanText(description, 1000),
cleanText(metadata.repoOrg),
cleanText(metadata.repoName),
cleanText(metadata.repoHost)
]
);
}
await client.query(
`
INSERT INTO draft_versions (
id, draft_id, version_number, object_key, content_hash, file_size,
created_by_api_key_id, source_ip, user_agent, cli_version,
git_branch, git_commit_sha, original_filename,
git_commit_subject, git_dirty, request_id, has_inline_script,
external_image_hosts, ci_run_url, ci_actor
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20)
`,
[
versionId,
draft.id,
versionNumber,
objectKey,
nowHash,
byteLength,
req.auth.id,
sourceIp,
req.get("user-agent") || null,
cleanText(metadata.cliVersion),
cleanText(metadata.gitBranch),
cleanText(metadata.gitCommitSha),
cleanText(filename),
cleanText(metadata.gitCommitSubject),
typeof metadata.gitDirty === "boolean" ? metadata.gitDirty : null,
requestId,
stats.hasInlineScript,
JSON.stringify(stats.externalImageHosts || []),
cleanText(metadata.ciRunUrl),
cleanText(metadata.ciActor)
]
);
await client.query(
`
UPDATE drafts
SET current_version_id = $1,
title = $2,
description = COALESCE($3, description),
repo_org = COALESCE($4, repo_org),
repo_name = COALESCE($5, repo_name),
repo_host = COALESCE($6, repo_host),
updated_at = now()
WHERE id = $7
`,
[
versionId,
title,
cleanText(description, 1000),
cleanText(metadata.repoOrg),
cleanText(metadata.repoName),
cleanText(metadata.repoHost),
draft.id
]
);
await client.query(
`
INSERT INTO upload_events (
id, draft_id, draft_version_id, api_key_id, event_type,
source_ip, user_agent, metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`,
[
newEventId(),
draft.id,
versionId,
req.auth.id,
existingDraft ? "draft.updated" : "draft.created",
sourceIp,
req.get("user-agent") || null,
metadata
]
);
return {
draftId: draft.id,
versionId,
versionNumber,
title,
requestId,
publicUrl: getDraftPublicUrl({
draftId: draft.id,
publicBaseUrl: config.publicBaseUrl,
requestBaseUrl: getRequestBaseUrl(req)
}),
rawUrl: getDraftRawUrl({
draftId: draft.id,
publicBaseUrl: config.publicBaseUrl,
requestBaseUrl: getRequestBaseUrl(req)
}),
warnings: validation.warnings
};
});
res.status(draftId ? 200 : 201).json({ ok: true, ...result });
} catch (error) {
next(error);
}
});
app.delete("/api/drafts/:draftId", requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
`
UPDATE drafts
SET deleted_at = now(), updated_at = now()
WHERE id = $1
AND account_id = $2
AND deleted_at IS NULL
RETURNING id
`,
[req.params.draftId, req.auth.account_id]
);
if (!result.rowCount) {
return res.status(404).json({ ok: false, error: "Draft not found." });
}
res.json({ ok: true });
} catch (error) {
next(error);
}
});
app.post("/api/drafts/:draftId/disable", requireAuth, async (req, res, next) => {
try {
const reason = cleanText(req.body?.reason) || "Disabled by owner.";
const result = await pool.query(
`
UPDATE drafts
SET disabled_at = now(), disabled_reason = $3, updated_at = now()
WHERE id = $1
AND account_id = $2
AND deleted_at IS NULL
RETURNING id
`,
[req.params.draftId, req.auth.account_id, reason]
);
if (!result.rowCount) {
return res.status(404).json({ ok: false, error: "Draft not found." });
}
res.json({ ok: true });
} catch (error) {
next(error);
}
});
registerWebRoutes(app);
// Every draft URL serves the raw uploaded HTML. The `/raw` aliases are kept
// because the upload API and CLI hand them out as the canonical agent URL.
const serveCurrent = async (req, res, next) => {
try {
const draftId = getDraftIdFromRequest(req);
if (!draftId) {
next();
return;
}
await renderDraft(req, res, { draftId });
} catch (error) {
next(error);
}
};
const serveVersion = async (req, res, next) => {
try {
const draftId = getDraftIdFromRequest(req);
if (!draftId) {
next();
return;
}
await renderDraft(req, res, {
draftId,
versionNumber: Number(req.params.versionNumber)
});
} catch (error) {
next(error);
}
};
app.get("/raw", serveCurrent);
app.get("/v/:versionNumber", serveVersion);
app.get("/v/:versionNumber/raw", serveVersion);
app.get(["/d/:draftId", "/d/:draftId/raw"], async (req, res, next) => {
try {
await renderDraft(req, res, { draftId: req.params.draftId });
} catch (error) {
next(error);
}
});
app.get(
["/d/:draftId/v/:versionNumber", "/d/:draftId/v/:versionNumber/raw"],
async (req, res, next) => {
try {
await renderDraft(req, res, {
draftId: req.params.draftId,
versionNumber: Number(req.params.versionNumber)
});
} catch (error) {
next(error);
}
}
);
app.use((req, res) => {
res.status(404).type("html").send(renderNotFound());
});
app.use((error, req, res, _next) => {
const status = error.statusCode || 500;
const message = status >= 500 ? "Internal server error." : error.message;
if (status >= 500) {
console.error(error);
}
res.status(status).json({ ok: false, error: message });
});
return app;
}
// Serve the exact uploaded HTML, byte for byte, to EVERY client — browsers,
// curl, and agent fetchers alike. There is deliberately no browser detection,
// iframe wrapper, or consent interstitial: any heuristic that tried to tell an
// agent from a browser inevitably misclassified some agent fetcher and hid the
// draft content the service exists to share. A draft URL is now just its HTML.
async function renderDraft(req, res, { draftId, versionNumber }) {
if (
versionNumber !== undefined &&
(!Number.isInteger(versionNumber) || versionNumber < 1)
) {
return res.status(404).type("html").send(renderNotFound());
}
const { draft, version } = await findPublicDraftVersion(draftId, versionNumber);
if (!draft || !version) {
return res.status(404).type("html").send(renderNotFound());
}
const html = await getHtmlObject(version.object_key);
res.setHeader("Content-Security-Policy", draftContentSecurityPolicy());
res.setHeader("X-Postplan-Draft-Id", draft.id);
res.setHeader("X-Postplan-Draft-Version", String(Number(version.version_number)));
res.type("html").send(html);
}
// A CSP is the only thing kept from the old serving path. It never alters the
// bytes a curl/agent client reads, so it does not gate content in any way; it
// only constrains what the page may do if a human opens it in a browser —
// blocking script execution, cross-origin network requests, and form posts.
// Uploaded drafts are already external-script/-form/-iframe free (see
// validateHtml) and live on isolated per-draft origins.
function draftContentSecurityPolicy() {
return [
"default-src 'none'",
"script-src 'none'",
"style-src 'unsafe-inline'",
"img-src https: data:",
"connect-src 'none'",
"base-uri 'none'",
"form-action 'none'"
].join("; ");
}
async function findPublicDraftVersion(draftId, versionNumber) {
const draftResult = await pool.query(
`
SELECT *
FROM drafts
WHERE id = $1
AND deleted_at IS NULL
AND disabled_at IS NULL
LIMIT 1
`,
[draftId]
);
const draft = draftResult.rows[0] || null;
if (!draft) return { draft: null, version: null };
const versionResult = versionNumber
? await pool.query(
`
SELECT *
FROM draft_versions
WHERE draft_id = $1 AND version_number = $2
LIMIT 1
`,
[draft.id, versionNumber]
)
: await pool.query("SELECT * FROM draft_versions WHERE id = $1 LIMIT 1", [
draft.current_version_id
]);
return { draft, version: versionResult.rows[0] || null };
}
async function findOwnedDraft(client, draftId, accountId) {
const result = await client.query(
`
SELECT *
FROM drafts
WHERE id = $1
AND account_id = $2
AND deleted_at IS NULL
LIMIT 1
`,
[draftId, accountId]
);
return result.rows[0] || null;
}
async function requireAuth(req, res, next) {
const auth = await optionalAuth(req);
if (!auth) {
return res.status(401).json({ ok: false, error: "Missing or invalid API key." });
}
req.auth = auth;
next();
}
async function optionalUploadAuth(req, _res, next) {
req.auth = (await optionalAuth(req)) || publicUploadAuth;
next();
}
async function optionalAuth(req) {
const header = req.get("authorization") || "";
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) return null;
return findApiKeyByToken(match[1].trim());
}
function noStoreHeaders(req, res, next) {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Cache-Control", "no-store");
next();
}
function getHomeUrlForRequest(req) {
return getHomeUrl({
publicBaseUrl: config.publicBaseUrl,
requestBaseUrl: getRequestBaseUrl(req)
});
}
function getDraftIdFromRequest(req) {
return getDraftIdFromHost({
publicBaseUrl: config.publicBaseUrl,
host: req.hostname || req.get("host")
});
}
function cleanText(value, maxLength = 255) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, maxLength) : null;
}
+16
View File
@@ -0,0 +1,16 @@
// Railway's edge sets `X-Real-IP` to the client's remote address, and documents
// it as the header for identifying the client IP:
// https://docs.railway.com/networking/public-networking/specs-and-limits
//
// It is the only trustworthy client-IP source behind Railway. Express's `req.ip`
// (with `trust proxy` enabled) reads the LEFT-MOST `X-Forwarded-For` entry, which
// a client can spoof by prepending a fake value — so it must not be used for
// abuse logging or rate-limit keys. We prefer `X-Real-IP` and only fall back to
// `req.ip` for non-Railway/local runs where the edge header is absent.
export function clientIp(req) {
const realIp = req.get?.("x-real-ip");
if (typeof realIp === "string" && realIp.trim()) {
return realIp.trim();
}
return req.ip || null;
}
+26
View File
@@ -0,0 +1,26 @@
export const config = {
port: Number(process.env.PORT || 3000),
databaseUrl: process.env.DATABASE_URL,
bootstrapApiKey: process.env.POSTPLAN_BOOTSTRAP_API_KEY,
publicBaseUrl: process.env.POSTPLAN_PUBLIC_BASE_URL,
maxHtmlBytes: Number(process.env.MAX_HTML_BYTES || 512 * 1024),
// Web sign-in (dashboard). Absent POSTPLAN_SESSION_SECRET, all web-auth
// routes respond 503 and the API/serving paths are unaffected.
sessionSecret: process.env.POSTPLAN_SESSION_SECRET,
shooBaseUrl: (process.env.SHOO_BASE_URL || "https://shoo.dev").replace(/\/+$/, ""),
s3: {
endpoint: process.env.AWS_ENDPOINT_URL || process.env.S3_ENDPOINT,
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY,
bucketName: process.env.AWS_S3_BUCKET_NAME || process.env.S3_BUCKET_NAME,
region: process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION || "auto",
forcePathStyle: (process.env.AWS_S3_FORCE_PATH_STYLE || "true") !== "false"
}
};
export function requireEnv(name, value) {
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
+13
View File
@@ -0,0 +1,13 @@
import { createHash, randomBytes } from "node:crypto";
export function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
export function contentHash(value) {
return sha256(value);
}
export function randomToken(bytes = 32) {
return randomBytes(bytes).toString("base64url");
}
+323
View File
@@ -0,0 +1,323 @@
import pg from "pg";
import { config, requireEnv } from "./config.js";
import { sha256 } from "./crypto.js";
import { newInternalId } from "./ids.js";
const { Pool } = pg;
export const publicUploadAuth = {
id: "key_public_upload",
account_id: "acct_public_upload",
name: "Public Uploads",
account_name: "Public Uploads"
};
export const pool = new Pool({
connectionString: requireEnv("DATABASE_URL", config.databaseUrl)
});
export async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS drafts (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
title TEXT NOT NULL,
description TEXT,
current_version_id TEXT,
repo_org TEXT,
repo_name TEXT,
repo_host TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
disabled_at TIMESTAMPTZ,
disabled_reason TEXT
);
CREATE TABLE IF NOT EXISTS draft_versions (
id TEXT PRIMARY KEY,
draft_id TEXT NOT NULL REFERENCES drafts(id),
version_number INTEGER NOT NULL,
object_key TEXT NOT NULL,
content_hash TEXT NOT NULL,
file_size INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by_api_key_id TEXT NOT NULL REFERENCES api_keys(id),
source_ip TEXT,
user_agent TEXT,
cli_version TEXT,
git_branch TEXT,
git_commit_sha TEXT,
git_commit_subject TEXT,
git_dirty BOOLEAN,
original_filename TEXT,
request_id TEXT,
has_inline_script BOOLEAN,
external_image_hosts JSONB,
ci_run_url TEXT,
ci_actor TEXT,
UNIQUE (draft_id, version_number)
);
CREATE TABLE IF NOT EXISTS upload_events (
id TEXT PRIMARY KEY,
draft_id TEXT NOT NULL REFERENCES drafts(id),
draft_version_id TEXT REFERENCES draft_versions(id),
api_key_id TEXT NOT NULL REFERENCES api_keys(id),
event_type TEXT NOT NULL,
source_ip TEXT,
user_agent TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS identities (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
provider TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT,
email_verified BOOLEAN,
display_name TEXT,
picture_url TEXT,
pii_subject TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ,
UNIQUE (provider, subject)
);
CREATE INDEX IF NOT EXISTS draft_versions_draft_id_idx ON draft_versions(draft_id);
CREATE INDEX IF NOT EXISTS upload_events_draft_id_idx ON upload_events(draft_id);
CREATE INDEX IF NOT EXISTS drafts_account_id_idx ON drafts(account_id);
-- Backfill columns for databases created before they were introduced.
ALTER TABLE identities ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE identities ADD COLUMN IF NOT EXISTS email_verified BOOLEAN;
ALTER TABLE identities ADD COLUMN IF NOT EXISTS display_name TEXT;
ALTER TABLE identities ADD COLUMN IF NOT EXISTS picture_url TEXT;
ALTER TABLE identities ADD COLUMN IF NOT EXISTS pii_subject TEXT;
ALTER TABLE drafts ADD COLUMN IF NOT EXISTS description TEXT;
ALTER TABLE drafts ADD COLUMN IF NOT EXISTS repo_host TEXT;
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS git_commit_subject TEXT;
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS git_dirty BOOLEAN;
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS request_id TEXT;
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS has_inline_script BOOLEAN;
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS external_image_hosts JSONB;
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS ci_run_url TEXT;
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS ci_actor TEXT;
`);
await ensurePublicUploadApiKey();
}
export async function ensureBootstrapApiKey() {
if (!config.bootstrapApiKey) return;
const accountId = "acct_bootstrap";
const apiKeyId = "key_bootstrap";
const keyHash = sha256(config.bootstrapApiKey);
await pool.query(
`
INSERT INTO accounts (id, name)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET updated_at = now()
`,
[accountId, "Bootstrap Account"]
);
await pool.query(
`
INSERT INTO api_keys (id, account_id, name, key_hash)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET key_hash = EXCLUDED.key_hash,
name = EXCLUDED.name,
revoked_at = NULL
`,
[apiKeyId, accountId, "Bootstrap API Key", keyHash]
);
}
export async function findApiKeyByToken(token) {
const keyHash = sha256(token);
const result = await pool.query(
`
SELECT api_keys.id, api_keys.account_id, api_keys.name, accounts.name AS account_name
FROM api_keys
JOIN accounts ON accounts.id = api_keys.account_id
WHERE api_keys.key_hash = $1
AND api_keys.id <> $2
AND api_keys.revoked_at IS NULL
LIMIT 1
`,
[keyHash, publicUploadAuth.id]
);
const apiKey = result.rows[0] || null;
if (apiKey) {
await pool.query("UPDATE api_keys SET last_used_at = now() WHERE id = $1", [apiKey.id]);
}
return apiKey;
}
async function ensurePublicUploadApiKey() {
await pool.query(
`
INSERT INTO accounts (id, name)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
updated_at = now()
`,
[publicUploadAuth.account_id, publicUploadAuth.account_name]
);
await pool.query(
`
INSERT INTO api_keys (id, account_id, name, key_hash)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET key_hash = EXCLUDED.key_hash,
name = EXCLUDED.name,
revoked_at = NULL
`,
[
publicUploadAuth.id,
publicUploadAuth.account_id,
publicUploadAuth.name,
sha256("postplan-public-upload-sentinel")
]
);
}
export async function withTransaction(work) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const value = await work(client);
await client.query("COMMIT");
return value;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export function newEventId() {
return newInternalId();
}
// Maps a verified external identity (e.g. shoo's pairwise_sub) to a postplan
// account, creating both on first sign-in. Profile claims (from shoo's pii
// consent) are refreshed on every login — they can change at Google any time.
// Returns { accountId, accountName, email, pictureUrl }.
export async function findOrCreateAccountForIdentity({ provider, subject, profile = {} }) {
try {
return await upsertIdentity({ provider, subject, profile });
} catch (error) {
// Two concurrent first sign-ins (e.g. two devices) can both miss the
// SELECT and collide on UNIQUE(provider, subject). The loser's transaction
// rolled back entirely (including its orphan account), so one retry hits
// the existing-identity path and succeeds.
if (error.code === "23505") {
return upsertIdentity({ provider, subject, profile });
}
throw error;
}
}
async function upsertIdentity({ provider, subject, profile }) {
const profileParams = [
profile.email ?? null,
profile.emailVerified ?? null,
profile.displayName ?? null,
profile.pictureUrl ?? null,
profile.piiSubject ?? null
];
return withTransaction(async (client) => {
const existing = await client.query(
`
SELECT identities.account_id, accounts.name AS account_name
FROM identities
JOIN accounts ON accounts.id = identities.account_id
WHERE identities.provider = $1 AND identities.subject = $2
LIMIT 1
`,
[provider, subject]
);
if (existing.rows[0]) {
const accountId = existing.rows[0].account_id;
// The verified token is authoritative for profile fields: a claim absent
// from this login (e.g. the user removed their Google picture) clears the
// stored value, so DB, session, and header always agree. pii_subject is
// the one exception — it is a stable identifier, not editable profile,
// and a transient absence must not unlink the account.
await client.query(
`
UPDATE identities
SET last_login_at = now(),
email = $3,
email_verified = $4,
display_name = $5,
picture_url = $6,
pii_subject = COALESCE($7, pii_subject)
WHERE provider = $1 AND subject = $2
`,
[provider, subject, ...profileParams]
);
// Same authoritative-token rule for the derived account name: if the
// user clears their Google name/email, the account label degrades to the
// neutral fallback instead of retaining old PII.
const accountName =
profile.displayName || profile.email || `Postplan ${subject.slice(-6)}`;
if (accountName !== existing.rows[0].account_name) {
await client.query("UPDATE accounts SET name = $2, updated_at = now() WHERE id = $1", [
accountId,
accountName
]);
}
return { accountId, accountName, email: profile.email ?? null, pictureUrl: profile.pictureUrl ?? null };
}
const accountId = `acct_${newInternalId()}`;
const accountName = profile.displayName || profile.email || `Postplan ${subject.slice(-6)}`;
await client.query("INSERT INTO accounts (id, name) VALUES ($1, $2)", [
accountId,
accountName
]);
await client.query(
`
INSERT INTO identities (
id, account_id, provider, subject,
email, email_verified, display_name, picture_url, pii_subject,
last_login_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
`,
[newInternalId(), accountId, provider, subject, ...profileParams]
);
return { accountId, accountName, email: profile.email ?? null, pictureUrl: profile.pictureUrl ?? null };
});
}
+102
View File
@@ -0,0 +1,102 @@
import { pool } from "./db.js";
import { config } from "./config.js";
import { getDraftPublicUrl, getDraftRawUrl } from "./public-url.js";
// The "my docs" feed: every draft owned by an account, newest first, with the
// aggregates a dashboard needs (latest version, version count, repo). Shared
// by GET /api/drafts and the server-rendered dashboard.
export async function listAccountDrafts(accountId, { requestBaseUrl }) {
const result = await pool.query(
`
SELECT
d.id,
d.title,
d.description,
d.repo_org,
d.repo_name,
d.repo_host,
d.created_at,
d.updated_at,
d.disabled_at,
cv.version_number AS latest_version_number,
cv.created_at AS latest_version_at,
COALESCE(vc.version_count, 0) AS version_count
FROM drafts d
LEFT JOIN draft_versions cv ON cv.id = d.current_version_id
LEFT JOIN (
SELECT draft_id, COUNT(*)::int AS version_count
FROM draft_versions
GROUP BY draft_id
) vc ON vc.draft_id = d.id
WHERE d.account_id = $1
AND d.deleted_at IS NULL
ORDER BY d.updated_at DESC
`,
[accountId]
);
return result.rows.map((row) => ({
draftId: row.id,
title: row.title,
description: row.description,
repoOrg: row.repo_org,
repoName: row.repo_name,
repoHost: row.repo_host,
latestVersionNumber:
row.latest_version_number === null ? null : Number(row.latest_version_number),
versionCount: Number(row.version_count),
createdAt: row.created_at,
updatedAt: row.updated_at,
latestVersionAt: row.latest_version_at,
disabled: Boolean(row.disabled_at),
publicUrl: getDraftPublicUrl({
draftId: row.id,
publicBaseUrl: config.publicBaseUrl,
requestBaseUrl
}),
rawUrl: getDraftRawUrl({
draftId: row.id,
publicBaseUrl: config.publicBaseUrl,
requestBaseUrl
})
}));
}
export async function getAccountDraftWithVersions(accountId, draftId, { requestBaseUrl }) {
const draftResult = await pool.query(
`
SELECT *
FROM drafts
WHERE id = $1 AND account_id = $2 AND deleted_at IS NULL
LIMIT 1
`,
[draftId, accountId]
);
const draft = draftResult.rows[0];
if (!draft) return null;
const versionsResult = await pool.query(
`
SELECT id, version_number, created_at, git_branch, git_commit_sha,
git_commit_subject, git_dirty, file_size
FROM draft_versions
WHERE draft_id = $1
ORDER BY version_number DESC
`,
[draftId]
);
return {
draft: {
draftId: draft.id,
title: draft.title,
description: draft.description,
publicUrl: getDraftPublicUrl({
draftId: draft.id,
publicBaseUrl: config.publicBaseUrl,
requestBaseUrl
})
},
versions: versionsResult.rows
};
}
+192
View File
@@ -0,0 +1,192 @@
import * as parse5 from "parse5";
const BLOCKED_TAGS = new Set([
"form",
"iframe",
"object",
"embed",
"applet",
"base",
"link"
]);
const URL_ATTRS = new Set([
"href",
"src",
"action",
"formaction",
"poster",
"srcdoc",
"xlink:href"
]);
const BLOCKED_PROTOCOLS = ["javascript:", "vbscript:", "file:"];
const ALLOWED_SCRIPT_TYPES = new Set(["", "text/javascript", "application/javascript"]);
// Far above any real document (browsers themselves flatten around 512), but
// well below where the recursive parse5 serializer used for the framed view
// would overflow the call stack (~2000+ levels).
const MAX_DEPTH = 512;
export function validateHtml(html, options = {}) {
const maxBytes = options.maxBytes ?? 512 * 1024;
const errors = [];
const warnings = [];
if (typeof html !== "string" || html.trim() === "") {
errors.push("HTML document is empty.");
return { ok: false, errors, warnings, title: null, hasScripts: false, stats: emptyStats() };
}
const byteLength = Buffer.byteLength(html, "utf8");
if (byteLength > maxBytes) {
errors.push(`HTML document is ${byteLength} bytes; maximum is ${maxBytes} bytes.`);
}
let document;
try {
// scriptingEnabled: false so <noscript> children parse as real elements:
// the hosted viewer renders drafts in an iframe without allow-scripts
// unless consented, and the policy must see what that frame shows.
document = parse5.parse(html, { scriptingEnabled: false });
} catch {
errors.push("HTML document could not be parsed.");
return { ok: false, errors, warnings, title: null, hasScripts: false, stats: emptyStats() };
}
let title = null;
let hasScripts = false;
const externalImageHosts = new Set();
function visit(node) {
if (node.tagName) {
const tagName = node.tagName.toLowerCase();
if (BLOCKED_TAGS.has(tagName)) {
errors.push(`Blocked <${tagName}> tag found.`);
}
if (tagName === "script") {
hasScripts = true;
const attributes = new Map(
(node.attrs || []).map((attr) => [attr.name.toLowerCase(), String(attr.value || "").trim()])
);
if (attributes.has("src")) {
errors.push("External script sources are not allowed.");
}
const scriptType = (attributes.get("type") || "").toLowerCase();
if (!ALLOWED_SCRIPT_TYPES.has(scriptType)) {
errors.push(`Unsupported script type "${scriptType}" found.`);
}
}
for (const attr of node.attrs || []) {
const name = attr.name.toLowerCase();
const value = String(attr.value || "").trim();
if (name.startsWith("on")) {
errors.push(`Blocked inline event handler attribute "${name}" found.`);
}
if (name === "srcdoc") {
errors.push('Blocked "srcdoc" attribute found.');
}
if (URL_ATTRS.has(name)) {
const normalized = value.replace(/[\u0000-\u0020]+/g, "").toLowerCase();
if (BLOCKED_PROTOCOLS.some((protocol) => normalized.startsWith(protocol))) {
errors.push(`Blocked unsafe URL in "${name}" attribute.`);
}
}
if (name === "style" && /expression\s*\(|behavior\s*:|url\s*\(\s*javascript:/i.test(value)) {
errors.push("Blocked unsafe inline CSS.");
}
}
if (tagName === "meta") {
const httpEquiv = (node.attrs || []).find((attr) => attr.name.toLowerCase() === "http-equiv");
if (httpEquiv && httpEquiv.value.trim().toLowerCase() === "refresh") {
errors.push("Blocked meta refresh tag found.");
}
}
// Images are the one external resource the serving CSP allows (img-src
// https: data:), so record which hosts a draft pulls from for later review.
if (tagName === "img") {
const src = (node.attrs || []).find((attr) => attr.name.toLowerCase() === "src");
const host = externalHost(src?.value);
if (host) externalImageHosts.add(host);
}
}
if (node.tagName === "title" && !title) {
title = collectText(node).trim().slice(0, 140) || null;
}
}
let tooDeep = false;
const stack = [{ node: document, depth: 0 }];
while (stack.length) {
const { node, depth } = stack.pop();
visit(node);
if (depth >= MAX_DEPTH) {
tooDeep = true;
continue;
}
const children = node.childNodes || [];
for (let i = children.length - 1; i >= 0; i--) {
stack.push({ node: children[i], depth: depth + 1 });
}
}
if (tooDeep) {
errors.push(`HTML is nested more than ${MAX_DEPTH} levels deep.`);
}
if (!title) {
warnings.push("No <title> found; Postplan will use a generic title.");
}
return {
ok: errors.length === 0,
errors: [...new Set(errors)],
warnings: [...new Set(warnings)],
title,
hasScripts,
stats: {
hasInlineScript: hasScripts,
externalImageHosts: [...externalImageHosts].sort()
}
};
}
function emptyStats() {
return { hasInlineScript: false, externalImageHosts: [] };
}
// Returns the lowercased host of an absolute http(s) (or protocol-relative) URL,
// or null for relative paths, data: URIs, and anything unparseable.
function externalHost(value) {
const raw = String(value || "").trim();
if (!raw) return null;
const candidate = raw.startsWith("//") ? `https:${raw}` : raw;
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.hostname.toLowerCase();
}
} catch {
// relative path, data: URI, etc. — not an external host
}
return null;
}
function collectText(node) {
let value = "";
for (const child of node.childNodes || []) {
if (child.nodeName === "#text") value += child.value || "";
value += collectText(child);
}
return value;
}
+12
View File
@@ -0,0 +1,12 @@
import { customAlphabet } from "nanoid";
const draftId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 12);
const internalId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 20);
export function newDraftId() {
return draftId();
}
export function newInternalId() {
return internalId();
}
+106
View File
@@ -0,0 +1,106 @@
const DRAFT_ID_PATTERN = /^[a-z0-9]{12}$/;
export function getRequestBaseUrl(req) {
const forwardedProto = req.get("x-forwarded-proto");
const protocol = forwardedProto
? forwardedProto.split(",")[0].trim()
: req.protocol || "http";
return `${protocol}://${req.get("host")}`;
}
export function getHomeUrl({ publicBaseUrl, requestBaseUrl }) {
const configured = normalizeUrl(publicBaseUrl);
const wildcard = parseWildcardBaseUrl(configured);
if (wildcard) {
wildcard.hostname = wildcard.hostname.slice(2);
wildcard.pathname = "/";
wildcard.search = "";
wildcard.hash = "";
return stripTrailingSlash(wildcard.toString());
}
return configured || normalizeUrl(requestBaseUrl);
}
export function getDraftPublicUrl({ draftId, publicBaseUrl, requestBaseUrl }) {
const configured = normalizeUrl(publicBaseUrl);
const wildcard = parseWildcardBaseUrl(configured);
if (wildcard) {
wildcard.hostname = `${draftId}.${wildcard.hostname.slice(2)}`;
wildcard.pathname = "/";
wildcard.search = "";
wildcard.hash = "";
return stripTrailingSlash(wildcard.toString());
}
const baseUrl = configured || normalizeUrl(requestBaseUrl);
return `${baseUrl}/d/${draftId}`;
}
export function getDraftRawUrl({ draftId, publicBaseUrl, requestBaseUrl }) {
const configured = normalizeUrl(publicBaseUrl);
const wildcard = parseWildcardBaseUrl(configured);
if (wildcard) {
wildcard.hostname = wildcard.hostname.slice(2);
wildcard.pathname = `/d/${draftId}/raw`;
wildcard.search = "";
wildcard.hash = "";
return wildcard.toString();
}
const baseUrl = configured || normalizeUrl(requestBaseUrl);
return `${baseUrl}/d/${draftId}/raw`;
}
export function getDraftIdFromHost({ publicBaseUrl, host }) {
const wildcard = parseWildcardBaseUrl(publicBaseUrl);
if (!wildcard) return null;
const rootHost = wildcard.hostname.slice(2).toLowerCase();
const requestHost = parseHost(host);
if (!requestHost || !requestHost.endsWith(`.${rootHost}`)) return null;
const draftId = requestHost.slice(0, -(rootHost.length + 1));
if (draftId.includes(".") || !DRAFT_ID_PATTERN.test(draftId)) return null;
return draftId;
}
function parseWildcardBaseUrl(value) {
const url = parseUrl(value);
if (!url || !url.hostname.startsWith("*.")) return null;
return url;
}
function parseHost(value) {
const normalized = String(value || "").trim();
if (!normalized) return null;
try {
return new URL(`http://${normalized}`).hostname.toLowerCase();
} catch {
return null;
}
}
function parseUrl(value) {
const normalized = normalizeUrl(value);
if (!normalized) return null;
try {
return new URL(normalized);
} catch {
return null;
}
}
function normalizeUrl(value) {
if (typeof value !== "string") return "";
return value.trim().replace(/\/+$/, "");
}
function stripTrailingSlash(value) {
return value.replace(/\/+$/, "");
}
+23
View File
@@ -0,0 +1,23 @@
const buckets = new Map();
export function createRateLimiter({ windowMs, max, keyPrefix, key }) {
return function rateLimiter(req, res, next) {
const now = Date.now();
const identity = key ? key(req) : req.auth?.id || req.ip || "anonymous";
const bucketKey = `${keyPrefix}:${identity}`;
const current = buckets.get(bucketKey);
if (!current || current.resetAt <= now) {
buckets.set(bucketKey, { count: 1, resetAt: now + windowMs });
return next();
}
current.count += 1;
if (current.count > max) {
res.setHeader("Retry-After", String(Math.ceil((current.resetAt - now) / 1000)));
return res.status(429).json({ ok: false, error: "Upload rate limit exceeded." });
}
next();
};
}
+302
View File
@@ -0,0 +1,302 @@
// Server-rendered dashboard pages. These are postplan's own UI (apex domain
// only) — unlike draft serving they may use inline styles/JS freely; the
// draft-serving CSP never applies here.
export function renderSignIn({ next }) {
const target = `/auth/sign-in?next=${encodeURIComponent(next || "/dashboard")}`;
return webPage({
title: "Sign in — Postplan",
body: `
<main class="narrow center">
<h1>Postplan</h1>
<p class="muted">Sign in to see the drafts you've published.</p>
<p><a class="button" href="${escapeHtml(target)}">Continue with shoo</a></p>
<p class="muted small">Publishing from the CLI stays anonymous unless you attach a key.</p>
</main>
`
});
}
export function renderAuthError({ message }) {
return webPage({
title: "Sign-in problem — Postplan",
body: `
<main class="narrow center">
<h1>Sign-in problem</h1>
<p class="muted">${escapeHtml(message)}</p>
<p><a class="button" href="/auth/sign-in">Try again</a></p>
</main>
`
});
}
export function renderDashboard({ session, drafts }) {
const groups = groupByRepo(drafts);
const sections = groups
.map(
(group) => `
<section>
<h2>${escapeHtml(group.label)}${
group.href
? ` <a class="small repo-link" href="${escapeHtml(group.href)}" target="_blank" rel="noopener noreferrer">GitHub ↗</a>`
: ""
}</h2>
${group.drafts.map(renderDraftRow).join("\n")}
</section>
`
)
.join("\n");
return webPage({
title: "My drafts — Postplan",
header: pageHeader({ session, active: "dashboard" }),
body: `
<main>
<h1>My drafts</h1>
${
drafts.length
? sections
: `<p class="muted">No drafts yet. Publish one with <code>postplan upload plan.html</code> using a key from <a href="/cli/auth">CLI setup</a>.</p>`
}
</main>
`
});
}
export function renderDraftDetail({ session, draft, versions }) {
const rows = versions
.map(
(v) => `
<tr>
<td><a href="${escapeHtml(draft.publicUrl)}/v/${Number(v.version_number)}" target="_blank" rel="noopener noreferrer">v${Number(v.version_number)}</a></td>
<td>${escapeHtml(v.git_commit_subject || "")}${v.git_dirty ? ' <span class="pill warn">dirty</span>' : ""}</td>
<td class="muted">${escapeHtml(v.git_branch || "")} ${escapeHtml((v.git_commit_sha || "").slice(0, 7))}</td>
<td class="muted">${escapeHtml(formatDate(v.created_at))}</td>
</tr>
`
)
.join("\n");
return webPage({
title: `${draft.title} — Postplan`,
header: pageHeader({ session, active: "dashboard" }),
body: `
<main>
<p class="small"><a href="/dashboard">← My drafts</a></p>
<h1>${escapeHtml(draft.title)}</h1>
${draft.description ? `<p class="muted">${escapeHtml(draft.description)}</p>` : ""}
<p><a href="${escapeHtml(draft.publicUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(draft.publicUrl)}</a></p>
<table>
<tr><th>Version</th><th>Commit</th><th>Ref</th><th>Published</th></tr>
${rows}
</table>
</main>
`
});
}
export function renderCliAuth({ session, keys = [] }) {
const keyRows = keys
.map(
(key) => `
<tr>
<td>${escapeHtml(key.name)}</td>
<td class="muted">${escapeHtml(formatDate(key.created_at))}</td>
<td class="muted">${key.last_used_at ? escapeHtml(formatDate(key.last_used_at)) : "never used"}</td>
<td>
<form method="post" action="/cli/auth/keys/${escapeHtml(key.id)}/revoke">
<button class="linklike" type="submit">Revoke</button>
</form>
</td>
</tr>
`
)
.join("\n");
return webPage({
title: "CLI setup — Postplan",
header: pageHeader({ session, active: "cli" }),
body: `
<main class="narrow">
<h1>Connect your CLI</h1>
<p class="muted">Generate a key, then paste it into the waiting <code>postplan auth login</code> prompt in your terminal.</p>
<form method="post" action="/cli/auth/keys">
<button class="button" type="submit">Generate a new API key</button>
</form>
<p class="muted small">Each visit can mint a fresh key. Keys are shown once.</p>
${
keys.length
? `<h2>Active keys</h2>
<table>
<tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr>
${keyRows}
</table>`
: ""
}
</main>
`
});
}
export function renderCliAuthKey({ session, token, keyName }) {
return webPage({
title: "Your new API key — Postplan",
header: pageHeader({ session, active: "cli" }),
body: `
<main class="narrow">
<h1>Your new API key</h1>
<p class="muted">Named <strong>${escapeHtml(keyName)}</strong>. Shown once — copy it now and paste it into your terminal.</p>
<div class="keybox">
<code id="key">${escapeHtml(token)}</code>
<button class="button" id="copy" type="button">Copy</button>
</div>
<p class="muted small">Terminal: <code>postplan auth login</code> (or <code>postplan auth set &lt;key&gt;</code>).</p>
<script>
document.getElementById("copy").addEventListener("click", async () => {
await navigator.clipboard.writeText(document.getElementById("key").textContent);
document.getElementById("copy").textContent = "Copied";
});
</script>
</main>
`
});
}
function renderDraftRow(draft) {
// The title is the one-click "open the plan" action (new tab); the internal
// detail/version-history screen hangs off the separate Details link.
return `
<div class="row">
<div>
<a class="row-title" href="${escapeHtml(draft.publicUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(draft.title)}</a>
${draft.disabled ? '<span class="pill warn">disabled</span>' : ""}
${draft.description ? `<div class="muted small">${escapeHtml(draft.description)}</div>` : ""}
</div>
<div class="row-meta muted small">
<a href="/dashboard/drafts/${escapeHtml(draft.draftId)}">Details</a> ·
v${draft.latestVersionNumber ?? "—"} · ${draft.versionCount} version${draft.versionCount === 1 ? "" : "s"} · ${escapeHtml(formatDate(draft.updatedAt))}
</div>
</div>
`;
}
function groupByRepo(drafts) {
const map = new Map();
for (const draft of drafts) {
const hasRepo = draft.repoOrg && draft.repoName;
const key = hasRepo ? `${draft.repoOrg}/${draft.repoName}` : "";
if (!map.has(key)) {
// Drafts uploaded by pre-0.0.3 CLIs have no repo_host; default those to
// github.com so the one-click repo link still works. Any member draft
// with a recorded host upgrades the group's link below.
map.set(key, {
label: hasRepo ? key : "No repository",
href: hasRepo
? `https://${draft.repoHost || "github.com"}/${draft.repoOrg}/${draft.repoName}`
: null,
drafts: []
});
}
const group = map.get(key);
if (hasRepo && draft.repoHost) {
group.href = `https://${draft.repoHost}/${draft.repoOrg}/${draft.repoName}`;
}
group.drafts.push(draft);
}
// Repo groups first (already newest-first within), "No repository" last.
return [...map.entries()].sort(([a], [b]) => (a === "") - (b === "")).map(([, g]) => g);
}
function pageHeader({ session = {}, active }) {
// Claims arrive via shoo's verified id_token, but only render an avatar for
// plain https URLs anyway.
const avatar =
typeof session.pictureUrl === "string" && session.pictureUrl.startsWith("https://")
? `<img class="avatar" src="${escapeHtml(session.pictureUrl)}" alt="" referrerpolicy="no-referrer">`
: "";
// The email is PII: render it blurred until hover/focus (shoo's own /me
// pattern) so it never leaks on screenshares or streams. A user with no
// Google display name gets their email as the account name — in that case
// skip the plain name and show only the blurred email.
const name =
session.accountName && session.accountName !== session.email
? `<span class="muted small">${escapeHtml(session.accountName)}</span>`
: "";
const email = session.email
? `<span class="muted small pii" tabindex="0" title="Hover to reveal">${escapeHtml(session.email)}</span>`
: "";
return `
<header class="top">
<nav>
<a href="/dashboard" class="${active === "dashboard" ? "active" : ""}">My drafts</a>
<a href="/cli/auth" class="${active === "cli" ? "active" : ""}">CLI setup</a>
</nav>
<form method="post" action="/auth/sign-out">
${avatar}
${name}
${email}
<button class="linklike" type="submit">Sign out</button>
</form>
</header>
`;
}
function formatDate(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 16).replace("T", " ");
}
function webPage({ title, body, header = "" }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
body { margin: 0; background: #f8fafc; color: #111827; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
main { max-width: 860px; margin: 32px auto 80px; padding: 0 20px; }
main.narrow { max-width: 560px; }
main.center { text-align: center; margin-top: 96px; }
h1 { font-size: 30px; margin: 0 0 14px; }
h2 { font-size: 17px; margin: 30px 0 6px; }
p { line-height: 1.6; }
a { color: #1d4ed8; }
code { background: #eef2f7; border: 1px solid #d1d5db; border-radius: 5px; padding: 1px 5px; font-size: 14px; }
.muted { color: #6b7280; }
.small { font-size: 13px; }
.button { display: inline-block; background: #111827; color: #fff; border: 0; border-radius: 8px; padding: 10px 18px; font-size: 15px; text-decoration: none; cursor: pointer; }
.linklike { background: none; border: 0; color: #1d4ed8; cursor: pointer; font-size: 13px; padding: 0; margin-left: 10px; text-decoration: underline; }
.top { display: flex; justify-content: space-between; align-items: center; max-width: 860px; margin: 0 auto; padding: 14px 20px; border-bottom: 1px solid #e5e7eb; }
.top nav a { margin-right: 16px; text-decoration: none; color: #374151; }
.top nav a.active { color: #111827; font-weight: 600; }
.top form { display: inline-flex; align-items: center; gap: 8px; }
.avatar { width: 26px; height: 26px; border-radius: 50%; vertical-align: middle; }
.repo-link { font-weight: 400; text-decoration: none; margin-left: 6px; }
.row-meta a { color: #6b7280; }
.pii { filter: blur(4px); border-radius: 3px; transition: filter .15s ease; cursor: pointer; }
.pii:hover, .pii:focus { filter: none; outline: none; }
.row { display: flex; justify-content: space-between; gap: 14px; align-items: baseline; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
.row-title { font-weight: 600; text-decoration: none; }
.row-meta { white-space: nowrap; }
.pill { font-size: 11px; border-radius: 999px; padding: 2px 8px; margin-left: 6px; }
.pill.warn { background: #fef3c7; color: #92400e; }
table { width: 100%; border-collapse: collapse; margin-top: 14px; }
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
th { color: #6b7280; font-weight: 600; font-size: 12px; text-transform: uppercase; }
.keybox { display: flex; gap: 10px; align-items: center; background: #fff; border: 1px solid #d1d5db; border-radius: 8px; padding: 14px; margin: 14px 0; }
.keybox code { flex: 1; word-break: break-all; background: none; border: 0; font-size: 15px; }
</style>
</head>
<body>${header}${body}</body>
</html>`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
+81
View File
@@ -0,0 +1,81 @@
export function renderHome({ publicBaseUrl }) {
return htmlPage({
title: "Postplan",
body: `
<main class="home">
<h1>Postplan</h1>
<p>Authenticated static HTML draft publishing for agents.</p>
<pre>npx postplan upload ./plan.html</pre>
<p><a href="/dashboard">My drafts</a> · <a href="/cli/auth">CLI setup</a></p>
<p>Health: <a href="/healthz">/healthz</a></p>
<p>Public base URL: ${escapeHtml(publicBaseUrl || "not configured")}</p>
</main>
`
});
}
export function renderNotFound() {
return htmlPage({
title: "Draft not found",
body: `
<main class="home">
<h1>Draft not found</h1>
<p>The requested draft is unavailable.</p>
</main>
`
});
}
function htmlPage({ title, body }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
body {
margin: 0;
background: #f8fafc;
color: #111827;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.home {
max-width: 760px;
margin: 64px auto;
padding: 0 20px;
}
h1 {
margin: 0 0 12px;
font-size: 40px;
line-height: 1.1;
}
p {
color: #374151;
font-size: 17px;
line-height: 1.6;
}
pre {
overflow-x: auto;
padding: 14px;
border: 1px solid #d1d5db;
background: #ffffff;
border-radius: 6px;
}
</style>
</head>
<body>${body}</body>
</html>`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
+20
View File
@@ -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);
});
+110
View File
@@ -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:<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:<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;
}
+59
View File
@@ -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");
}
+115
View File
@@ -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);
}
+287
View File
@@ -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;
}