From 337be8cd3b58f8ff9cecaabe47dbdede64c9d0d0 Mon Sep 17 00:00:00 2001 From: bnz183 Date: Fri, 12 Jun 2026 05:19:24 +0200 Subject: [PATCH 1/2] improve studio UI polish and CMS workflow clarity --- .claude/rules/docs-style.md | 50 +++++++ .claude/rules/no-scope-creep.md | 41 +++++ .claude/rules/release-gates.md | 45 ++++++ .claude/rules/source-draft-project-rules.md | 38 +++++ .claude/worktrees/project-guidance | 1 + .github/ISSUE_TEMPLATE/adapter_request.md | 36 +++++ .github/ISSUE_TEMPLATE/publisher_request.md | 37 +++++ .github/ISSUE_TEMPLATE/security_hardening.md | 30 ++++ .github/pull_request_template.md | 30 ++++ CLAUDE.md | 90 +++++++++++ README.md | 6 +- RELEASE_CHECKLIST.md | 10 +- SECURITY.md | 43 +++++- docs/architecture.md | 19 ++- docs/brand-assets.md | 100 +++++++++++++ docs/comparison.md | 121 +++++++++++++++ docs/compatibility-roadmap.md | 8 +- docs/contributing-roadmap.md | 92 ++++++++++++ docs/demo-script.md | 84 +++++++++++ docs/portfolio-case-study.md | 148 +++++++++++++++++++ docs/project-status.md | 2 +- docs/public-launch-checklist.md | 136 +++++++++++++++++ docs/roadmap.md | 70 +++++++++ 23 files changed, 1217 insertions(+), 20 deletions(-) create mode 100644 .claude/rules/docs-style.md create mode 100644 .claude/rules/no-scope-creep.md create mode 100644 .claude/rules/release-gates.md create mode 100644 .claude/rules/source-draft-project-rules.md create mode 160000 .claude/worktrees/project-guidance create mode 100644 .github/ISSUE_TEMPLATE/adapter_request.md create mode 100644 .github/ISSUE_TEMPLATE/publisher_request.md create mode 100644 .github/ISSUE_TEMPLATE/security_hardening.md create mode 100644 .github/pull_request_template.md create mode 100644 CLAUDE.md create mode 100644 docs/brand-assets.md create mode 100644 docs/comparison.md create mode 100644 docs/contributing-roadmap.md create mode 100644 docs/demo-script.md create mode 100644 docs/portfolio-case-study.md create mode 100644 docs/public-launch-checklist.md create mode 100644 docs/roadmap.md diff --git a/.claude/rules/docs-style.md b/.claude/rules/docs-style.md new file mode 100644 index 0000000..301c438 --- /dev/null +++ b/.claude/rules/docs-style.md @@ -0,0 +1,50 @@ +# Docs and copy style + +## Tone + +Professional open-source developer tool: clean, technical, precise, +trustworthy. Not generic SaaS marketing, not AI-generated filler, not startup +hype, not fake enterprise. Write like the maintainer of a tool people trust +with their repositories and credentials. + +## Positioning to emphasize + +- Git-owned content and portability (plain `.md`/`.mdx` in the user's repo) +- Adapter/publisher architecture (one schema, many targets) +- Local/private control; credentials stay server-side +- Publishing confidence: validation, exact output preview, content QA, + publish checklist, demo mode + +## Competitors + +Respect Decap CMS, TinaCMS, CloudCannon, GitCMS, WordPress, and Ghost. State +plainly where they are stronger (maturity, hosting, ecosystems, visual +editing) and where SourceDraft differs. Never attack, never imply they are +bad choices, never invent their weaknesses. + +## Evidence rules + +- Feature claims must match shipped code; check `docs/project-status.md`. +- Acceptable sources: this repository, official docs of the tools discussed, + reputable open-source ecosystem guidance, neutral UX/product principles. +- Not acceptable as neutral evidence: vendor marketing pages, SEO listicles, + fabricated statistics, competitor sales pages. Real user/founder reviews may + be cited only as clearly-marked anecdotes. +- No fake screenshots, metrics, benchmarks, or testimonials. + +## UX writing principles + +Optimize docs and UI text for: fast first success, low cognitive load, clear +system status, obvious next action, recognition over recall, error +prevention, user control and recovery, progressive disclosure, transparent +limitations, and trust around credentials and publishing. The reader is +technical but may be new to Git-based CMS workflows. + +## Mechanics + +- Sentence-case headings, short paragraphs, tables for matrices. +- Every doc links onward to the next logical doc. +- Commands shown must actually work from the repo root (or state the cwd). +- Mark experimental/partial features inline, not only in a footnote. +- Keep "MVP password auth is intended for local/private use" warnings wherever + exposure to the public internet could be implied. diff --git a/.claude/rules/no-scope-creep.md b/.claude/rules/no-scope-creep.md new file mode 100644 index 0000000..b57ead2 --- /dev/null +++ b/.claude/rules/no-scope-creep.md @@ -0,0 +1,41 @@ +# No scope creep + +SourceDraft launches first as a genuinely useful free and open-source AGPL +project. The free version must not be artificially crippled, and monetization +is **not** implemented now. + +## Forbidden in the current phase + +Do not implement, scaffold, stub, or document as available: + +- Paywalls, billing, SaaS plans, license gates, or feature flags that gate + open-source functionality +- Telemetry or analytics collection of any kind +- OAuth, user accounts, team accounts, or RBAC +- Hosted / multi-tenant Studio +- Plugin marketplace +- AI writing tools +- Large UI redesigns + +Also forbidden: fake screenshots, fake metrics, fake benchmarks, and +production/enterprise overclaims in any doc or UI string. + +## Allowed to mention (roadmap only) + +Future commercial possibilities (hosted SourceDraft Cloud, managed onboarding, +OAuth/team accounts, RBAC, managed media, premium support, agency workspaces, +migration services, dual licensing) may appear in `docs/roadmap.md` as +clearly-labeled future options — never as current features, never as code. + +## Decision filter + +Classify every proposed change as one of: + +1. **True launch blocker** — broken, misleading, insecure, or missing piece + that would embarrass the project on day one. Do it. +2. **High-value polish** — improves first success, clarity, or trust with low + risk. Do it if cheap and in scope. +3. **Later roadmap** — useful but not now. Write it down in `docs/roadmap.md` + or an issue; do not implement. +4. **Explicitly not now** — anything on the forbidden list. Refuse, even if + requested casually, and point to this file. diff --git a/.claude/rules/release-gates.md b/.claude/rules/release-gates.md new file mode 100644 index 0000000..88979c5 --- /dev/null +++ b/.claude/rules/release-gates.md @@ -0,0 +1,45 @@ +# Release gates + +Before tagging a release, merging release-related PRs, or recommending any +public promotion, all gates below must pass. See `RELEASE_CHECKLIST.md` and +`docs/public-launch-checklist.md` for the full operator checklists. + +## Automated gates + +```bash +pnpm install --frozen-lockfile +pnpm build +pnpm test +pnpm test:e2e # required for releases and any UI/auth/publish change +``` + +- CI (`.github/workflows/ci.yml`) green: build, unit tests, studio e2e +- CodeQL: no open high-severity alerts on the release PR + +## Repository hygiene gates + +- `LICENSE` is AGPL-3.0-or-later; no stray MIT references anywhere +- `.env` / `.env.local` gitignored and not committed +- No-secrets scan clean on tracked files (tokens, passwords, private keys): + `git grep -nIiE 'ghp_[A-Za-z0-9]|gho_[A-Za-z0-9]|BEGIN [A-Z]+ PRIVATE KEY' -- ':!*.example*'` +- No QuBrite hardcoding in `*.ts` / `*.tsx` app logic + +## Honesty gates + +- README, `docs/project-status.md`, and `CHANGELOG.md` agree on shipped vs + experimental vs not-shipped +- Stated limitations still accurate: MVP password auth, in-memory sessions, + Contents API scale limits, `s3-compatible` upload not implemented, no post + list for Bitbucket/WordPress/Ghost +- No screenshots showing tokens, real repo secrets, or personal data +- No production/SaaS/enterprise claims anywhere + +## Manual gates (release only) + +- Demo mode walkthrough passes (`docs/manual-acceptance-test.md`) +- Real publish against a **test** GitHub repository: direct commit and + pull-request mode both verified +- Screenshots regenerated (`pnpm screenshots:generate`) if UI changed + +If any gate fails, the release stops. Document the failure; do not waive +gates silently. diff --git a/.claude/rules/source-draft-project-rules.md b/.claude/rules/source-draft-project-rules.md new file mode 100644 index 0000000..6387ad7 --- /dev/null +++ b/.claude/rules/source-draft-project-rules.md @@ -0,0 +1,38 @@ +# SourceDraft project rules + +SourceDraft is an open-source publishing Studio for Markdown, MDX, and +Git-backed content workflows, with an adapter/publisher architecture for +multiple static-site frameworks and CMS targets. License: AGPL-3.0-or-later. + +## Product identity + +- Target users: solo developers, technical bloggers, documentation-site + maintainers, Astro/Next.js/Hugo/Docusaurus/MkDocs/Nuxt Content users, and + small teams that want Git-owned content. +- Core promise: Git-owned, portable content; secrets server-side; publishing + confidence (validation, preview of exact output path/file, content QA). +- SourceDraft is **not** WordPress, not a site builder, not a hosted CMS. +- QuBrite.com is the origin story only. Never hardcode QuBrite (or any single + site) into core logic, defaults, or fixtures. + +## Engineering rules + +- Universal article schema lives in `@sourcedraft/core`; adapters and + publishers consume it through `adapterRegistry` / `publisherRegistry`. +- Secrets are read from `.env` in `apps/studio/server` only. Browser code must + never import publisher/media packages or see credential values. +- Keep modules typed, small, and testable. Prefer boring reliable code. +- No unnecessary comments, no unrelated refactors, no dependency additions + without explicit justification. +- Errors returned to Studio must be clear and actionable, without leaking + secret values. + +## Honesty rules + +- Docs and UI describe only what is implemented. Shipped vs experimental vs + not-shipped follows `docs/project-status.md` — update it when status + changes, and keep README/CHANGELOG consistent with it. +- No fake analytics, fake charts, fake metrics, fake screenshots, placeholder + features, or production/enterprise overclaims. +- Known limitations (MVP auth, in-memory sessions, Contents API scale limits, + S3 upload not implemented) stay visibly documented until fixed. diff --git a/.claude/worktrees/project-guidance b/.claude/worktrees/project-guidance new file mode 160000 index 0000000..e791f54 --- /dev/null +++ b/.claude/worktrees/project-guidance @@ -0,0 +1 @@ +Subproject commit e791f54723e8a630ae5316dbd6a14e0763924b51 diff --git a/.github/ISSUE_TEMPLATE/adapter_request.md b/.github/ISSUE_TEMPLATE/adapter_request.md new file mode 100644 index 0000000..60fc9ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/adapter_request.md @@ -0,0 +1,36 @@ +--- +name: Adapter request +about: Request or propose a new file adapter (framework/static-site generator) +title: "adapter: " +labels: enhancement, adapter +assignees: "" +--- + +## Framework / generator + + + +## Frontmatter format + + + +## Field mapping + + + +## File path conventions + + + +## Example post file + +```markdown + +``` + +## Are you willing to implement it? + + diff --git a/.github/ISSUE_TEMPLATE/publisher_request.md b/.github/ISSUE_TEMPLATE/publisher_request.md new file mode 100644 index 0000000..96f78b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/publisher_request.md @@ -0,0 +1,37 @@ +--- +name: Publisher request +about: Request or propose a new publish target (Git host or CMS API) +title: "publisher: " +labels: enhancement, publisher +assignees: "" +--- + +## Target + + + +## Kind + + + +## Auth model + + + +## Capabilities expected + +- Publish new post: yes / no +- Update existing post: yes / no (how is the post identified?) +- Upload media: yes / no +- List/read posts: yes / no + +## Why not a plugin? + + + +## Are you willing to implement it? + + diff --git a/.github/ISSUE_TEMPLATE/security_hardening.md b/.github/ISSUE_TEMPLATE/security_hardening.md new file mode 100644 index 0000000..1239bea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security_hardening.md @@ -0,0 +1,30 @@ +--- +name: Security hardening +about: Propose a hardening improvement (NOT for reporting vulnerabilities) +title: "hardening: " +labels: security +assignees: "" +--- + + + +## Area + + + +## Current behavior + + + +## Proposed hardening + + + +## Trade-offs + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..dd9dfd3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ +## What and why + + + +## Type of change + + + +- [ ] Bug fix +- [ ] Feature (adapter / publisher / Studio / server) +- [ ] Documentation +- [ ] Tests / CI +- [ ] Refactor (no behavior change) + +## Checklist + +- [ ] `pnpm build` passes +- [ ] `pnpm test` passes +- [ ] `pnpm test:e2e` passes (required if Studio UI, auth, demo mode, or publish flows changed) +- [ ] Tests added/updated for logic changes +- [ ] Docs updated if behavior or configuration changed (including compatibility matrices) +- [ ] No secrets in code, fixtures, tests, or screenshots; nothing credential-related in browser code +- [ ] No new dependencies (or justified below) +- [ ] In scope — no monetization, telemetry, OAuth/RBAC, or hosted features (see docs/roadmap.md "Explicitly not now") + +## Test notes + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f1950b9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +Guidance for AI assistants working in this repository. Detailed rules live in +`.claude/rules/`. + +## What SourceDraft is + +An open-source (AGPL-3.0-or-later) publishing Studio for Markdown, MDX, and +Git-backed content workflows. A local React Studio plus a server-side publish +API commit content and media to the user's own target — GitHub, GitLab, +Bitbucket, WordPress, or Ghost — through an adapter/publisher architecture. + +- **Adapters** render a validated universal article into platform-specific + file output (Astro MDX, Hugo, Docusaurus, MkDocs, Nuxt Content, …). +- **Publishers** send content to a target (Git file commit or remote CMS API). +- Content stays portable: plain `.md`/`.mdx` files in the user's repository. + +Status: early local/private MVP. Honest about limitations — see +`docs/project-status.md`. QuBrite.com is the origin story only, never a +dependency or hardcoded target. + +## Repository layout + +| Path | Contents | +|------|----------| +| `apps/studio` | React Studio UI + Express publish API (`server/`) | +| `packages/core` | Universal article schema and validation | +| `packages/adapter-*` | One package per file adapter (8 shipped) | +| `packages/adapters` | `adapterRegistry` | +| `packages/publishers` | `publisherRegistry` (GitLab, Bitbucket, WP, Ghost) | +| `packages/github-publisher` | GitHub Contents API client | +| `packages/media-providers` | Git media, Cloudinary, S3 (config-only) | +| `packages/plugins` | Server-side plugin loader | +| `packages/setup` | Setup wizard + config validation CLI | +| `examples/*` | Folder-layout integration examples (not runnable sites) | +| `docs/` | All user and contributor documentation | + +## Commands + +```bash +pnpm install # workspace install (pnpm 11+, Node 22+) +pnpm dev # Studio UI + publish API +pnpm build # build everything incl. studio server TS +pnpm test # unit tests across packages + studio +pnpm test:e2e # Playwright smoke tests (demo mode, no credentials) +pnpm setup # guided config wizard +pnpm validate:config # validate sourcedraft.config.json + .env +``` + +Run `pnpm build` and `pnpm test` before finishing any code change. Run +`pnpm test:e2e` when Studio UI, auth, demo mode, or publish flows change. + +## Hard rules + +1. **Secrets stay server-side.** Tokens, passwords, and API keys are read from + `.env` in `apps/studio/server` only. Never import publisher packages or + reference credentials in browser code. Never commit `.env`/`.env.local`. +2. **License is AGPL-3.0-or-later** everywhere. No MIT references. +3. **No scope creep** — see `.claude/rules/no-scope-creep.md`. No billing, + paywalls, telemetry, OAuth, RBAC, team accounts, hosted/multi-tenant + Studio, plugin marketplace, or AI writing features. +4. **No fabrication.** No fake screenshots, metrics, benchmarks, testimonials, + or placeholder features. Docs must describe what the code actually does. +5. **No new dependencies** unless absolutely necessary and justified in the + PR description. +6. **Honest status.** SourceDraft is an early local/private MVP, not a hosted + SaaS or production multi-user product. Keep all docs consistent with + `docs/project-status.md` (shipped / experimental / not shipped). +7. **No QuBrite hardcoding** in app logic. Generic core, per-site config. +8. **Do not push to `main`.** PRs only; CI and CodeQL must pass. + +## Code style + +- TypeScript, small typed modules, boring reliable code over clever + abstractions. +- New adapters/publishers go through the registries; follow the interfaces in + `docs/compatibility-roadmap.md`. +- Unit tests with `node --test` next to the source (`*.test.ts`). +- Match existing comment density (low); no comments that restate code. + +## Docs style + +See `.claude/rules/docs-style.md`. Short version: precise, technical, +trustworthy; respectful toward Decap/Tina/CloudCannon/WordPress/Ghost; +no SaaS hype, no overclaims; every feature claim must match shipped code. + +## Release gates + +See `.claude/rules/release-gates.md` and `RELEASE_CHECKLIST.md` before +tagging or promoting anything publicly. diff --git a/README.md b/README.md index 27dbd43..27a1f4b 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,9 @@ Your static site still builds and deploys exactly as before. SourceDraft creates ## What it does not do yet - Host your website or run your Astro build -- OAuth, user accounts, or role-based access +- OAuth, user accounts, role-based access, or hosted multi-tenant Studio - Full S3/R2 media upload (`s3-compatible` validates config only; use Cloudinary or git media today) - Post list in Studio for Bitbucket, WordPress, and Ghost publishers -- OAuth, team accounts, or hosted multi-tenant Studio Eight adapters ship today — see [docs/adapters.md](docs/adapters.md). See [docs/project-status.md](docs/project-status.md) for the full shipped vs experimental list. @@ -187,6 +186,9 @@ Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) fo - [Architecture](docs/architecture.md) - [Adapters](docs/adapters.md) - [Project status](docs/project-status.md) +- [How SourceDraft compares](docs/comparison.md) — Decap, Tina, CloudCannon, WordPress, Ghost +- [Roadmap](docs/roadmap.md) +- [Contributing roadmap](docs/contributing-roadmap.md) — good first issues - [Manual acceptance test](docs/manual-acceptance-test.md) - [Smoke tests (Playwright)](docs/getting-started.md#smoke-tests-playwright) - [Release checklist](RELEASE_CHECKLIST.md) diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index afbb18e..2ecc8d0 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,6 +1,8 @@ # SourceDraft release checklist -Use this before tagging a public release or promoting the repository. +Use this before tagging a public release or promoting the repository. For +first-time public promotion (announcements, demos, screenshots), also run +[docs/public-launch-checklist.md](docs/public-launch-checklist.md). ## Automated checks @@ -32,7 +34,9 @@ pnpm test:e2e - [ ] Docs state: early local/private MVP, not hosted SaaS, not production multi-user auth - [ ] GitHub Contents API limits documented - [ ] `mediaDir` vs `publicMediaPath` documented -- [ ] Issue templates present under `.github/ISSUE_TEMPLATE/` +- [ ] Issue templates present under `.github/ISSUE_TEMPLATE/` (bug, feature, adapter, publisher, security hardening) +- [ ] `.github/pull_request_template.md` present +- [ ] `SECURITY.md` reporting instructions current ## Screenshots @@ -88,4 +92,4 @@ Only tag after automated checks pass and manual acceptance is satisfactory. - Post list in Studio for Bitbucket, WordPress, and Ghost - Git Trees API indexer for very large repos -Roadmap: [docs/compatibility-roadmap.md](docs/compatibility-roadmap.md) +Roadmap: [docs/roadmap.md](docs/roadmap.md) · [docs/compatibility-roadmap.md](docs/compatibility-roadmap.md) diff --git a/SECURITY.md b/SECURITY.md index f4012b1..b84444f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,44 @@ # Security Policy -Do not report real secrets in public issues. +SourceDraft is an early open-source MVP intended for **local or private** +deployments. Its threat model and current limitations are documented in +[docs/security.md](docs/security.md) — read that first. -GitHub tokens and publishing credentials must stay server-side. +## Reporting a vulnerability + +Please do **not** open a public issue for security vulnerabilities. + +Report privately via [GitHub Security Advisories](https://github.com/bnz183/SourceDraft/security/advisories/new) +("Report a vulnerability" on the repository's Security tab). + +Include where practical: + +- Affected area (Studio UI, publish API, a specific publisher/adapter/media provider) +- Reproduction steps or proof of concept +- Impact assessment (what an attacker gains) + +You should receive an initial response within 7 days. This is a small +maintainer-run project — fixes are best-effort but security reports are +prioritized over feature work. + +## Scope notes + +Known, documented MVP limitations are not considered vulnerabilities on their +own (but bypasses of the documented protections are): + +- Single shared password auth with in-memory sessions (local/private use only) +- No CSRF tokens — `Sec-Fetch-Site`/`Origin` checks instead +- Studio is not hardened for public internet exposure + +## Handling secrets + +- GitHub/GitLab/Bitbucket tokens, WordPress/Ghost/Cloudinary credentials, and + the admin password live in `.env` and are read **server-side only**. +- Never commit `.env` or `.env.local`; never paste tokens, passwords, or + private repository details into issues, PRs, or screenshots. +- If you accidentally expose a token, revoke and rotate it immediately. + +## Automated checks + +CI runs build, unit tests, and Playwright smoke tests; GitHub CodeQL analyzes +JavaScript/TypeScript and Actions workflows on pushes and PRs to `main`. diff --git a/docs/architecture.md b/docs/architecture.md index 70576f9..a401ac6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ Publish API (server) → validate (@sourcedraft/core) → adapt (adapterRegistry / @sourcedraft/adapters) → publish (publisherRegistry / @sourcedraft/publishers) - → GitHub repository file in contentDir + → Git repository file in contentDir, or remote CMS API (WordPress, Ghost) Your static site build (outside SourceDraft) → deployed site ``` @@ -26,8 +26,8 @@ Studio (browser) Publish API (server) → validate type, size, signature → sanitize filename - → publisher.uploadMedia (github publisher today) - → GitHub repository file in mediaDir + → media provider (git repo, Cloudinary) via publisher.uploadMedia or provider API + → repository file in mediaDir, or CDN URL Studio → publicPath for heroImage / Markdown body ``` @@ -48,10 +48,13 @@ Publish API (server) | Package | Role | |---------|------| | `@sourcedraft/core` | Article schema and validation | -| `@sourcedraft/adapter-*` | Platform-specific file output (Astro, Markdown, Next.js, Hugo, Eleventy/Jekyll) | +| `@sourcedraft/adapter-*` | Platform-specific file output (Astro MDX, Markdown, Next.js MDX, Hugo, Eleventy/Jekyll, Docusaurus, MkDocs, Nuxt Content) | | `@sourcedraft/adapters` | `adapterRegistry` — built-in adapter registration and dispatch | | `@sourcedraft/github-publisher` | Low-level GitHub Contents API client | -| `@sourcedraft/publishers` | `publisherRegistry` — typed publish/upload/list/read surface | +| `@sourcedraft/publishers` | `publisherRegistry` — GitHub, GitLab, Bitbucket, WordPress, Ghost publish/upload/list/read | +| `@sourcedraft/media-providers` | Git media, Cloudinary, S3-compatible (config validation only) | +| `@sourcedraft/plugins` | Server-side loader for custom adapters/publishers/media providers | +| `@sourcedraft/setup` | Setup wizard and `validate:config` CLI | | `@sourcedraft/config` | Load `sourcedraft.config.json` | ## Studio @@ -72,8 +75,8 @@ See [configuration.md](configuration.md), [adapters.md](adapters.md), [compatibi ## Adapters and publishers -**Adapters** turn a validated article into platform-specific file content. Registered in `adapterRegistry`. +**Adapters** turn a validated article into platform-specific file content. Registered in `adapterRegistry`. Eight ship today — see [adapters.md](adapters.md). -**Publishers** send content to a target. Registered in `publisherRegistry`. Shipped: GitHub (`github`). +**Publishers** send content to a target. Registered in `publisherRegistry`. Shipped: `github`, `gitlab`, `bitbucket` (Git file commits) and `wordpress`, `ghost` (remote CMS APIs) — see [publishers.md](publishers.md) for the capability matrix. -Future publishers (not implemented): WordPress API, Ghost API. +Custom adapters, publishers, and media providers can register through server-side [plugins](plugins.md). diff --git a/docs/brand-assets.md b/docs/brand-assets.md new file mode 100644 index 0000000..2c9373d --- /dev/null +++ b/docs/brand-assets.md @@ -0,0 +1,100 @@ +# Brand assets guide + +Direction for SourceDraft's visual identity. This is guidance for creating +real assets — it does not ship placeholder or fake assets. If an asset does +not exist yet, leave the slot empty rather than faking it. + +## Identity in one line + +A professional open-source developer tool: clean, technical, trustworthy. +Closer in spirit to Git, Vite, or Playwright than to a SaaS landing page. +The brand should signal "this tool respects your repository and your +credentials." + +## Logo direction + +- **Concept space:** drafting + source. Ideas worth exploring: a document + glyph merged with a commit node or branch line; a cursor/caret on a + Markdown `#`; a minimal "SD" monogram with a diff/branch accent. +- **Style:** flat, geometric, single-weight strokes. No gradients, no 3D, no + mascots, no AI-generated look. +- **Variants needed:** square mark (favicon/avatar, works at 32px), and a + horizontal lockup (mark + "SourceDraft" wordmark) for README and social + preview. +- **Wordmark:** a clean sans-serif or semi-mono face; "SourceDraft" set as + one word, capital S and D. +- **Format:** SVG source of truth; export PNG at 1x/2x. Keep SVGs hand-edited + or optimized (no embedded raster data). + +## Color and style direction + +- **Base:** near-black/near-white neutrals (editor chrome territory), one + restrained accent color used sparingly for actions and the logo accent. + Candidates: a desaturated teal/cyan or amber — pick one, use it everywhere, + avoid the default-blue-SaaS look and avoid purple-gradient AI clichés. +- **Semantic colors** stay conventional: green = success/shipped, amber = + warning/experimental, red = error. Don't repurpose them decoratively. +- **Typography in assets:** system/sans for UI text, monospace for paths, + frontmatter, and code — monospace is part of the identity (this is a tool + about files). +- Generous whitespace; no drop shadows heavier than the Studio UI itself. + +## Screenshot style + +Screenshots are the primary "brand" surface today and must stay honest: + +- Always generated from **demo mode** fixtures via `pnpm screenshots:generate` + (1280×900) — reproducible, never staged with fake content +- Show real UI states: actual warnings in the content QA panel, the real + demo banner, real setup-health rows +- Never include tokens, real repo names you don't want public, personal + paths, or email addresses +- No marketing annotations (arrows, circles, emoji) baked into committed + screenshots; annotate copies in blog posts if needed +- Same browser, same width, light theme consistently (until a dark theme + exists — don't fake one) + +Inventory and rules: [screenshots.md](screenshots.md). + +## Demo video style + +- Record the scripts in [demo-script.md](demo-script.md), demo mode only +- Screen-only capture at 1280px+ logical width; crop to the browser, hide + bookmarks/extensions +- Calm pacing — no jump cuts every two seconds, no stock music with a "growth + hacking" feel; quiet or no music is fine +- Captions or voiceover that states demo mode up front +- 60–90s for social embeds; the 3-minute cut for README/docs linking + +## GitHub social preview + +The 1280×640 image shown when the repo is shared: + +- Lockup (logo + "SourceDraft") + one line: "Open-source publishing Studio + for Markdown, MDX, and Git-backed content" — nothing else +- Solid neutral background or a *real* cropped screenshot as a muted backdrop +- No fake browser chrome around fake UI, no star counts, no badges + +## README hero + +- Keep the current approach: short text intro first, then the real + `studio-overview.png` — readers should meet honest claims before imagery +- When a logo exists, a small centered mark above the H1 is enough; do not + add a marketing banner image +- Badges (CI status, license) are fine; keep to one row, only badges that + reflect real automated state + +## Required vs optional + +| Asset | Status | Priority | +|-------|--------|----------| +| Studio screenshots (×9, generated) | Exists | Required — keep current | +| GitHub repo description + topics | Settings task | Required before promotion | +| Square logo mark (SVG + PNG) | Not created | Required before heavy promotion | +| Social preview image (1280×640) | Not created | Required before heavy promotion | +| Horizontal lockup | Not created | Optional | +| Demo video (60–90s) | Not created | Optional, high value | +| Dark-theme variants | Not applicable yet | Optional, later | + +Store finished assets under `docs/assets/brand/` with an `ATTRIBUTION.md` if +any external resources (fonts, icon bases) require it. diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000..d4613a5 --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,121 @@ +# How SourceDraft compares + +An honest comparison with tools you might be evaluating. All of these are +good projects with real strengths; SourceDraft is younger and smaller than +every tool on this page. The goal here is fit, not a winner. + +**Where SourceDraft sits:** a local/private Studio (editor + server-side +publish API) that writes plain `.md`/`.mdx` files to your own Git repository +through framework adapters, or publishes to WordPress/Ghost APIs. Content +stays portable; credentials stay on your server; nothing is hosted for you. + +Status caveat: SourceDraft is an early MVP — see +[project-status.md](project-status.md). If you need a mature, battle-tested +product today, several tools below are further along. + +## Quick orientation + +| Tool | Hosting model | Where content lives | Editing model | +|------|---------------|--------------------|---------------| +| **SourceDraft** | Self-run, local/private | `.md`/`.mdx` in your Git repo (or WP/Ghost via API) | Tiptap rich editor + source mode, adapter preview | +| **Decap CMS** | Embedded in your static site (`/admin`) | Files in your Git repo | Widget-based forms + Markdown editor | +| **TinaCMS** | Self-hosted or Tina Cloud backend | Files in your Git repo | Visual/inline editing on your site | +| **CloudCannon** | Commercial hosted platform | Files in your Git repo | Visual editing, hosted UI | +| **GitCMS** | Hosted editor UI | Files in your Git repo | Hosted Markdown editing | +| **WordPress admin** | Self-hosted or wordpress.com | WordPress database | Block editor (Gutenberg) | +| **Ghost admin** | Self-hosted or Ghost(Pro) | Ghost database | Koenig editor | + +## Decap CMS (formerly Netlify CMS) + +**Decap is stronger on:** maturity and community, running with zero extra +process (it ships as static files in your site's `/admin`), a large widget +ecosystem, editorial workflow (draft/review states backed by PRs), and Git +gateway auth options. + +**SourceDraft differs:** the Studio runs as its own local app with a +server-side publish API, so tokens never go through a browser-side Git +gateway and no `admin/config.yml` lives in your site repo. One universal +article schema renders through adapters for eight frameworks, with a preview +of the exact file path and frontmatter before each commit. If you want an +editor embedded in the deployed site itself, Decap is the better shape. + +## TinaCMS + +**Tina is stronger on:** visual/inline editing on your actual site, a typed +content schema with GraphQL queries, strong Next.js integration, and a +polished hosted backend (Tina Cloud) when you want auth and collaboration +without building it. + +**SourceDraft differs:** it is file-first rather than site-first — there is +no integration into your site's rendering at all, which means it works the +same for Astro, Hugo, MkDocs, or Nuxt Content without framework bindings. No +cloud account is involved; everything runs from your `.env`. If live visual +editing on the rendered page matters to you, Tina is the better fit. + +## CloudCannon + +**CloudCannon is stronger on:** being a complete commercial product — hosted +visual editor, team accounts and permissions, client-friendly editing, +support, and site hosting/builds. For agencies handing a site to +non-technical clients, it is a much more finished answer. + +**SourceDraft differs:** it is free, AGPL, and entirely under your control — +no vendor account, no hosted service touching your repository, credentials +only on your own machine or server. You trade CloudCannon's polish and team +features for ownership and zero platform dependency. + +## GitCMS + +**GitCMS is stronger on:** instant onboarding — connect a repo from the +browser and start editing without installing or running anything yourself. + +**SourceDraft differs:** it never asks you to grant a third-party service +access to your repository. The publish API runs where you run it, tokens stay +in your `.env`, and the adapter/publisher layer targets multiple frameworks +and remote CMS APIs rather than a single hosted editing surface. + +## WordPress admin + +**WordPress is stronger on:** almost everything a full CMS does — themes, +plugins, media management, scheduling, multi-user roles, and twenty years of +ecosystem. If your site *is* WordPress, its admin is the native tool. + +**SourceDraft differs:** it is not a CMS replacement; it is a Git-first +writing tool. Content is Markdown/MDX in your repo, not rows in a database, +so it diffs, reviews, and migrates like code. SourceDraft can also *publish +to* WordPress through its REST API — useful if you want one Markdown-first +editor in front of both a static site and a WordPress site (with documented +limits: no post list in Studio, body sent as-is without HTML conversion). + +## Ghost admin + +**Ghost is stronger on:** being a complete publishing platform — excellent +editor, memberships, newsletters, SEO handling, and hosting via Ghost(Pro). +For a standalone publication, Ghost is a far more complete product. + +**SourceDraft differs:** same trade as WordPress — Git-owned files first, +with an optional Ghost publisher (Admin API; same documented limits) when +part of your content lives in Ghost. + +## When SourceDraft is probably the wrong choice + +- You need hosted multi-user accounts, roles, or client access today +- You want visual editing on the rendered page +- Your writers are non-technical and no developer runs the Studio for them +- You need a mature product with a large plugin/community ecosystem +- You need to expose the editor on the public internet (MVP auth is + local/private only — see [security.md](security.md)) + +## When SourceDraft fits + +- You own a Git-backed Astro/Next.js/Hugo/Eleventy/Docusaurus/MkDocs/Nuxt + site and want one editor that writes correct frontmatter and file paths +- You want content portable as plain files, with commits (or PRs) you review +- You want publish credentials on a server you control, never in a browser + or third-party platform +- You want one schema to target several frameworks — or both files and a + remote CMS — through adapters and publishers + +See also: [project-status.md](project-status.md) · +[publishers.md](publishers.md) · [adapters.md](adapters.md) · +[roadmap.md](roadmap.md) diff --git a/docs/compatibility-roadmap.md b/docs/compatibility-roadmap.md index b6e3617..8bbe7a5 100644 --- a/docs/compatibility-roadmap.md +++ b/docs/compatibility-roadmap.md @@ -46,7 +46,7 @@ Defaults: `adapter: "astro-mdx"`, `publisher: "github"`. **Adapters:** `astro-mdx`, `markdown`, `nextjs-mdx`, `hugo-markdown`, `eleventy-jekyll-markdown`, `docusaurus-mdx`, `mkdocs-markdown`, `nuxt-content-markdown` -**Publishers:** `github` (wraps `@sourcedraft/github-publisher`) +**Publishers:** `github` (wraps `@sourcedraft/github-publisher`), `gitlab`, `bitbucket`, `wordpress`, `ghost` — see [publishers.md](publishers.md) for the capability matrix ## Studio integration points @@ -60,10 +60,10 @@ Defaults: `adapter: "astro-mdx"`, `publisher: "github"`. ## Future (not implemented) -- WordPress REST API publisher -- Ghost API publisher -- Plugin/marketplace loading - Git Trees API for large repos +- Post list/read in Studio for Bitbucket, WordPress, and Ghost +- Full S3/R2 media upload (`s3-compatible` validates config only today) +- Dedicated `listMedia` capability (media library reuses `listPosts`) ## Risks diff --git a/docs/contributing-roadmap.md b/docs/contributing-roadmap.md new file mode 100644 index 0000000..c587f28 --- /dev/null +++ b/docs/contributing-roadmap.md @@ -0,0 +1,92 @@ +# Contributing roadmap + +Concrete, scoped ways to contribute, ordered roughly by ramp-up cost. Read +[CONTRIBUTING.md](../CONTRIBUTING.md) first for setup, branch workflow, and +test requirements. When in doubt, open an issue before writing code. + +## Good first issues + +Small, self-contained, low-risk: + +- Fix unclear or outdated wording in any `docs/*.md` (file a PR directly) +- Improve a server-side error message to name the fix, not just the failure +- Add a unit test for an untested pure helper in `packages/*` +- Improve an empty state or loading message in Studio +- Add a troubleshooting row to [getting-started.md](getting-started.md) for a + problem you actually hit + +## Documentation tasks + +- A quickstart recipe for a framework/host combination you use + ([quickstart-recipes.md](quickstart-recipes.md)) +- Walkthrough improvements informed by a real first-run experience — note + where you got stuck, then fix that spot +- Verify a compatibility matrix row against the code and correct drift +- Translate confusing Git/CMS jargon into plainer language in + [non-technical-overview.md](non-technical-overview.md) + +## Adapter tasks + +Adapters are the friendliest code contribution — one package, clear +interface, pure functions, easy tests. See the interface in +[compatibility-roadmap.md](compatibility-roadmap.md) and copy the structure +of `packages/adapter-hugo-markdown`. + +- New adapter for a framework you run (propose via the adapter request + template first — include frontmatter format, path conventions, field + mapping) +- Additional `adapterOptions` for existing adapters where a real site needs + them (keep options minimal) +- `fromFrontmatter` edge cases: posts with unusual-but-valid frontmatter + that fail to load today, with tests + +## Publisher tasks + +Heavier — network APIs, capability declarations, error mapping: + +- **Post list/read for Bitbucket, WordPress, Ghost** (top roadmap item — + makes the Posts sidebar work for those targets) +- Clearer mapped errors from publisher APIs (rate limits, permissions, + protected branches) +- New publisher proposals via the publisher request template — include the + official API docs and auth model + +## Testing tasks + +- Unit tests for uncovered registry/server logic +- Playwright smoke tests for flows demo mode already exercises (keep them + credential-free and deterministic) +- Edge-case tests: huge bodies, unicode slugs, empty optional fields, + unusual dates + +## Security hardening tasks + +Use the security hardening issue template. Genuinely useful areas: + +- Durable session storage (in-memory today) +- CSRF token support beyond `Sec-Fetch-Site`/`Origin` checks +- Stricter upload validation or content sniffing improvements +- Reverse-proxy deployment guidance (HTTPS, headers, `STUDIO_ALLOWED_ORIGINS`) + +For exploitable vulnerabilities, **do not open an issue** — follow +[SECURITY.md](../SECURITY.md). + +## Maintainer expectations + +What you can expect: + +- Best-effort issue/PR responses — this is a solo-maintained project; days, + not hours +- Honest review: small focused PRs get reviewed quickly; large unsolicited + refactors may be declined even if good +- Security reports prioritized over features + +What is expected of you: + +- `pnpm build` and `pnpm test` pass before requesting review; `pnpm test:e2e` + when UI/auth/publish flows change +- Stay in scope: no monetization, telemetry, OAuth/RBAC, hosted features, or + dependency additions without prior discussion (see [roadmap.md](roadmap.md) + "Explicitly not now") +- No secrets in code, fixtures, screenshots, issues, or commits +- Contributions are licensed AGPL-3.0-or-later diff --git a/docs/demo-script.md b/docs/demo-script.md new file mode 100644 index 0000000..e8d1c0a --- /dev/null +++ b/docs/demo-script.md @@ -0,0 +1,84 @@ +# Demo scripts + +Scripts for live demos and screen recordings. All of them run entirely in +**demo mode** — no credentials, no real commits — so they are safe to record +and reproducible from fixtures. + +Setup before recording: fresh `pnpm dev`, browser at ~1280px width, no real +tokens anywhere on screen. Demo mode entry: leave GitHub vars unset and click +**Explore demo mode**, or set `SOURCEDRAFT_DEMO_MODE=true`. See +[demo-mode.md](demo-mode.md). Style guidance: [brand-assets.md](brand-assets.md). + +Honesty rule for every cut: if the recording shows demo mode, say so. Never +present a simulated publish as a real commit. + +## 60-second demo + +Goal: what SourceDraft is, in one breath. One take, no detours. + +1. **(0:00)** Sign-in screen. Say: "SourceDraft is an open-source Studio for + Markdown and MDX on Git-backed sites. This is demo mode — no credentials, + nothing gets committed." Click **Explore demo mode**. +2. **(0:10)** Posts sidebar. Open a sample post. "Posts are plain files in + your repo; Studio reads them back through the same adapters it writes with." +3. **(0:20)** Type in the editor; show the slash command menu briefly. +4. **(0:35)** Open the preview. "This is the exact file and repo path the + adapter will write — frontmatter included. Eight framework adapters: + Astro, Hugo, Next.js, Docusaurus, MkDocs, more." +5. **(0:50)** Click publish → **Publish simulated**. "Real mode commits to + GitHub — directly or as a pull request. Tokens stay server-side in `.env`. + It's an early MVP, AGPL, on GitHub." + +## 90-second demo + +The 60-second script plus trust beats. Insert after step 4: + +- **(+0:00)** Content quality panel: "Non-blocking QA — SEO length, alt + text, headings, links. Warnings, not gates; you stay in control." +- **(+0:15)** Settings → Setup health: "Studio tells you what's configured + and what's missing — booleans only, it never shows secret values." + +And extend the closing line: "What it doesn't do yet: no hosted service, no +multi-user accounts, single-password auth meant for local use. That's all +documented." + +## 3-minute demo + +Full walkthrough for a recorded video or a live audience. + +1. **(0:00) Framing.** Sign-in screen visible. "If you run an Astro, Hugo, or + Next.js blog, publishing means hand-writing frontmatter and committing + files. SourceDraft is a local Studio that does that for you — content + stays in your Git repo, credentials stay on your server. This whole demo + is demo mode: sample posts, simulated publishing, zero credentials." +2. **(0:20) Enter demo mode.** Point out the banner — the UI itself tells + you nothing will be committed. +3. **(0:35) Create a post.** New post → title, description, category, tags. + Show slash commands and the Markdown toolbar; flip to source mode briefly: + "rich editor or raw Markdown — your choice." +4. **(1:15) Content QA.** Open content quality. Leave description short or + an image alt empty so a real warning shows. "Recommendations, not + blockers." +5. **(1:35) Preview.** "The adapter renders the exact file: YAML frontmatter, + body, and the path under `contentDir`. Switch adapters and the same post + renders for Hugo or Docusaurus conventions instead." +6. **(2:00) Media.** Upload an image from fixtures; show it in the media + library and insert it. "In real mode this commits to your `mediaDir` or + uploads to Cloudinary; the URL uses your configured public path." +7. **(2:20) Publish.** Publish checklist → validation status, output path, + publish mode → **Simulate publish** → simulated success. "Real mode: + direct commit, or a pull request against a protected branch." +8. **(2:40) Secrets.** Open Settings → Setup health. "The browser never sees + tokens. The publish API reads `.env` on the server and reports only + booleans here." +9. **(2:50) Honest close.** "Early MVP: single-password auth for local or + private use, no OAuth or teams, some publisher features are partial — + the compatibility matrices in the docs say exactly what works. AGPL, + on GitHub, feedback welcome." + +## After recording + +- Check every frame for personal paths, emails, or anything token-shaped +- Keep claims within [project-status.md](project-status.md) +- Update or re-record when the UI changes materially (same rule as + screenshots — see [screenshots.md](screenshots.md)) diff --git a/docs/portfolio-case-study.md b/docs/portfolio-case-study.md new file mode 100644 index 0000000..09464b5 --- /dev/null +++ b/docs/portfolio-case-study.md @@ -0,0 +1,148 @@ +# SourceDraft — engineering case study + +A walkthrough of SourceDraft as a piece of engineering: the problem, the +architecture, the trade-offs, and what was deliberately left out. Written for +engineers, maintainers, and technical founders evaluating the project or its +author. + +## The problem + +Git-backed static sites (Astro, Hugo, Next.js, Docusaurus, MkDocs, Nuxt +Content, Eleventy/Jekyll) make publishing a developer workflow: hand-write +YAML frontmatter, get the filename convention right, commit, push. That is +fine for developers and hostile to everyone else — and error-prone even for +developers (wrong date format, duplicate slug, missing alt text, broken +frontmatter that fails the site build). + +Existing Git-based CMS options solve this with an editor embedded in the +deployed site (Decap), framework-coupled visual editing (Tina), or a hosted +platform that gets write access to your repository (CloudCannon, GitCMS). +Each is a good trade for someone. SourceDraft makes a different one: a +**local Studio with a server-side publish API**, so content stays plain +files in your repo and credentials never leave your own environment. + +SourceDraft began as an internal publishing tool for QuBrite.com and was +generalized into an open-source project. QuBrite is the origin story, not a +dependency — nothing in core references it. + +## Target users + +Solo developers, technical bloggers, documentation maintainers, and small +teams that want Git-owned content with a simpler writing workflow — people +comfortable running `pnpm dev` but tired of hand-editing frontmatter. + +## Architecture + +``` +Studio (React, browser) — editor, preview, no secrets ever + │ article JSON / multipart upload +Publish API (Express, server) — auth, validation, credentials from .env + │ +core ──► adapterRegistry ──► publisherRegistry ──► your target +schema renders file commits via Git API (GitHub, GitLab, ++ validation or calls CMS API Bitbucket, WP, Ghost) +``` + +The design rests on one schema and two registries: + +- **`@sourcedraft/core`** — a universal article schema (title, slug, dates, + category, tags, draft, body, optional SEO fields) with validation. Every + feature upstream and downstream speaks this type. +- **`adapterRegistry`** (`@sourcedraft/adapters`) — adapters render a + validated article into platform-specific output: frontmatter dialect + (YAML/TOML), field mapping (`pubDate` → `date` → `lastmod`), file + extension, and path convention (`slug`, `date-slug`, `index`). Adapters + also parse existing files back into the schema (`fromFrontmatter`), which + powers editing. +- **`publisherRegistry`** (`@sourcedraft/publishers`) — publishers move + content to a target and declare `capabilities` (publish, upload media, + list, read). Unsupported operations return typed `{ ok: false, error }` + results instead of throwing, so Studio can degrade clearly per target. + +Because adapters and publishers are orthogonal, the matrix multiplies +instead of adding: 8 adapters × 5 publishers from one editor, plus a +server-side plugin loader (`@sourcedraft/plugins`) for custom connectors +without forking. + +## Monorepo layout + +| Workspace | Role | +|-----------|------| +| `apps/studio` | React Studio UI + Express publish API | +| `packages/core` | Universal article schema and validation | +| `packages/adapter-*` (×8) | One package per framework adapter | +| `packages/adapters`, `packages/publishers` | Registries | +| `packages/github-publisher` | GitHub Contents API client (commit, PR, draft-PR modes) | +| `packages/media-providers` | Git media, Cloudinary, S3-compatible (config-only) | +| `packages/plugins` | Server-side plugin loader | +| `packages/setup` | Interactive setup wizard + config validation CLI | +| `examples/*` (×7) | Folder-layout integration references per framework | + +Plain TypeScript, `node --test` unit tests colocated with source, no +framework beyond React/Express/Tiptap. Boring on purpose. + +## Security model + +The central invariant: **secrets exist only in `.env`, read only by the +publish API**. Browser code never imports publisher packages. + +- Auth: single shared password (scrypt hash preferred, plaintext fallback for + local dev), HttpOnly `SameSite=Lax` session cookies, in-memory sessions. +- State-changing routes check `Sec-Fetch-Site`/`Origin`; rate limiting on + auth, publish, media, and read endpoints. +- Media uploads validate MIME type, size, and file signatures; filenames are + sanitized; path traversal blocked; no SVG/HTML/executables. +- Setup health endpoint reports config *presence* booleans, never values. +- Demo mode is a hard gate: when forced on, publish and upload simulate + success and remote calls never happen, even with credentials present. + +The docs say plainly what this is: MVP hardening for local/private use, not +production multi-user auth. That transparency is part of the design. + +## Quality engineering + +- CI on every push/PR: `pnpm build`, unit tests across all packages, and + Playwright e2e smoke tests that run entirely in demo mode — so CI needs no + live credentials and tests are deterministic from fixtures. +- GitHub CodeQL on JS/TS and Actions workflows. +- README screenshots are *generated* by Playwright from demo fixtures + (`pnpm screenshots:generate`) — reproducible, never staged or faked. +- A release checklist, manual acceptance script, and per-connector docs with + capability matrices that state what does **not** work. + +## Product-side details worth noting + +- **Publish confidence loop:** validation → content QA warnings (SEO, alt + text, headings, links) → preview of the exact output file and repo path → + publish checklist → publish, optionally as a PR against protected branches. +- **Setup detection:** scans a local project to suggest adapter and paths; + **content audit** scans existing posts read-only before you trust the tool + with them. +- **Demo mode** doubles as onboarding (try Studio with zero credentials) and + as the e2e test substrate. + +## Trade-offs and honest limitations + +| Decision | Cost accepted | +|----------|---------------| +| Local Studio + own API instead of hosted | No multi-user, no client access; you run it yourself | +| Shared-password MVP auth | Not safe for public exposure; in-memory sessions reset on restart | +| GitHub Contents API (no Trees indexer) | ~1000 entries/folder listing limit, ~1 MB inline files | +| No Markdown→HTML converter for WP/Ghost | Remote CMS bodies sent as-is; rendering depends on target | +| `s3-compatible` config validation only | No S3/R2 upload yet — documented, not hidden | +| File-first, no site rendering integration | No visual/inline editing on the deployed page | + +## Roadmap + +Near-term: deeper publisher capabilities (post list for Bitbucket/WP/Ghost), +Git Trees API for large repos, S3/R2 upload, durable sessions. Later: +self-host hardening for small teams. Possible commercial layer (hosted Cloud, +OAuth/teams) is explicitly future-only — see [roadmap.md](roadmap.md). + +## Where to look in the code + +- Schema and validation: `packages/core/src` +- A complete small adapter: `packages/adapter-hugo-markdown/src` +- Registry pattern: `packages/adapters/src`, `packages/publishers/src` +- API surface and auth middleware: `apps/studio/server` +- E2E approach: `apps/studio/e2e` + demo fixtures in `apps/studio/server/demo` diff --git a/docs/project-status.md b/docs/project-status.md index bf472e9..df0b62c 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -51,7 +51,7 @@ Early open-source MVP — usable for solo writing and publishing to Git or remot | **WordPress/Ghost admin** | Optional **publishers** — SourceDraft is not a full WP/Ghost replacement | Native CMS UI and media library | | **Static dashboard** | Validates universal schema, previews exact file path, multi-adapter | Often framework-specific or hosted | -SourceDraft fits when you want one editor for Markdown/MDX files **or** API publish to WP/Ghost, with secrets on the server only. +SourceDraft fits when you want one editor for Markdown/MDX files **or** API publish to WP/Ghost, with secrets on the server only. Full honest comparison: [comparison.md](comparison.md) ## Demo mode diff --git a/docs/public-launch-checklist.md b/docs/public-launch-checklist.md new file mode 100644 index 0000000..17b8070 --- /dev/null +++ b/docs/public-launch-checklist.md @@ -0,0 +1,136 @@ +# Public launch checklist + +Operator checklist for taking SourceDraft from "public repository" to +"actively promoted project". Complements [RELEASE_CHECKLIST.md](../RELEASE_CHECKLIST.md) +(per-release gates) — run both before the first announcement. + +## 1. Pre-public checks + +- [ ] `RELEASE_CHECKLIST.md` fully green for the current version +- [ ] README renders correctly on GitHub (images, tables, anchors) +- [ ] All README/doc links resolve (no 404s, no links to private resources) +- [ ] `docs/project-status.md` matches reality — shipped / experimental / not + shipped all still accurate +- [ ] No production/SaaS/enterprise overclaims anywhere in docs +- [ ] License consistent: AGPL-3.0-or-later in `LICENSE`, `package.json`, README +- [ ] GitHub repo settings: description, topics (`cms`, `markdown`, `mdx`, + `git-based-cms`, `static-site-generator`, `astro`), social preview image +- [ ] Issue templates and PR template work (open a draft issue to verify) +- [ ] `SECURITY.md` private reporting path works (Security tab → advisories enabled) + +## 2. Build and test + +```bash +pnpm install --frozen-lockfile +pnpm build +pnpm test +pnpm exec playwright install chromium # first time only +pnpm test:e2e +``` + +- [ ] All commands exit 0 locally +- [ ] CI green on `main` (build-and-test + studio-e2e jobs) +- [ ] CodeQL: no open high-severity alerts + +## 3. Security sanity checks + +- [ ] No-secrets scan on tracked files: + `git grep -nIiE 'ghp_[A-Za-z0-9]|gho_[A-Za-z0-9]|glpat-|BEGIN [A-Z ]*PRIVATE KEY' -- ':!*.example*'` → no hits +- [ ] `git ls-files | grep -E '^\.env'` → only `.env.example` +- [ ] `sourcedraft.config.example.json` and `.env.example` contain placeholders only +- [ ] Screenshots in `docs/assets/` show demo content, no tokens or personal data +- [ ] `docs/security.md` limitations section still matches the code + +## 4. Demo mode test (fresh-clone simulation) + +In a clean clone with no `.env`: + +- [ ] `pnpm install && pnpm dev` starts without errors +- [ ] Sign-in screen shows **Explore demo mode** +- [ ] Demo banner clearly states no commits are made +- [ ] Sample posts load; editing works; **Simulate publish** succeeds +- [ ] Settings → Setup health shows useful next actions, no secret values + +## 5. Manual GitHub publish test + +Against a **test** repository (never production): + +- [ ] Direct mode: publish a new post → file appears at the expected + `contentDir` path with correct frontmatter +- [ ] Upload an image → lands in `mediaDir`; URL in post uses `publicMediaPath` +- [ ] Edit the same post → same file updated, no duplicate +- [ ] Wrong token/repo produces a clear error, not a blank screen + +## 6. PR publish test + +On a test repo with branch protection on `main`: + +- [ ] `SOURCEDRAFT_PUBLISH_MODE=pull-request` → publish creates a PR with the + expected file path and branch prefix +- [ ] Optional: `draft-pull-request` mode creates a draft PR +- [ ] Merging the PR produces the correct file on `main` + +## 7. Screenshot / video checklist + +- [ ] `pnpm screenshots:generate` run after any UI change; diffs committed +- [ ] All nine README/doc screenshots current (see [screenshots.md](screenshots.md)) +- [ ] Optional demo video recorded from [demo-script.md](demo-script.md) — + demo mode only, 1280px+ width, no real credentials on screen +- [ ] Visual style follows [brand-assets.md](brand-assets.md) + +## 8. What NOT to promote yet + +Do not claim or imply any of the following in announcements: + +- Production-ready, enterprise-ready, or "secure for public deployment" +- Multi-user, teams, roles, or OAuth +- Hosted/cloud offering of any kind +- S3/R2 media uploads (config validation only today) +- Post list/editing in Studio for Bitbucket, WordPress, or Ghost +- Any metric you have not measured (users, stars, performance) + +Honest framing wins long-term trust and avoids day-one corrections from +commenters. + +## 9. Suggested announcement wording + +Adjust voice to the venue; keep claims within shipped scope. + +**Short (social / link aggregator title):** + +> SourceDraft — an open-source publishing Studio for Markdown/MDX and +> Git-backed sites. Local editor, server-side publish API, 8 framework +> adapters, 5 publish targets (GitHub/GitLab/Bitbucket/WordPress/Ghost). +> AGPL. Early MVP, feedback welcome. + +**Longer (Show HN / Reddit / blog post intro):** + +> I built SourceDraft, an open-source (AGPL) publishing Studio for +> Git-backed content workflows. You write Markdown/MDX in a local browser +> Studio (Tiptap editor, content QA, preview of the exact file and path), +> and a server-side publish API commits to your repo — direct or as a pull +> request — or publishes to WordPress/Ghost APIs. Adapters handle +> frontmatter and path conventions for Astro, Next.js, Hugo, +> Eleventy/Jekyll, Docusaurus, MkDocs, and Nuxt Content. +> +> Design goals: content stays plain files in your Git repo, credentials stay +> server-side in your `.env`, and no hosted service gets access to your +> repository. There's a demo mode that runs with zero credentials if you +> want to try it in two minutes. +> +> It's an early MVP and honest about it: single-password auth for +> local/private use, no multi-user/OAuth, some publisher capabilities are +> partial (documented in the compatibility matrices). Roadmap and +> limitations are in the repo. I'd appreciate feedback, especially from +> people running Decap/Tina/CloudCannon who can tell me what they'd miss. + +- [ ] Announcement drafted and checked against section 8 +- [ ] You (the maintainer) are available for ~48h after posting to answer + issues and comments + +## 10. After launch + +- [ ] Watch issues/discussions daily for the first week +- [ ] Label incoming issues; point newcomers to + [contributing-roadmap.md](contributing-roadmap.md) +- [ ] Note recurring confusion → docs fixes are the highest-leverage follow-up diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..081d32c --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,70 @@ +# Roadmap + +Where SourceDraft is heading, in honest tiers. Nothing here is a commitment +with a date; items move based on real usage and contributor interest. +Technical extension details live in +[compatibility-roadmap.md](compatibility-roadmap.md). + +The free, open-source AGPL version is the product. It will not be +artificially crippled to sell something later. + +## Near-term (open-source, current focus) + +Improvements to the existing local/private workflow: + +- **Post list/read for more publishers** — Bitbucket, WordPress, and Ghost + can publish but cannot list posts in Studio yet +- **S3/R2 media upload** — `s3-compatible` currently validates config only +- **Git Trees API indexer** — lift the Contents API limits (~1000 + entries/folder, ~1 MB inline files) for large repos +- **Media management** — delete/rename in the media library (upload + list today) +- **Better error surfaces** — keep improving actionable publisher/API errors +- **Docs and onboarding** — quickstart recipes, troubleshooting, more + framework examples +- **More adapters via community** — see + [contributing-roadmap.md](contributing-roadmap.md) + +## Later (self-hosted hardening) + +For people running Studio beyond a single laptop — still open source: + +- Durable sessions (survive API restarts) +- CSRF tokens and stricter request protection suitable for reverse-proxy + deployments +- Markdown→HTML conversion option for WordPress/Ghost bodies +- Scheduled/draft publishing workflows on top of PR modes +- Optional multi-password or per-writer identification (not full RBAC) + +## Future commercial possibilities (not built, not promised) + +If SourceDraft earns real adoption, a paid layer could fund maintenance. +Candidates, listed for transparency: + +- Hosted **SourceDraft Cloud** (managed Studio + publish API) +- Managed setup/onboarding and migration services +- OAuth, team accounts, RBAC, persistent sessions as managed features +- Managed media storage +- Agency/client workspaces +- Premium/commercial support; possible dual-license arrangements + +None of this exists, none of it is scheduled, and none of it will remove +functionality from the open-source version. + +## Explicitly not now + +Deliberately out of scope in the current phase: + +- Paywalls, billing, SaaS plans, license gates +- Telemetry or usage analytics +- OAuth / user accounts / RBAC implementations +- Hosted or multi-tenant Studio +- Plugin marketplace +- AI writing tools +- Site hosting or running your static-site build +- Large UI redesigns + +## Influence the roadmap + +Open an issue with the `feature_request`, `adapter_request`, or +`publisher_request` template. Real workflows beat hypotheticals — describe +what you publish, where, and what breaks today. From 3eda386306daeb709dba314a6134305c67db4d5e Mon Sep 17 00:00:00 2001 From: bnz183 Date: Fri, 12 Jun 2026 06:05:59 +0200 Subject: [PATCH 2/2] security: harden Studio auth with Argon2id and tighter rate limits Replace scrypt for new password hashes while keeping legacy verification, tighten auth rate limits with env overrides, and use generic login failure messages. --- apps/studio/package.json | 1 + apps/studio/server/adminPassword.ts | 50 +++++++++++++++++++- apps/studio/server/auth.test.ts | 71 ++++++++++++++++++++++------ apps/studio/server/auth.ts | 22 +++++---- apps/studio/server/index.ts | 6 +-- apps/studio/server/rateLimit.test.ts | 62 +++++++++++++++++++++--- apps/studio/server/rateLimit.ts | 35 +++++++++++--- docs/security.md | 29 +++++++++--- packages/setup/src/envFile.test.ts | 5 ++ packages/setup/src/envFile.ts | 3 +- pnpm-lock.yaml | 31 ++++++++++++ pnpm-workspace.yaml | 1 + scripts/hash-admin-password.ts | 4 +- 13 files changed, 271 insertions(+), 49 deletions(-) diff --git a/apps/studio/package.json b/apps/studio/package.json index c4247ea..710b34c 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -44,6 +44,7 @@ "@tiptap/react": "^3.26.0", "@tiptap/starter-kit": "^3.26.0", "@tiptap/suggestion": "^3.26.0", + "argon2": "^0.43.1", "busboy": "^1.6.0", "dotenv": "^16.5.0", "express": "^5.1.0", diff --git a/apps/studio/server/adminPassword.ts b/apps/studio/server/adminPassword.ts index c1aaf57..8394d17 100644 --- a/apps/studio/server/adminPassword.ts +++ b/apps/studio/server/adminPassword.ts @@ -1,4 +1,12 @@ import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; +import argon2 from "argon2"; + +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 19456, + timeCost: 2, + parallelism: 1, +} as const; const DEFAULT_SCRYPT_N = 16384; const DEFAULT_SCRYPT_R = 8; @@ -13,6 +21,41 @@ export type ScryptHashParams = { hash: Buffer; }; +export function isArgon2PasswordHash(value: string): boolean { + return value.trim().startsWith("$argon2"); +} + +export function isScryptPasswordHash(value: string): boolean { + return parseScryptPasswordHash(value) !== null; +} + +export async function hashPassword(password: string): Promise { + if (password.length === 0) { + throw new Error("Password must not be empty."); + } + + return argon2.hash(password, ARGON2_OPTIONS); +} + +export async function verifyPassword( + password: string, + encodedHash: string, +): Promise { + if (password.length === 0) { + return false; + } + + if (isArgon2PasswordHash(encodedHash)) { + try { + return await argon2.verify(encodedHash, password); + } catch { + return false; + } + } + + return verifyScryptPassword(password, encodedHash); +} + export function parseScryptPasswordHash( value: string, ): ScryptHashParams | null { @@ -54,7 +97,8 @@ export function formatScryptPasswordHash( ].join("$"); } -export function hashAdminPassword( +/** Legacy scrypt hasher kept for tests and manual migration tooling only. */ +export function hashScryptPassword( password: string, options?: { N?: number; r?: number; p?: number; keylen?: number }, ): string { @@ -68,6 +112,10 @@ export function hashAdminPassword( return formatScryptPasswordHash({ N, r, p, salt, hash }); } +export async function hashAdminPassword(password: string): Promise { + return hashPassword(password); +} + export function verifyScryptPassword( password: string, storedHash: string, diff --git a/apps/studio/server/auth.test.ts b/apps/studio/server/auth.test.ts index f24e6c6..0773e6a 100644 --- a/apps/studio/server/auth.test.ts +++ b/apps/studio/server/auth.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import { afterEach, describe, it } from "node:test"; -import { hashAdminPassword } from "./adminPassword.js"; -import { isAuthConfigured, verifyPassword } from "./auth.js"; +import { + hashAdminPassword, + hashPassword, + hashScryptPassword, + isArgon2PasswordHash, + verifyPassword, +} from "./adminPassword.js"; +import { AUTH_FAILURE_MESSAGE, isAuthConfigured, verifyPassword as verifyAuthPassword } from "./auth.js"; const ENV_KEYS = [ "SOURCEDRAFT_ADMIN_PASSWORD_HASH", @@ -38,46 +44,83 @@ describe("studio auth", () => { restoreEnv(); }); - it("accepts valid scrypt hash login and prefers hash over plaintext", () => { + it("accepts valid argon2 hash login and prefers hash over plaintext", async () => { saveEnv(); clearAuthEnv(); - const hash = hashAdminPassword("studio-secret"); + const hash = await hashAdminPassword("studio-secret"); + assert.equal(isArgon2PasswordHash(hash), true); process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH = hash; process.env.SOURCEDRAFT_ADMIN_PASSWORD = "legacy-only"; assert.equal(isAuthConfigured(), true); - assert.equal(verifyPassword("studio-secret"), true); - assert.equal(verifyPassword("legacy-only"), false); - assert.equal(verifyPassword("wrong"), false); + assert.equal(await verifyAuthPassword("studio-secret"), true); + assert.equal(await verifyAuthPassword("legacy-only"), false); + assert.equal(await verifyAuthPassword("wrong"), false); }); - it("rejects invalid scrypt hash login", () => { + it("accepts legacy scrypt hash login", async () => { + saveEnv(); + clearAuthEnv(); + + const hash = hashScryptPassword("studio-secret"); + process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH = hash; + + assert.equal(isAuthConfigured(), true); + assert.equal(await verifyAuthPassword("studio-secret"), true); + assert.equal(await verifyAuthPassword("wrong"), false); + }); + + it("rejects invalid hash login", async () => { saveEnv(); clearAuthEnv(); process.env.SOURCEDRAFT_ADMIN_PASSWORD_HASH = "scrypt$16384$8$1$invalid$invalid"; assert.equal(isAuthConfigured(), true); - assert.equal(verifyPassword("anything"), false); + assert.equal(await verifyAuthPassword("anything"), false); }); - it("falls back to legacy plaintext password when hash is absent", () => { + it("falls back to legacy plaintext password when hash is absent", async () => { saveEnv(); clearAuthEnv(); process.env.SOURCEDRAFT_ADMIN_PASSWORD = "legacy-password"; assert.equal(isAuthConfigured(), true); - assert.equal(verifyPassword("legacy-password"), true); - assert.equal(verifyPassword("other"), false); + assert.equal(await verifyAuthPassword("legacy-password"), true); + assert.equal(await verifyAuthPassword("other"), false); }); - it("reports missing auth config when no password values are set", () => { + it("reports missing auth config when no password values are set", async () => { saveEnv(); clearAuthEnv(); assert.equal(isAuthConfigured(), false); - assert.equal(verifyPassword("anything"), false); + assert.equal(await verifyAuthPassword("anything"), false); + }); + + it("uses a generic authentication failure message", () => { + assert.equal(AUTH_FAILURE_MESSAGE, "Authentication failed."); + }); +}); + +describe("admin password hashing", () => { + it("stores argon2id hashes instead of plaintext", async () => { + const hash = await hashPassword("studio-secret"); + assert.match(hash, /^\$argon2id\$/); + assert.notEqual(hash, "studio-secret"); + }); + + it("verifies correct and incorrect passwords", async () => { + const hash = await hashPassword("studio-secret"); + assert.equal(await verifyPassword("studio-secret", hash), true); + assert.equal(await verifyPassword("wrong", hash), false); + }); + + it("rejects empty passwords", async () => { + const hash = await hashPassword("studio-secret"); + assert.equal(await verifyPassword("", hash), false); + await assert.rejects(() => hashPassword(""), /Password must not be empty/); }); }); diff --git a/apps/studio/server/auth.ts b/apps/studio/server/auth.ts index 84d76d5..c8b94a5 100644 --- a/apps/studio/server/auth.ts +++ b/apps/studio/server/auth.ts @@ -6,10 +6,12 @@ import { isPublisherConfigured, } from "./demoMode.js"; import { + verifyPassword as verifyStoredPassword, verifyPlaintextPassword, - verifyScryptPassword, } from "./adminPassword.js"; +export const AUTH_FAILURE_MESSAGE = "Authentication failed."; + const SESSION_COOKIE = "sourcedraft_session"; /** 24 hours — in-memory MVP sessions, not durable account auth. */ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; @@ -115,10 +117,14 @@ export function isAuthConfigured(): boolean { return getAdminPasswordHash() !== null || getLegacyAdminPassword() !== null; } -export function verifyPassword(password: string): boolean { +export async function verifyPassword(password: string): Promise { + if (password.length === 0) { + return false; + } + const hash = getAdminPasswordHash(); if (hash !== null) { - return verifyScryptPassword(password, hash); + return verifyStoredPassword(password, hash); } const legacyPassword = getLegacyAdminPassword(); @@ -214,17 +220,17 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo res.status(401).json({ ok: false, error: "Authentication required." }); } -export function login( +export async function login( req: Request, password: string, res: Response, -): { ok: boolean; error?: string } { +): Promise<{ ok: boolean; error?: string; status?: number }> { if (!isAuthConfigured()) { - return { ok: false, error: "Studio auth is not configured." }; + return { ok: false, error: AUTH_FAILURE_MESSAGE, status: 401 }; } - if (!verifyPassword(password)) { - return { ok: false, error: "Invalid password." }; + if (!(await verifyPassword(password))) { + return { ok: false, error: AUTH_FAILURE_MESSAGE, status: 401 }; } const token = createSession(); diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index a546914..302f425 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -69,12 +69,12 @@ app.get("/api/auth/status", readLimiter, (req, res) => { }); }); -app.post("/api/auth/login", strictAuthLimiter, requireSameSiteRequest, (req, res) => { +app.post("/api/auth/login", strictAuthLimiter, requireSameSiteRequest, async (req, res) => { const password = typeof req.body?.password === "string" ? req.body.password : ""; - const result = login(req, password, res); + const result = await login(req, password, res); if (!result.ok) { - res.status(result.error === "Invalid password." ? 401 : 500).json({ + res.status(result.status ?? 401).json({ ok: false, error: result.error, }); diff --git a/apps/studio/server/rateLimit.test.ts b/apps/studio/server/rateLimit.test.ts index 2f1f6e9..fab49b4 100644 --- a/apps/studio/server/rateLimit.test.ts +++ b/apps/studio/server/rateLimit.test.ts @@ -4,7 +4,7 @@ import { describe, it } from "node:test"; import { strictAuthLimiter } from "./rateLimit.js"; describe("rate limiting", () => { - it("returns the standard 429 JSON error payload", async () => { + it("allows requests below the auth threshold", async () => { const previousNodeEnv = process.env.NODE_ENV; const previousRelaxed = process.env.STUDIO_RATE_LIMIT_RELAXED; process.env.NODE_ENV = "production"; @@ -23,9 +23,60 @@ describe("rate limiting", () => { } const baseUrl = `http://127.0.0.1:${address.port}`; - let blockedBody: { ok: boolean; error: string } | null = null; + const response = await fetch(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + assert.equal(response.status, 200); + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + + if (previousRelaxed === undefined) { + delete process.env.STUDIO_RATE_LIMIT_RELAXED; + } else { + process.env.STUDIO_RATE_LIMIT_RELAXED = previousRelaxed; + } + } + }); + + it("returns the standard 429 JSON error payload after the auth threshold", async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousRelaxed = process.env.STUDIO_RATE_LIMIT_RELAXED; + process.env.NODE_ENV = "production"; + delete process.env.STUDIO_RATE_LIMIT_RELAXED; + + try { + const app = express(); + app.post("/api/auth/login", strictAuthLimiter, (_req, res) => { + res.json({ ok: true }); + }); + + const server = app.listen(0); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to bind test server."); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + let blockedBody: { error: string } | null = null; - for (let attempt = 0; attempt < 12; attempt += 1) { + for (let attempt = 0; attempt < 8; attempt += 1) { const response = await fetch(`${baseUrl}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, @@ -33,7 +84,7 @@ describe("rate limiting", () => { }); if (response.status === 429) { - blockedBody = (await response.json()) as { ok: boolean; error: string }; + blockedBody = (await response.json()) as { error: string }; break; } } @@ -49,8 +100,7 @@ describe("rate limiting", () => { }); assert.ok(blockedBody); - assert.equal(blockedBody?.ok, false); - assert.equal(blockedBody?.error, "Too many requests. Try again later."); + assert.equal(blockedBody?.error, "Too many requests. Please try again later."); } finally { if (previousNodeEnv === undefined) { delete process.env.NODE_ENV; diff --git a/apps/studio/server/rateLimit.ts b/apps/studio/server/rateLimit.ts index bb5ca98..7580111 100644 --- a/apps/studio/server/rateLimit.ts +++ b/apps/studio/server/rateLimit.ts @@ -1,6 +1,7 @@ import rateLimit from "express-rate-limit"; -const RATE_LIMIT_MESSAGE = "Too many requests. Try again later."; +const RATE_LIMIT_MESSAGE = "Too many requests. Please try again later."; +const DEFAULT_WINDOW_MS = 15 * 60 * 1000; function isRateLimitRelaxed(): boolean { if (process.env.NODE_ENV !== "production") { @@ -10,30 +11,50 @@ function isRateLimitRelaxed(): boolean { return process.env.STUDIO_RATE_LIMIT_RELAXED?.trim().toLowerCase() === "true"; } +function parseEnvInt(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) { + return fallback; + } + + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} + function resolveMax(max: number): number { return isRateLimitRelaxed() ? max * 20 : max; } -function createLimiter(max: number, windowMs: number) { +const windowMs = parseEnvInt("SOURCEDRAFT_RATE_LIMIT_WINDOW_MS", DEFAULT_WINDOW_MS); + +function createLimiter(max: number) { return rateLimit({ windowMs, max: () => resolveMax(max), standardHeaders: true, legacyHeaders: false, handler: (_req, res) => { - res.status(429).json({ ok: false, error: RATE_LIMIT_MESSAGE }); + res.status(429).json({ error: RATE_LIMIT_MESSAGE }); }, }); } /** Baseline protection for every /api route before body parsing. */ -export const apiLimiter = createLimiter(600, 15 * 60 * 1000); +export const apiLimiter = createLimiter(600); /** Login and demo entry — strict to slow brute-force attempts. */ -export const strictAuthLimiter = createLimiter(10, 15 * 60 * 1000); +export const strictAuthLimiter = createLimiter( + parseEnvInt("SOURCEDRAFT_AUTH_RATE_LIMIT_MAX", 5), +); /** Logout, publish, and media upload — moderate write protection. */ -export const writeLimiter = createLimiter(60, 15 * 60 * 1000); +export const writeLimiter = createLimiter( + parseEnvInt("SOURCEDRAFT_WRITE_RATE_LIMIT_MAX", 60), +); /** Config, health, posts, and media reads — generous for normal Studio use. */ -export const readLimiter = createLimiter(300, 15 * 60 * 1000); +export const readLimiter = createLimiter(120); diff --git a/docs/security.md b/docs/security.md index 14707cb..10bbf6a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -6,7 +6,7 @@ All credentials are read from `.env` in the publish API only. Studio stores a se | Secret | Used for | |--------|----------| -| `SOURCEDRAFT_ADMIN_PASSWORD_HASH` | Studio login (preferred scrypt hash) | +| `SOURCEDRAFT_ADMIN_PASSWORD_HASH` | Studio login (preferred Argon2id hash) | | `SOURCEDRAFT_ADMIN_PASSWORD` | Studio login legacy plaintext fallback for local dev | | `GITHUB_*` | GitHub Contents API (publish, list, media) | | `GITLAB_*` | GitLab Repository Files API | @@ -46,7 +46,20 @@ Sessions reset when the API process restarts. This is not durable account auth. Protected routes include login, logout, publish, and media upload. Middleware checks `Sec-Fetch-Site` or `Origin`/`Referer` and rejects obvious cross-site POSTs. Optional `STUDIO_ALLOWED_ORIGINS` for reverse-proxy deployments. -Rate limiting is enabled on auth, publish, media, and read endpoints. Limits are relaxed automatically outside `NODE_ENV=production` so local development stays usable. Set `STUDIO_RATE_LIMIT_RELAXED=true` in production only when you intentionally need higher local-style limits behind a trusted reverse proxy. +Rate limiting is enabled on auth, publish, media, and read endpoints. Defaults (per IP, 15-minute window): + +| Scope | Limit | Override env var | +|-------|-------|------------------| +| Login / demo entry | 5 | `SOURCEDRAFT_AUTH_RATE_LIMIT_MAX` | +| Publish / upload / logout | 60 | `SOURCEDRAFT_WRITE_RATE_LIMIT_MAX` | +| Reads (config, posts, media) | 120 | — | +| All `/api` routes (baseline) | 600 | — | + +Window length: `SOURCEDRAFT_RATE_LIMIT_WINDOW_MS` (default 900000). + +Limits are relaxed automatically outside `NODE_ENV=production` so local development stays usable. Set `STUDIO_RATE_LIMIT_RELAXED=true` in production only when you intentionally need higher local-style limits behind a trusted reverse proxy. + +Blocked requests return HTTP 429 with `{ "error": "Too many requests. Please try again later." }`. This is basic MVP hardening — not a substitute for CSRF tokens or production-grade account auth on a public deployment. @@ -98,12 +111,12 @@ Details: [plugins.md](plugins.md) ## Studio auth (MVP) -Single shared password, in-memory sessions. +Single shared password, in-memory sessions. Passwords are verified with slow hashing (Argon2id preferred). -Prefer `SOURCEDRAFT_ADMIN_PASSWORD_HASH` over plaintext `SOURCEDRAFT_ADMIN_PASSWORD`. The hash format is: +Prefer `SOURCEDRAFT_ADMIN_PASSWORD_HASH` over plaintext `SOURCEDRAFT_ADMIN_PASSWORD`. New hashes use Argon2id: ```text -scrypt$N$r$p$saltBase64$hashBase64 +$argon2id$v=19$m=19456,t=2,p=1$... ``` Generate a hash: @@ -112,9 +125,11 @@ Generate a hash: pnpm exec tsx scripts/hash-admin-password.ts "your-password" ``` -When both hash and plaintext are set, the hash is used. Plaintext remains a legacy local-dev fallback only. +Legacy scrypt hashes (`scrypt$N$r$p$saltBase64$hashBase64`) still verify for migration. Re-generate with the script above and update `.env` when convenient. Plaintext `SOURCEDRAFT_ADMIN_PASSWORD` remains a legacy local-dev fallback only. + +Login failures return a generic error and do not reveal whether the password or account state was wrong. -**Intended for local/private use.** Do not expose Studio publicly without HTTPS, stronger auth, and deployment hardening. +**Intended for local/private use.** Do not expose Studio publicly without TLS, a reverse proxy, stronger auth, and deployment hardening. This hardening is not a replacement for a full external security audit. Report security concerns privately; redact tokens in bug reports. See [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/packages/setup/src/envFile.test.ts b/packages/setup/src/envFile.test.ts index 7dd41a0..0d121c3 100644 --- a/packages/setup/src/envFile.test.ts +++ b/packages/setup/src/envFile.test.ts @@ -65,7 +65,9 @@ test("serializeEnvFile escapes unsafe values and rejects invalid keys", () => { ["TAB", "col1\tcol2"], ["EMPTY", ""], ["HASH", "value#comment"], + ["EQUALS", "key=value"], ["INJECTION", "safe\nGITHUB_TOKEN=hijacked"], + ["QUOTE_INJECT", 'abc"\nEVIL=true'], ]); const serialized = serializeEnvFile(map); @@ -84,9 +86,12 @@ test("serializeEnvFile escapes unsafe values and rejects invalid keys", () => { assert.equal(loaded.get("TAB"), "col1\tcol2"); assert.equal(loaded.get("EMPTY"), ""); assert.equal(loaded.get("HASH"), "value#comment"); + assert.equal(loaded.get("EQUALS"), "key=value"); assert.equal(loaded.get("INJECTION"), "safe\nGITHUB_TOKEN=hijacked"); + assert.equal(loaded.get("QUOTE_INJECT"), 'abc"\nEVIL=true'); assert.throws(() => serializeEnvFile(new Map([["bad-key", "value"]])), /Invalid env key/); + assert.throws(() => serializeEnvFile(new Map([["123", "value"]])), /Invalid env key/); }); test("serializeEnvFile round-trips through loadEnvMap", () => { diff --git a/packages/setup/src/envFile.ts b/packages/setup/src/envFile.ts index e1f0871..2173fe3 100644 --- a/packages/setup/src/envFile.ts +++ b/packages/setup/src/envFile.ts @@ -4,7 +4,7 @@ import { formatEnvValueForDisplay } from "./maskSecrets.js"; export type EnvMap = Map; -const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/u; +const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/iu; export function isValidEnvKey(key: string): boolean { return ENV_KEY_PATTERN.test(key); @@ -26,6 +26,7 @@ function needsQuoting(value: string): boolean { if ( value[index] === " " || value[index] === "#" || + value[index] === "=" || value[index] === '"' || value[index] === "\\" || value[index] === "\n" || diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c32945..e086fe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@tiptap/suggestion': specifier: ^3.26.0 version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + argon2: + specifier: ^0.43.1 + version: 0.43.1 busboy: specifier: ^1.6.0 version: 1.6.0 @@ -762,6 +765,10 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@playwright/test@1.60.0': resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} @@ -1186,6 +1193,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + argon2@0.43.1: + resolution: {integrity: sha512-TfOzvDWUaQPurCT1hOwIeFNkgrAJDpbBGBGWDgzDsm11nNhImc13WhdGdCU6K7brkp8VpeY07oGtSex0Wmhg8w==} + engines: {node: '>=16.17.0'} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -1725,6 +1736,14 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-addon-api@8.8.0: + resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} @@ -2427,6 +2446,8 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@phc/format@1.0.0': {} + '@playwright/test@1.60.0': dependencies: playwright: 1.60.0 @@ -2857,6 +2878,12 @@ snapshots: dependencies: color-convert: 2.0.1 + argon2@0.43.1: + dependencies: + '@phc/format': 1.0.0 + node-addon-api: 8.8.0 + node-gyp-build: 4.8.4 + balanced-match@4.0.4: {} baseline-browser-mapping@2.10.34: {} @@ -3376,6 +3403,10 @@ snapshots: negotiator@1.0.0: {} + node-addon-api@8.8.0: {} + + node-gyp-build@4.8.4: {} + node-releases@2.0.47: {} object-inspect@1.13.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db8228e..3070a69 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,5 @@ packages: - "packages/*" - "examples/*" allowBuilds: + argon2: true esbuild: true diff --git a/scripts/hash-admin-password.ts b/scripts/hash-admin-password.ts index f1a7ebb..5e4dafa 100644 --- a/scripts/hash-admin-password.ts +++ b/scripts/hash-admin-password.ts @@ -6,9 +6,9 @@ const password = process.argv[2]; if (!password || password.length === 0) { console.error("Usage: pnpm exec tsx scripts/hash-admin-password.ts "); console.error(""); - console.error("Output format: scrypt$N$r$p$saltBase64$hashBase64"); + console.error("Output format: $argon2id$v=19$m=19456,t=2,p=1$..."); console.error("Set SOURCEDRAFT_ADMIN_PASSWORD_HASH in .env with the generated value."); process.exit(1); } -console.log(hashAdminPassword(password)); +console.log(await hashAdminPassword(password));