@orion-studios/cms
v0.15.0
Published
Orion CMS v2 core engine: JSONB content model on Supabase primitives
Maintainers
Readme
@orion-studios/cms
Orion Studios' CMS engine: a single package that turns a custom Next.js site into a client-editable site. JSONB content on Supabase, a self-contained Studio admin, and a block system where one definition drives everything.
Frontend and Studio visual contract
The custom frontend owns the design. Orion CMS supplies typed content, permissions, versioning, and editing behavior. It does not substitute a generic CMS renderer for a project-owned page.
Studio always opens pages, collection entries, and site-wide globals through protected same-origin preview routes. Those routes must call the same project components, layouts, CSS, fonts, and responsive rules as the public routes. Studio adds inline editing to that rendered document and keeps links, forms, booking controls, and other effects inert. Inspector fields remain available when a value cannot be matched safely in the rendered document.
Every consuming project must provide the applicable /cms-preview/[id],
/cms-collection-preview/[id], and /cms-global-preview/[key] routes. An
integration is incomplete until its public and Studio output match at desktop
and mobile sizes. Do not change frontend markup or styling to accommodate the
CMS. Change the typed content model, preview adapter, or editing controls.
- No schema migrations for content-model changes. Pages and collection entry data use JSONB, so adding or editing block and entry fields is a code change.
defineBlock()is the single source of truth. A Zod schema + a React component per section type produces: TypeScript types, validation, defaults, the builder palette, the editing panel, inline click-to-edit, and the public-site renderer.- Three runtime modes, chosen by env:
supabase(production),memory(zero-setup dev/demo, in-process store),static(no CMS at all — renders fromsite-content.ts; how sites launch before a client buys the CMS).
Install
npm install @orion-studios/cmsRequires Next.js (App Router), React 19, Zod 3. Supabase only in supabase mode.
Entry points
| Import | Contents |
| --- | --- |
| @orion-studios/cms/blocks | Blocks, globals, typed collections, and connected application definitions and registries |
| @orion-studios/cms/server | createCmsRoutes, host application handlers, previews, content sync, and the permission matrix |
| @orion-studios/cms/studio | Studio, password recovery and account controls, users, globals, collections, connected applications, and createStudioApi |
| @orion-studios/cms/content | Anonymous published reads for pages, globals, collection lists, and collection details |
| @orion-studios/cms/forms | Form config types, processSubmission (validation, honeypot, rate limiting), shared SiteForm renderer |
| @orion-studios/cms/sql/bootstrap.sql | The complete idempotent schema (tables, RPCs, RLS, grants) |
| @orion-studios/cms/studio/styles.css | Studio styles (self-contained, ost- prefixed) |
Blog content helpers
@orion-studios/cms/blocks exports createBlogPostSchema,
defineBlogBlockExport, and createPostMarkdownExporter. These opt-in helpers
reuse typed collection storage and block validation, and require explicit
content or omission rules when exporting an article. They do not enable a blog
or authorize publication. See the blog content contract for
usage, limits, and the remaining editor and review work.
The 5-minute wiring (already done in the site template)
// src/app/api/cms/[...path]/route.ts — the entire CMS API
import { createCmsRoutes } from '@orion-studios/cms/server'
import { collections, connectedApplications, registry } from '@/cms/registry'
export const { GET, POST, PATCH, DELETE } = createCmsRoutes({
registry,
collections,
connectedApplications: {
registry: connectedApplications,
handlers: hostApplicationHandlers,
},
syncToken: process.env.CMS_SYNC_TOKEN,
cronToken: process.env.CRON_SECRET,
previewSecret: process.env.CMS_PREVIEW_SECRET,
previewKeyId: process.env.CMS_PREVIEW_KEY_ID,
analyticsSecret: process.env.CMS_ANALYTICS_HASH_SECRET,
autoReplyHashSecret: process.env.CMS_AUTO_REPLY_HASH_SECRET,
projectRef: process.env.CMS_EXPECTED_SUPABASE_PROJECT_REF,
})
// src/app/studio/page.tsx — the admin
import { Studio } from '@orion-studios/cms/studio'
import '@orion-studios/cms/studio/styles.css'
export default function Page() {
return <Studio
registry={registry}
globals={globals}
collections={collections}
connectedApplications={connectedApplications}
recoveryRedirectUrl="https://client.example/studio"
siteName="Client Co"
logoUrl="/logo.png"
/>
}Don't start here for a new site — copy templates/site from the
orion-cms-packages repo instead; it has all of this wired plus the scripts
(bootstrap, create-admin, sync). See NEW-SITE-PLAYBOOK.md at the repo
root for the full procedure.
Roles
Four tiers, enforced in the API (src/server/permissions.ts), UI adapts:
content— edit text/images on existing sections (no add/remove/reorder)editor: full content control including structure, new pages, exact-version publish, submissions, detailed analytics, and replacement of existing mediaadmin— everything + user management (Users panel in the Studio)developer— full access; the tier Orion Studios keeps for itself
Rank rules: you can only assign roles at or below your own, never change your own role, never delete yourself.
Environment
| Variable | Where | Purpose |
| --- | --- | --- |
| NEXT_PUBLIC_SUPABASE_URL | local + Vercel | project URL (also selects supabase mode) |
| NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | local + Vercel | public reads (RLS: published content only) |
| SUPABASE_SECRET_KEY | local + Vercel | server-only API access; the legacy service-role alias remains supported |
| CMS_EXPECTED_SUPABASE_PROJECT_REF | local + Vercel | binds the project URL, server credential, previews, and operator database target |
| NEXT_PUBLIC_CMS_RECOVERY_URL | local + Vercel | absolute Studio password recovery return URL, also allowlisted in Supabase Auth |
| CMS_DATABASE_URL | local operator only | direct or port 5432 session connection for bootstrap and migrations |
| CMS_EXPECTED_DATABASE_ROLE | local operator only | dedicated CMS migration role with no app schema access |
| CMS_EXPECTED_DATABASE_NAME | local operator only | exact database name required by the target guard |
| CMS_DATABASE_CONFIRMATION | local operator only | exact redacted target-receipt hash required before database writes |
| CMS_PREVIEW_SECRET | local + Vercel | signs short-lived, revocable preview grants |
| CMS_PREVIEW_KEY_ID | local + Vercel | non-secret identifier bound into preview grants |
| CMS_PREVIEW_PREVIOUS_SECRET, CMS_PREVIEW_PREVIOUS_KEY_ID, CMS_PREVIEW_PREVIOUS_VALID_UNTIL | local + Vercel | optional bounded preview-key rotation overlap |
| CMS_ANALYTICS_HASH_SECRET | local + Vercel | hashes analytics session and visitor identifiers |
| CMS_AUTO_REPLY_HASH_SECRET | local + Vercel | hashes form auto-reply rate-limit keys |
| CMS_SYNC_TOKEN | local + Vercel | authorizes POST /api/cms/sync |
| CRON_SECRET | local + Vercel | authorizes scheduled publication only |
| CMS_GOOGLE_CLIENT_ID, CMS_GOOGLE_CLIENT_SECRET | local + Vercel | site-held Google OAuth client credentials |
| CMS_GOOGLE_REDIRECT_URI | local + Vercel | this site's fixed /api/cms/integrations/google/callback URL |
| CMS_GOOGLE_RELAY_URL | Vercel | optional owner-enabled Orion relay URL used as Google's registered redirect URI |
| CMS_GOOGLE_OAUTH_STATE_SECRET | local + Vercel | signs the site origin, state id, and capability returned through OAuth |
| CMS_GOOGLE_TOKEN_KEY_ID, CMS_GOOGLE_TOKEN_KEYS | local + Vercel | active key id and JSON keyring for encrypted refresh tokens and PKCE verifiers |
| CMS_STATIC=true | optional | static mode (no CMS) |
| CMS_MEMORY=true | optional | force memory mode |
The memory backend is privileged only outside production. In production it
can serve public ephemeral forms and events, but it rejects Studio and guarded
routes, the fixed development token, preview fallback, sync, publish cron,
notification email, and user management. NEXT_PUBLIC_CMS_MEMORY and other
public flags cannot enable the development credential.
Studio holds one Google account connection per site. Search Console, Business Profile, and Calendar are enabled separately, and each enable request asks only for that capability's missing scopes. The site stores and exchanges every token. Relay adoption, Google Cloud redirect registration, and app verification remain owner actions. Calendar availability is provided here for later booking work; booking behavior is not part of this package phase.
Publication contract
Draft title, SEO, and layout are saved together as one immutable page version.
Manual publish requires that version's current draft_version_id. Scheduling
stores the selected version ID and later draft edits do not change what will go
live. A missing, stale, foreign, or already-published version fails with 409.
Content roles may create media and edit its metadata. Only editor, admin, and
developer roles may replace an existing file or read submissions and detailed
analytics.
Globals use the same draft, exact publish, schedule, and restore rules. The
legacy cms_globals.data column remains the published value used by public
clients. Studio edits write draft_data only until an editor publishes the
exact draft version.
Collections provide versioned entries for articles, portfolio work, and other
structured lists. defineCollection() supplies the data schema, path prefix,
editor fields, and renderer. Anonymous list and detail reads receive published
projections only, with a maximum page size of 100. Draft preview tokens are
bound to one entry, collection, version, project, and ten-minute window. Slug,
path, display date, and sort order are versioned with the entry. Draft changes
remain private, and a redirect is created only when a changed published path
goes live. The content sync manifest accepts typed collections entries and
seeds them as private drafts.
Set the top-level sync input to publication: 'draft' for a staged cutover.
Page and global manifests then update only their draft snapshots. Their public
projections remain unchanged until an editor publishes an exact version. The
default remains published for existing sites.
Connected applications keep operational records in the host system. Use
defineConnectedApplication() for presentation, safe preview, ownership, and
protected settings schemas. Mount host handlers through createCmsRoutes.
The package validates bearer identity, trusted CMS role, schema, and exact
settings revision before calling a handler. Studio preview disables all
submission and provider actions.
Password recovery
Studio users can request recovery from the sign-in screen and change their
password from Account. Administrators can reset another lower-ranked user from
Users without a browser prompt. Add each production, preview, and local Studio
URL to Supabase Auth redirect URLs, then pass the matching absolute URL through
recoveryRedirectUrl.
Successful self-service and recovery password updates record
account.password.change with the authenticated user ID. The activity record
does not contain an email, password, recovery token, or request body.
Configure custom SMTP for production password recovery. Supabase's default mail service is suitable only for initial testing and may restrict recipients, templates, and delivery volume. Never place reset tokens, passwords, or server keys in logs, activity rows, query parameters, or client-visible errors.
Run the additive SQL migrations in filename order before deploying code that
calls the exact-version RPCs. bootstrap.sql has the same final schema,
functions, grants, and published-metadata backfill for new projects. If a
release fails after the migration, keep the fail-closed RPCs and roll forward.
Do not restore the old mutable publish signature.
Development
npm run typecheck && npm test # 24 node:test suites
npm run build # tsup → dist/
npm pack # tarball for vendoring / inspectionThe SQL bootstrap is idempotent and runs under restricted Postgres roles: it
avoids auth-schema privileges and explicitly grants the PostgREST roles
(service_role, anon, authenticated) table access — RLS policies filter
rows but do not grant access, and Supabase's default privileges only cover
postgres-created tables.
