Run it
Overview
Generated from README.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.
A TypeScript-native, self-hostable backend — auth, data, REST API, and storage — with a content admin built in. Secure by default.
Extensible by developers at build time, not by operators at runtime. Plugins and themes are typed TypeScript modules you import and deploy — not uploads you install from a dashboard. That is a deliberate trade: you get type safety, a strict CSP, and no arbitrary-code-upload surface, and you give up click-to-install. See Extensibility model.
Use it two ways with the same install:
- As a classic CMS. One process, one
db.json, boots in under a second. The public site is Astro-fast (server-rendered HTML, almost no client JS); the admin panel is a normal HTML form at/admin. No PHP, no plugin marketplace. - As a headless BaaS. Point any frontend (Astro/React/Vue/an AI-vibe-coded
app) at the REST API with a bearer API key and configurable CORS. There’s
a typed SDK (
astrobaas/client), a CLI (astrobaas init), an MCP server so AI agents can drive it, signed webhooks, and an agent-readable contract at/llms.txt+/openapi.json. Persist to a JSON file for local dev or to SQLite/libSQL (Turso) for durable, multi-host deploys.
Status: pre-alpha — see CHANGELOG.md. Auth + API-key/bearer auth, post/category/user/media CRUD, custom content types, a theme system, a plugin system, pluggable storage (lowdb + libSQL), the client SDK / CLI / MCP server, outbound webhooks, sitemap, RSS, search, image optimization, and backup/restore all work. See “What’s not done yet” below for the honest gaps.
Not on npm yet.
astrobaasis not published to the npm registry, sonpm install astrobaasand a barenpx astrobaasoutside this repository will 404. What that does and does not affect:
- Running AstroBaaS: unaffected. Clone the repo — that is the documented install below, and it is the only one this project supports today.
npx astrobaas …inside the clone: works. npx resolves thebinentries from this package’s ownpackage.json. Every CLI example here assumes you are in the project directory.- Importing
astrobaas/clientfrom a SEPARATE frontend project: does not work yet. Until the package is published, call the REST API directly — it is plain HTTP and JSON, documented in INTEGRATION.md and at/openapi.json— ornpm packa tarball from a local build.npx astrobaas-mcpin an MCP client config: use an absolute path tobin/astrobaas-mcp.mjsin your clone. See the example below.Publishing is tracked as part of the pre-1.0 checklist in TRUTH_PLAN.md.

