A git-based, security-first CMS that installs directly into an existing Next.js app — no separate server, no hosted database. Content is plain MDX + YAML frontmatter, versioned in your own repo.
Status: v1 released (M7 of 7).
This is the monorepo: the published package, two runnable examples, and this repo's own dev tooling. If you're looking to use cimisy in your own app, start with the package README — it has the real quickstart, config reference, RBAC guide, GitHub App setup walkthrough, and migration notes. This file covers the repo itself.
Local adapter (no setup required):
pnpm install
pnpm --filter cimisy build
pnpm --filter next-local devThen open http://localhost:3000/admin for the editor, or http://localhost:3000/blog for the public site rendered via the Reader API.
GitHub adapter (requires registering your own GitHub App — see examples/next-github/README.md or the package README's setup guide):
pnpm install
pnpm --filter cimisy build
cp examples/next-github/.env.local.example examples/next-github/.env.local # fill in your App's credentials
pnpm --filter next-github devpackages/cimisy— the publishedcimisynpm package (its README is the canonical usage doc)examples/next-local— a minimal Next.js app demonstrating the local-adapter flowexamples/next-github— a Next.js app demonstrating the GitHub-adapter + auth + RBAC + draft/PR flow
Plain pnpm workspace, no Turborepo/Nx. From the repo root:
pnpm install
pnpm run lint # all packages + examples
pnpm run typecheck # all packages + examples
pnpm run test # vitest, packages/cimisy only (examples have no tests of their own)
pnpm run build # packages/cimisy only — see note belowbuild is deliberately scoped to packages/*, not the examples: examples/next-local uses the local storage adapter, which refuses to run once NODE_ENV=production (by design — it has no auth, direct disk writes). A next build of that example is expected to fail with LOCAL_ADAPTER_IN_PRODUCTION, which is the guard rail working correctly, not a broken build.
This repo uses Changesets. Contributors run pnpm changeset to describe a change; merging to main triggers .github/workflows/release.yml, which either opens a "Version Packages" PR (if unreleased changesets exist) or publishes packages/cimisy to npm with provenance once that PR is merged. Publishing prefers npm's OIDC trusted publishing (no long-lived NPM_TOKEN) — see the workflow file for the one-time setup this needs on npmjs.com, and its comments for the NPM_TOKEN-based fallback if trusted publishing isn't configured yet.
Only packages/cimisy is published — both example apps are private: true and excluded via .changeset/config.json's ignore list.
cimisy holds write credentials to your repository; see SECURITY.md for the vulnerability reporting process and THREAT_MODEL.md for the assets, trust boundaries, and specific attack scenarios this project is designed against, each mapped to the code and tests that mitigate it.
Built milestone-by-milestone, each independently demoed and verified (lint/typecheck/test/build plus live smoke testing) before moving on:
M1 — config engine + local adapter (dev-only)
- A typed
cimisy.config.tsconfig/schema engine (collection,singleton,fields) - A local filesystem storage adapter (dev-only — refuses to run under
NODE_ENV=production) - Optimistic-concurrency-safe reads/writes/deletes against MDX files
- Strict, allowlist-based slug/path validation (no path traversal by construction)
- Fail-closed YAML frontmatter parsing (any parse warning is treated as a hard error)
M2 — GitHub-backed storage + GitHub App auth
- A GitHub storage adapter using the Git Data API (blob/tree/commit/ref) for atomic multi-file commits, with the same per-file optimistic-concurrency semantics as the local adapter
- GitHub App authentication: App-level JWT + installation-token exchange delegated to
@octokit/auth-app(not hand-rolled), installation ID resolved and cached per repo - User sign-in via GitHub App user-OAuth (identity-only — no user access token is ever persisted; all repo reads/writes go through the App installation token instead)
- Signed, httpOnly, sameSite session cookies (
jose/HS256, alg pinned, tamper- and expiry-tested); CSRF-protected OAuth state via a double-submit cookie - The whole admin API requires a valid session when the GitHub source is configured
createBranch/openChangeRequest/mergeChangeRequest/getHistoryimplemented and tested
M3 — layered RBAC + branch/PR publish workflow
- Two-layer authorization: GitHub's own collaborator permission level (admin/maintain/write/triage/read) maps to a cimisy role, which then gates specific path-glob rules (
content/blog/**,**, etc.) — a working, secure-by-default role set ships out of the box, no team has to hand-roll one - A single centralized
requirePermissionchoke point enforced on every read/write/delete before the storage adapter is ever touched — deny-by-default, and covered by an explicit IDOR regression test (a forged client-suppliedrole/isAdminfield in a request body has zero effect; only the server-resolved session role matters) - Direct-publish roles commit straight to the default branch; everyone else drafts on a deterministic per-user/per-entry branch (
cimisy/<username>/<collection>/<slug>) with an auto-opened PR — repeated saves land on the same branch/PR instead of duplicating it - Merging/approving a draft is deliberately not reimplemented — that's GitHub's own PR review and branch protection; cimisy only opens the PR and surfaces its link in the admin UI
- Collaborator-permission lookups are cached briefly (60s) so a revoked collaborator loses access promptly rather than only after their session cookie eventually expires
M4 — block editor + safe MDX serialization
- A block registry (
paragraph,heading,code,image,callout) where each block declares how it round-trips to mdast/MDX — native markdown for paragraph/heading/code (inert, no JSX needed), real JSX elements for image/callout (the ones that map to actual React components on the consuming site) - Content is built as mdast AST nodes from zod-validated props — never string-concatenated into MDX source, which is what rules out attribute-breakout injection at the write path
- A strict AST allowlist validator gates the read path: rejects
import/export(mdxjsEsm),{expression}syntax (mdxFlowExpression/mdxTextExpression), any JSX tag not declared by a registered block, JSX spread attributes, and expression-valued JSX attributes — recursively, everywhere in the tree, not just at the top level - A permanent malicious-MDX fixture corpus (24 tests) covering import/export smuggling, expression injection (flow/text/attribute/spread), unknown-tag injection, and DoS shapes (deep nesting, wide flat documents) — every payload is asserted rejected, not just spot-checked
- Found and fixed a real DoS bug while building this: the validator's tree walk was unbounded recursion, so a deeply-nested hand-edited file crashed with an uncaught stack overflow instead of a clean rejection — now depth-limited with a proper
ValidationError - Found and fixed a related availability bug: a single hand-edited (malicious or just broken) file used to take the entire collection listing down with it; per-file parse errors are now isolated so one bad file shows as one broken row, not a dead admin panel
- A genuine multi-block editor UI (add/remove/reorder, type-appropriate controls per block) driven entirely by manifest data the server sends — no per-project UI code needed when a config registers different block types
- Verified live end-to-end: created a post exercising all 5 block types through the admin API, confirmed clean human-readable MDX output, confirmed round-trip fidelity, and confirmed a hand-edited malicious file placed directly on disk (never touching the UI) is rejected on read while leaving the rest of the collection listable
M5 — preview via Draft Mode + Reader API
- A public-facing Reader (
createReader,cimisy/next) — no auth, no RBAC (it's what renders a site's own pages for any visitor), draft-mode aware, and going through the exact sameparseEntry→assertSafeMdxTreevalidation path as the admin API, so a hand-edited malicious file is rejected here too, not just in the admin UI - A direct block-tree → React renderer (
renderBlocks,cimisy/render) instead of the MDX-text recompilation the plan sketched — since content is already a validated block tree by the time the Reader returns it, re-serializing back to MDX text just to recompile it via@mdx-js/mdx/next-mdx-remotewould be redundant and pull in a heavier dependency for no safety benefit; sensible unstyled defaults ship for all 5 built-in block kinds, fully overridable per block type - Real Next.js Draft Mode integration: a
cimisy_preview_refcookie (default branch, or a specific draft branch) rides alongside Next's own__prerender_bypasscookie, so the Reader can tell "preview world" from "published world" per request without a rebuild - An authenticated, RBAC-gated preview-enabling route with explicit open-redirect prevention (a
redirectTopointing anywhere off-site is neutralized, not passed through) - An optional
previewPathtemplate on collection config (e.g."/blog/:slug") surfaced through the manifest, so the admin UI shows a real "Preview" link with zero per-project UI code - Verified live end-to-end: created a post, confirmed it renders correctly via the Reader +
renderBlockson a real public route, then ran the full enable → banner-appears → disable → banner-gone preview cycle against a running dev server and confirmed both cookies are set/cleared correctly at each step
M6 — security hardening pass + audit trail + docs
- CSRF protection on every state-changing admin route (
src/next/csrf.ts):Originheader verified against the app's own origin, falling back toReferer, failing closed when neither is present — a second, independent layer on top ofsameSite: "lax"session cookies - A pluggable
RateLimiterinterface (src/security/rate-limit.ts) applied to admin writes (identity-keyed) and the OAuth callback (IP-keyed), returning a real429+Retry-After; ships an honest in-memory default that's explicitly documented as unsafe across multiple serverless instances rather than overpromising production-readiness - A full zod-boundary audit across every API route: every
request.json()now goes through a schema (writeEntryBodySchema,deleteEntryBodySchema) via a sharedparseJsonBodyhelper instead of an unchecked type cast - Found and fixed a real ordering bug during that audit: slugs were being authorized by RBAC before being validated as safe paths. Slug validation now happens the moment a slug is parsed (from the URL or the request body), before any permission check runs
- A consolidated path-traversal fuzz sweep (
path-traversal-fuzz.test.ts, ~40 payloads × 4 path-building functions, 169 cases) — this caught a real inconsistency indraftBranchName, which validated the attacker-influencedslugcomponent with a looser, mixed-case-permissive pattern than the lowercase-onlyassertSafeSlugused everywhere else a slug becomes a path; fixed to use the stricter validator - An activity-log panel in the admin UI surfacing real git history per entry (
GET .../history), backed by an adapter-optionalgetHistorycapability — reports{supported: false}cleanly on adapters (like local) that don't have one, rather than erroring SECURITY.md(private vulnerability reporting via GitHub's Security tab, scope, disclosure timeline) andTHREAT_MODEL.md(assets, trust boundaries, and 14 specific attack scenarios mapped to the exact file and test that mitigates each one)- CI: lint/typecheck/test/build on every push and PR, CodeQL (
javascript-typescript,security-extended, weekly + every PR), and Dependabot for both npm and GitHub Actions dependencies
M7 — public v1 release
- Changesets wired up (
@changesets/cli,.changeset/config.jsonscoped topackages/cimisyonly — both example apps areprivate: trueand ignored); a real release dry run performed locally:changeset versioncorrectly bumpedcimisyto1.0.0and generatedCHANGELOG.mdfrom the changeset, andnpm publish --dry-runagainst the built package confirmed a clean, correctly-scoped tarball (145+ files,dist/only, plusREADME.md/LICENSE) .github/workflows/release.yml: on push tomain, runs lint/typecheck/test, then either opens a "Version Packages" PR or publishes to npm with provenance viachangesets/action, preferring npm's OIDC trusted publishing over a long-livedNPM_TOKENpackages/cimisy/package.jsonfilled out with real publish metadata (repository,homepage,bugs,keywords,publishConfig: { access: "public", provenance: true })- A real
packages/cimisy/README.md— the actual npm package page: quickstart, GitHub App setup guide, full config reference (config/collection/singleton/fields/blocks), RBAC guide, Reader/render usage, Draft Mode, security summary, and migration notes — plus a bundledLICENSE - Verified end-to-end exactly like a real first-time install, not just unit tests: built the package,
npm pack'd the real tarball,npm install'd it into a from-scratch Next.js app outside this workspace, copy-pasted the README's own quickstart snippets verbatim (config, admin page, API route), booted the dev server, and created a post through the real admin API — confirmed a clean.mdxfile with correct frontmatter landed on disk, exactly as documented
Apache-2.0