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
+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;
}