Why AstroBaaS
| You want… | AstroBaaS gives you |
|---|---|
| A blog you can self-host on a $5/mo VPS | One Node process. db.json + public/uploads/ are the entire data layer. |
| Static-site speed without the rebuild-on-publish workflow | Astro SSR — no build step to ship a post; the database is read from an in-process cache. |
| Markdown-friendly content you can grep | Importers for WordPress WXR and raw markdown. Back up your whole site as one JSON file. |
| Pre-alpha software that doesn’t lie about being beta | Roadmap and changelog say exactly what works and what doesn’t. |
It is not a replacement for WordPress at scale, a headless CMS for an e-commerce front-end, or a multi-tenant platform. Those are bigger problems that need bigger tools.
Try it in 30 seconds
Requires Node 22.12+ (see .nvmrc).
git clone <repository-url>
cd AstroCMS_v1
cp .env.example .env # optional for dev; AUTH_SECRET is required in production
npm install
npm run dev # http://localhost:4321
Sign in at http://localhost:4321/login with admin@local / admin.
Change the password immediately — the dashboard banner reminds you.
One-click deploy
- Coolify: Point it at the repo and use the bundled
Dockerfile.
Set AUTH_SECRET (generate with openssl rand -hex 32) and mount a volume at
/app/data so db.json + uploads persist across redeploys.
Screenshots
| Posts list | Editor |
|---|---|
![]() | ![]() |
Scripts
| Command | What it does |
|---|---|
npm run dev | Astro dev server on :4321 |
npm run build | Type-check + production build |
npm run start | Run the production build with NODE_ENV=production (after npm run build; requires AUTH_SECRET) |
npm run preview | Preview the built site locally |
npm run typecheck | Run astro check only |
npm run setup | Interactive: create or replace the admin user |
npm run reset-password | CLI to reset any user’s password by email |
npm run import:md <dir> | Import a directory of markdown posts |
npm run import:wp <wxr> | Import a WordPress export (WXR XML) |
npm run smoke | Spawn dev, hit core routes, exit non-zero on fail |
npm run smoke:libsql | Same smoke suite against the libSQL driver |
npm run test:unit | Unit tests (validate, auth, sanitizer, plugins, SDK, CLI, MCP) |
npm test | Unit tests + smoke test |
npm run e2e | Build + Playwright browser tests (needs npx playwright install chromium) |
The package ships two CLI bins, astrobaas and astrobaas-mcp (the MCP
server). Run these from the project directory — npx resolves them from this
package’s own bin entries, and the package is not on npm yet. Beyond init /
secret / setup, the CLI scaffolds extensions:
npx astrobaas plugin new my-plugin # code plugin -> src/plugins/my-plugin/
npx astrobaas theme new my-theme # theme -> src/themes/my-theme/
npx astrobaas plugin manifest my-thing # declarative manifest (runtime-installable)
npx astrobaas plugin validate my-thing.manifest.json
validate runs the same validator as the install endpoint, so “valid here”
means “installable there”.
What works today
-
Auth. Signed-cookie sessions, PBKDF2 password hashing, double-submit CSRF, per-IP rate limit (in-process or shared/libSQL for multi-node), security headers, and a hash-based CSP (
script-srcis'self'+ per-build hashes, no'unsafe-inline'), X-Frame-Options, Referrer-Policy, Permissions-Policy. -
Two-factor (TOTP). Optional per-account 2FA with any authenticator app (RFC 6238, dependency-free), one-time backup codes, and a two-step login that never re-asks for the password. Enable it in your profile.
-
Content. Posts, standalone pages, categories, users, media; status workflow (draft / review / scheduled / published / trashed); slug auto-generation with uniqueness; server-side HTML allow-list sanitization. A page is a post with
kind: "page"— it lands at/{slug}instead of/blog/{slug}and is excluded from the archive, the feed and the defaultGET /api/postsresponse. Posts can be pinned or given a manual position; unset sorts by date, so an existing site’s order does not change until you pin something. Headings with text get stable slug anchors (#how-it-works), body images get the file’s ownwidth/height, and an image with no description gets one from the media library — an explicitly emptyalt=""is never overwritten, because that is how correct markup marks a decorative image. An optional table of contents renders above long articles (Reading → Table of contents; 0 turns it off). The body a post API route returns incontent_renderedis the body this CMS renders itself, byte for byte — same filters, same sanitizer, same anchors, same dimensions, same lazy-loading hints — because all four renderers call one function. The rendered contents list is a theme slot rather than an API field, buttoc_min_headingsis a public setting and the anchors are in the body, so a headless storefront can build the same list from the same ids. -
Content analysis in the editor. A green dot with the checks behind it, recomputed as you type — no endpoint, no round trip, and it works on a draft that has never been saved. It deliberately shows no reading-ease score outside English: the formula counts English syllables, and running it over Greek would print a number nobody can trace to a fact.
-
Search you can teach. Ranked, accent- and capital-folding, weighted by field, with a synonym table you maintain —
σκελετός, μοντούρα, framemakes all three find each other. Typo tolerance and Greeklish need an index of your own catalogue and ship in the paid module. -
Sections and patterns. A palette of pre-designed blocks (hero, columns, card, CTA, note, media, gallery, spacer) plus whole-page patterns, inserted into the editor as allow-listed CSS classes rather than a parallel block tree — so
contentstays the HTML string every downstream consumer already reads, and themes restyle sections through design tokens. A hover toolbar moves, duplicates and deletes a section. Any page can be set as the site home page from Settings. -
Media that a decoupled storefront can actually use. Every upload records its width and height and generates a fixed set of WebP derivatives (400, 800, 1600 — never upscaled, largest capped so a 4000px camera original is not reproduced at 4000px), so a storefront builds a
srcsetinstead of running its own image optimizer. The untouched original is kept and downloadable. The API publishesurl_absoluteon each record andmedia_baseon the response envelope, so a storefront on a different host resolves images against the CMS rather than against itself — whileurl,thumb_urlandimages[].srcstay exactly as they were for existing consumers.npm run media:backfillgives the same treatment to a library uploaded before this existed. -
A deep health check.
GET /api/health/deep(admin session orHEALTH_TOKEN) exercises rather than introspects: it encodes a test image with sharp, writes and removes a probe file in the uploads directory, counts on the real rate-limit store, and writes to the database. It also reports whether the CMS knows its own public address and which plugins were requested versus actually loaded. 503 when anything essential is broken, so a deploy script can assert it withcurl -fsS. -
A reference deployment.
deploy/ships an nginx vhost and a systemd unit with limits sized against measured traffic, plus a post-deploy checklist of the things that fail silently. -
Roles. admin, editor, author, manager (shop staff: full catalogue, read-only orders and customers, own posts) and viewer. The admin navigation filters itself by role, so nobody sees a menu item that bounces them back.
-
Admin. Dashboard with live counts and recent-activity feed, posts list with pagination + search + filters, post editor (rich text + media picker), categories CRUD, users CRUD, media library with disk-backed uploads, profile + password change, backup/restore tools page.
-
Public site. Home (stock welcome page, or any CMS page you designate), blog index with category filter + pagination,
/blog/[slug], CMS pages at/[slug], about, contact (working contact form + newsletter signup), 404, 500,/sitemap.xml,/rss.xml,/robots.txt, OG image generator at/og/[slug].png. Site title/tagline/social come from Settings. -
Commerce — part of the core, not an add-on. Products, brands, product categories, orders, customers — with server-side pricing (integer cents, never floats) and atomic stock reservation, so concurrent checkouts cannot oversell the last unit. Public, anonymous checkout is capped by operator-adjustable order limits.
-
Payments. Stripe, PayPal, and Klarna, plus bank transfer and cash on delivery. Hosted checkout only — card data never touches your server, so a self-hosted install stays outside PCI scope. Credentials come from the environment, webhooks are signature- or fetch-back-verified with no bypass, and a verified event still has to match the order’s amount before it can mark anything paid. See PAYMENTS.md.
-
Email. Console and webhook transports out of the box, plus a bundled SMTP2GO connector — activate it and set two environment variables.
-
Agent-ready.
/llms.txt, a drift-tested/openapi.json, a typed client, and a 33-tool MCP server (npx astrobaas-mcp) covering content, commerce, media, and settings. Repo conventions for AI contributors live in AGENTS.md. -
i18n, all the way to the visible page. Set
SITE_LOCALES=en,de,frand posts carry alocale, the admin gains a language picker + filter,/de/blogserves German content from the same templates, and the API takes?locale=. Unset = single-language, with identical URLs and behaviour to before. Translations are grouped withtranslation_of.Every link in the nav, footer, post cards and breadcrumbs carries the locale, and a crawlable language switcher appears in the header — pointing at the real translation where one exists, and at that language’s home page where it does not, marked so the reader can tell which. The stock marketing home page’s own buttons are not converted yet.
For content records — posts and Pages — the canonical URL follows the record’s language rather than the URL the reader used, so canonical, hreflang, sitemap and RSS name the same address by construction. Static routes such as
/aboutcanonicalise to the URL they were served at. -
Themes with template overrides. Switch the active theme and customize colors, typography, and arbitrary CSS in the admin (served from
/theme.css, so it applies on first paint under the strict CSP). A theme can also replace templates —Header,Footer,PostCard,PostArticle,Sidebar— and inherits the built-in default for every slot it doesn’t override, so themes stay forward-compatible. Bundlededitorialtheme demonstrates it; see THEME_DEVELOPMENT.md. -
Plugins. Filter/action hook system (8 hooks) with persisted activation, error isolation, and a typed API from
astrobaas/core; bundled examples insrc/plugins/. Plugins are imported at build time — see below andPLUGIN_DEVELOPMENT.md. -
Animated/3D frontends. Effects (parallax, scroll-float, WebGL) live in the Astro frontend, fed by CMS content —
@astrojs/reactis wired for React Three Fiber islands and the CSP is env-configurable for CDN/asset loading. Working reference at/showcase; seeFRONTEND_EFFECTS.md. -
Headless / BaaS. Bearer API-key auth (CSRF-exempt) + configurable CORS for cross-origin frontends; a typed client SDK (
astrobaas/client); theastrobaasCLI (init/secret/setup); an MCP server (astrobaas-mcp) exposing content tools to AI agents; signed outbound webhooks; and an agent-readable contract at/llms.txt+/openapi.json. -
Pluggable storage. lowdb JSON for zero-config local dev; SQLite/libSQL (Turso) for durable multi-host deploys — selected by
DATABASE_URL. See STORAGE.md. -
Ops.
/healthzfor orchestrators; structured(-ish) logging; one-shot Docker container; CI runs typecheck + build + unit + smoke on every PR.
Extensibility model
Two tiers. Data installs at runtime; code is compiled in.
The dividing line is not plugins-versus-themes — both come in both tiers. It is whether an extension needs to run or merely describe.
| Declarative (manifest) | Compiled-in (code) | |
|---|---|---|
| Install | Paste JSON in the admin, or POST /api/plugins/install / /api/themes/install. No rebuild. | Add a folder + one import, redeploy. |
| A plugin can | Register content types, contribute <meta>/<link> tags, subscribe webhooks, add editor sections with scoped CSS. | Anything: hooks, filters, server logic, its own API routes. |
| A theme can | Ship design tokens, a stylesheet, and section patterns. | All of that, plus replace the Header/Footer/Sidebar/PostCard/PostArticle templates. |
| Runs code? | Never. | Yes — it is your code. |
Astro compiles the server at build time, so anything that must execute on the server, or render markup as a component, has to be present at build time. That is a property of the runtime, not a policy choice. What is a choice is that everything expressible as data was made installable without a rebuild.
Sections are why the declarative theme tier exists at all: an editor block is an allow-listed CSS class rather than a component, so a theme’s contribution — tokens, a stylesheet, ready-made layouts — is data all the way down.
What you get for the split:
- No arbitrary-code-upload surface. There is no “upload a zip and run it as root” path, the single most-exploited vector in classic CMS ecosystems. A manifest cannot execute anything; compiled-in code is code you reviewed.
- Type safety end to end for the compiled tier: a bad hook signature fails the build, not production.
- A strict CSP stays possible. Scripts are hashed at build time with no
'unsafe-inline'— achievable because executable extensions are known at build time. Plugin and theme CSS are served from/plugins.cssand/theme.cssfor the same reason. - Manifests are validated, not trusted. A plugin’s CSS must be scoped to its
own
ab-x-<pluginId>-namespace; a theme’s tokens must be values the vocabulary defines, so a stored value can select CSS but never be CSS; and any section or pattern whose markup the sanitizer would rewrite is refused at install, with the sanitized output shown beside what was submitted.
What you still give up: a plugin that needs server logic, or a theme that needs to change page structure, is a code extension and needs a deploy.
Where the paid boundary sits
Commerce is core and stays core. It is not a plugin and is not sold
separately — it is woven through the storage contract every driver implements
(25 references in the Storage interface alone), and splitting it would buy an
abstraction nobody needs.
The paid boundary is one level up, at the vertical: the optometry / eyewear module — prescription capture and validation, dioptre and PD handling, lens configuration. That is genuinely separable, worth more to fewer people, and is the shape of something worth licensing. It is decided but not built; see OPTICAL_MODULE.md, which also records the constraints any licensing design must not violate.
Plugin dependencies already exist to support this
(dependencies: { "commerce": "^2.0.0" } in a manifest, with activation refused
until every dependency is installed, active and in range — and a dependency that
cannot be deactivated, uninstalled or upgraded out of range while a dependent is
running). See PLUGIN_DEVELOPMENT.md.
One thing to be clear about. A declarative theme cannot run code, read your data, or make network requests — but it does decide how every page looks. Install themes from sources you would accept a stylesheet from.
Use it as a backend (headless)
Full walkthrough: INTEGRATION.md (keys, CORS, SDK, webhooks, MCP, CLI).
Mint an API key in the admin (or POST /api/keys as admin), then from any origin:
import { createClient } from 'astrobaas/client';
const baas = createClient('https://cms.example.com', { apiKey: process.env.ASTROBAAS_KEY });
const posts = await baas.posts.list({ status: 'published', limit: 10 });
const post = await baas.posts.get('hello-world');
await baas.posts.create({ title: 'From my frontend', status: 'draft' }); // needs author+ key
await baas.content('product').create({ name: 'Widget', price: 9 }); // custom types
- Bearer auth + CORS. Send
Authorization: Bearer <key>; bearer requests are CSRF-exempt. Start the server withCORS_ORIGINSlisting your frontend’s origin. - Scaffold securely. From inside the clone,
npx astrobaas initwrites a.envwith a CSPRNGAUTH_SECRET(the #1 production foot-gun, removed).npx astrobaas secretprints one for piping. - AI agents. Run the MCP server so an agent operates the backend as tools:
Agents can also just read// Claude Desktop config. An absolute path, not `npx astrobaas-mcp`: this // config is read from your home directory, not from the clone, and the // package is not on npm yet — so npx would 404 there. { "mcpServers": { "astrobaas": { "command": "node", "args": ["/absolute/path/to/AstroCMS_v1/bin/astrobaas-mcp.mjs"], "env": { "ASTROBAAS_URL": "https://cms.example.com", "ASTROBAAS_KEY": "abk_..." } } } }/llms.txt(plain-text brief) or/openapi.json. - Webhooks.
POST /api/webhooks { url, events }to get notified onpost.*/content.*events. Each delivery is signed:X-AstroBaaS-Signature: sha256=HMAC-SHA256(secret, rawBody).
What’s not done yet
- Runtime installation is data-only. Plugins and themes both install from a JSON manifest at runtime — no rebuild — but a manifest carries data, not code: settings, content types, webhooks, design tokens, stylesheets, editor sections and patterns. Anything that needs to run JavaScript on the server, or replace a page template, is still a compiled-in extension and needs a rebuild. See Extensibility model.
- No comments or custom taxonomies. Post revisions and autosave landed, but editorial depth is still thinner than WordPress.
- No embeds.
iframeis not on the sanitizer’s allow-list, so a pasted YouTube or Maps embed is removed on save. That is what keeps the strict CSP intact; supporting embeds means shipping a consent-gated facade, which is not built yet. - No PDF export, no ESP sync, no custom roles. See docs/WP-PARITY-ROADMAP.md for what is measured and what is left.
- Payment providers are not sandbox-verified. The protocol logic and every security control are implemented and adversarially tested offline, and the full webhook → capture chain runs live in the smoke suite; what has not been done is a round trip against Stripe/PayPal/Klarna’s live sandboxes, which needs real credentials. Run yours there before taking real money.
- No independent security audit. The code has been through an adversarial self-review with regression tests (see SECURITY.md), but no external pentest, fuzzing, or bug bounty. Treat it as alpha.
- No fuzz/load tests, no a11y CI. Coverage is unit + an HTTP smoke suite over
every screen/API on all three storage drivers + a Playwright browser suite
(
npm test,npm run e2e). - Multi-host needs libSQL. With a
libsql://(Turso)DATABASE_URLthe data survives replicas; the lowdb default does not (ephemeral FS / write races). For real multi-writer traffic use the relational driver (DATABASE_DRIVER=relational); the default doc-blob mode is last-write-wins. Rate limiting shares counters across replicas only when you setRATE_LIMIT_STORE=libsql. See STORAGE.md.
Configuration
Required environment variables
| Variable | Purpose | Default |
|---|---|---|
AUTH_SECRET | HMAC key for session cookies. ≥ 16 chars. | A dev-only insecure value (warns) |
NODE_ENV | When production, missing AUTH_SECRET is fatal. | unset |
Optional
| Variable | Purpose |
|---|---|
DATABASE_URL | Selects the storage engine: unset → lowdb JSON; file:… → local SQLite; libsql://… → Turso/remote. |
DATABASE_AUTH_TOKEN | Auth token for a remote libsql:// database. |
CORS_ORIGINS | Space/comma-separated origins (or *) allowed to call /api/* cross-origin. Credentials are never allowed (token auth, CSRF-safe). |
RATE_LIMIT_PER_MIN | Default 60. Increase if you’re behind a CDN. |
RATE_LIMIT_STORE | libsql (with a libSQL DATABASE_URL) shares rate-limit counters across replicas; default is in-process. |
TRUST_PROXY | 1 to honour X-Forwarded-For (only behind a proxy you control). |
COOKIE_SECURE | Force the cookie Secure flag on/off (1/0); defaults to on in prod. |
CSP_* | Per-directive CSP allowlists for CDN/3D assets (see .env.example). |
METRICS_ENABLED | 1 exposes Prometheus counters at /metrics (off by default). |
LOG_REQUESTS | 1 emits one structured JSON log line per request. |
HEALTH_TOKEN | 32+ random characters. Lets a deploy script read GET /api/health/deep without a session (Authorization: Bearer …). Unset means admin session only, and everyone else gets a 404. |
MEDIA_ORIGINAL_EXIF | keep retains the uploaded file byte for byte. By default the EXIF block is stripped losslessly (pixels untouched) — it carries GPS and a device serial, and the original is fetchable at a guessable URL. |
MEDIA_KEEP_ORIGINALS | 0 discards the uploaded file once its derivatives exist. On by default: the original is the shop’s master copy, and it is also the larger line item on disk (≈3 MB for a phone photo, against 80–480 KB for all of its derivatives together). |
SANITIZE_STRICT_CLASSES | 1 drops the ab-x-* plugin section namespace from the content allow-list, making stored classes a closed set. Off by default: turning it on strips plugin-contributed sections out of content on save, which is data loss for anyone using one. |
Where data lives
- Database: by default
db.json(gitignored, seeded with built-in starter content on first boot; delete to reset; override withDB_PATH). SetDATABASE_URLto use SQLite/libSQL instead — see STORAGE.md. - Uploads:
public/uploads/<yyyy>/<mm>/<sha1-prefix>.<ext>. Gitignored. Override the directory withUPLOADS_DIR.
Schema migrations & upgrades
The database records a schema version. On startup AstroBaaS runs any pending
migrations automatically, in order, across whichever storage driver is active —
so upgrading in place is just deploying the new build and restarting. A fresh
install is stamped at the current version (nothing to migrate). GET /readyz
reports schema_version / expected_schema_version, and returns 503 if the
database is stuck below what the build expects (e.g. a failed migration), so an
orchestrator holds traffic until the upgrade completes.
Migrations live in src/lib/migrations.ts. Because
every driver stores entities as JSON documents, one migration (written against
the storage-agnostic Storage interface) upgrades all three. To add one, append
an entry with the next integer version and an idempotent up(); never edit
or renumber a shipped migration. See CONTRIBUTING for the checklist.
Architecture
Browser / Frontend / AI agent
│ cookie+CSRF (admin UI) · Bearer API key (headless) · MCP (agent)
▼
Astro SSR (Node adapter)
├── middleware.ts (cookie+bearer auth, CSRF, CORS, rate limit, security headers)
├── src/pages/api/* (REST endpoints, return {success, data|error})
├── /llms.txt /openapi.json (agent-readable contract)
└── src/pages/*.astro (admin + public UI)
│ └─► webhooks.ts ──► signed POST to subscribers
▼
LocalDB ──► Storage adapter: lowdb (db.json) | libSQL/SQLite (DATABASE_URL)
Theme/plugin/SDK authors import the stable surface from astrobaas/core and
astrobaas/client (never src/lib/*); see STABILITY.md.
See PLAN.md for the forward plan (i18n, theming, editorial depth, runtime plugins, ecosystem), ROADMAP.md for the phased log and CHANGELOG.md for what’s new.
Security
This is pre-alpha software. Read SECURITY.md before exposing it to the internet. The TL;DR:
- Change the seed admin password (the dashboard banner reminds you).
- Generate a strong
AUTH_SECRET(openssl rand -hex 32). - Put it behind HTTPS via a reverse proxy. In production
(
NODE_ENV=production) session/CSRF cookies areSecure, so HTTPS is required. - Persist data across redeploys —
docker compose up --buildmounts a volume at/app/datafordb.json+ uploads (or mount your own and back it up off-host).
License
GPL-3.0-or-later — Copyright (C) 2025 Theodor Dimitriou. See LICENSE.
The open-core question — a free community edition alongside paid modules, the way Magento and Adobe Commerce are split — is analysed in LICENSING.md. It is available because the copyright is held by one person, and it is preserved by the contributor agreement below.
Contributing
See CONTRIBUTING.md. Bug reports use the template at
.github/ISSUE_TEMPLATE/bug_report.md. Security issues: please follow
SECURITY.md, not the issue tracker.
Pull requests are gated on a Contributor License Agreement — a bot asks once, you reply with one sentence, and later PRs are not gated again. You keep your copyright; the grant lets the maintainer license your work commercially as well as under the GPL, which is what funds the project, and §4.1 commits to keeping every merged contribution in the open-source edition. The reasoning, and the honest case for declining to sign, are in CLA.md § Why this exists. Declining is fine: open an issue instead and the change will be implemented independently, with credit.

