How the Directus service is built and configured, how its schema is kept as code, and the guards around changing schema, flows and permissions.
| Reference | Value |
|---|---|
| Subject | Directus runtime, schema as code, roles and policies, flows, guarded scripts and workflow lanes; for anyone changing the CMS |
| Verified source | apps/cms and cms.yml on main at f40e8704; checked 2026-11-07. Dockerfile, extensions, Build Bot file grant, workflow lanes and scripts re-verified on main at 0461e045 (#646, #677, #686, #690, #693), checked 2026-11-10 |
| Support state | CMS declarations and publication contracts checked against main f40e8704. Optional source/date fields, validation and narrow Human Editor grants are declared in the snapshot; live grants/schema require their own audit |
| Part | Contract |
|---|---|
| Image | Dockerfile: directus/directus:12.5.1 pinned by digest. A separate Node 22.23.4 stage packs [email protected], verifies its SHA-512, widens its host range to Directus 12 and fails if the manifest changes. A second Node 22.23.4 stage runs npm ci --ignore-scripts on extensions/article-editor and builds it with the pinned @directus/extensions-sdk 18.1.1 |
| Extensions | directus-extension-sync (id-map endpoint used by directus-sync), the local noindex middleware and the local directus-extension-article-editor interface. Article Block Editor (article-block-editor, host 12.5.1) wraps Directus's built-in block editor, keeps image extras by block ID and adds per-image Layout (Normal, Row, Beside next block), Light version and Show both themes controls. The snapshot sets it on projects_sections_translations.content, projects_translations.description, blog_posts_translations.body and legal_pages_translations.body; production takes that only through the confirmed owner packet |
| Service | One Railway service, https://cms.yesid.dev, built from apps/cms only; health checks use /server/ping |
| Data | Neon Postgres through DB_CONNECTION_STRING; Cloudflare R2 through the built-in s3 storage driver (STORAGE_S3_*) |
| Behavior | Asset transforms locked to named presets (STORAGE_ASSET_TRANSFORM=presets). One Directus instance: cache, synchronization and rate limiter use memory stores (CACHE_STORE, SYNCHRONIZATION_STORE, RATE_LIMITER_STORE = memory). Set no REDIS or REDIS_* variable: Directus 12 moves its message bus, locks and shared stores, WebSockets and collaboration included, to Redis whenever one exists (apps/cms/.env.example:92). Directus MCP enabled for the AI Editor role; server-level MCP deletes are on (mcp_allow_deletes: true in settings.json), and the AI Editor policy has no delete permission. MCP sign-in by OAuth is on (mcp_oauth_enabled, mcp_oauth_dcr_enabled); those sessions run with the signed-in user's rights, and the Human Editor policy has 51 delete permissions |
| Deployment prerequisites | IP_TRUST_PROXY=true and a Directus license key are set on the Railway service, per the Dockerfile header |
apps/cms/package.json is scripts-only; Directus itself runs from the image. apps/cms/vercel.json sets ignoreCommand: exit 0, so Vercel never builds this package.
directus-sync.config.cjs dumps to apps/cms/directus/:
| Path | Holds |
|---|---|
directus/snapshot/ |
Collections, fields and relations, including editor groups, labels and ordering |
directus/collections/ |
roles, policies, permissions, flows, operations, presets, dashboards, panels, folders, settings, translations |
Build Bot directus_files read grant in directus/collections/permissions.json |
id, filename_download, type, filesize, width, height, folder, title and description for files in the seven delivery folders or unfiled, used by the export to read covers and build images; pinned by build-media-policy.test.ts |
Editorial rows are not in Git. Settings fields holding per-environment file UUIDs (project_logo, public_foreground, public_favicon) are committed as null; sync-push.ts merges the live values in for the push and restores the file afterward.
Command (in apps/cms) |
Effect |
|---|---|
bun run sync:diff |
Read-only preview of what a push would change |
bun run sync:pull |
Replaces the local dump with remote state and can create remote id mappings; review the whole diff and stage only apps/cms/directus/ |
bun run sync:push |
Always through the sync-push.ts guard; never call directus-sync push directly |
The guard refuses production unless DIRECTUS_SYNC_ALLOW_PROD_SCHEMA_PUSH=1 and DIRECTUS_SYNC_PUSH_ACK=sync-push-can-delete-cms-data are set, keeps permissions out unless DIRECTUS_SYNC_INCLUDE_PERMISSIONS=1 with DIRECTUS_SYNC_PERMISSIONS_ACK=permissions-push-can-delete-parallel-work, and then refuses any production push that touches policies or permissions regardless of acknowledgements (refuseProductionPolicySync). The snapshot declares the 11 live Human Editor asset grants. Twenty deferred Ops Bot/Build Bot grants are reference-only declarations in the offline plan. Eight unfiltered create/read rows use permissions: null; other fields, validation and presets are enforced.
A committed dump, a clean diff or a green test does not prove the live CMS matches. The About keys stop_stack and stop_clients label the Education and Languages sections. The global tech_stack collection supplies technology data. Fields read by the exporter are content contracts; a field hidden from the form can be required by the export.
The CMS holds optional projects_impact_metrics.source_url, projects_impact_metrics.as_of, services.impact_metric_source_url and services.impact_metric_as_of fields. Their editor validation and export schema apply to stored evidence. Human Editor has the two narrow Service-field read/update grants. Site metric displays show values and labels; the source/date fields are not displayed or included in metric text in llms-full.txt.
All write lanes run only from main by workflow_dispatch, inside the production GitHub environment, after the offline test job.
| Dispatch action | Does |
|---|---|
diff |
Read-only sync:diff (also runs automatically after every push to main) |
push |
Guarded sync:push; include_permissions is off by default and production policy sync is refused |
flows-push |
Read-only permission audit, diff, push of flows only, then a convergence diff |
permission-control-audit |
GET-only semantic comparison of declared and production permissions |
| No permission write action | Production permission rows, including the Build Bot file grant, cannot be pushed (production policy sync is refused) and use the confirmed owner-browser write path |
Saving CMS content does not itself start a build. A production build uses live CMS content, whether triggered by Publish site or a merge to main. A merge after a CMS content write therefore publishes that content when the production build succeeds; the code diff does not bound the content published.
| Role | Purpose |
|---|---|
| Administrator | Full access, including schema |
| Editor | Day-to-day content and files (Human Editor policy) |
| AI Editor | Content edits through the Directus MCP |
| Build Bot | Read-only content policy used by the production export token, including delivery-folder file reads for build images |
| Ops Bot | Declared content-operations policy; its presence in Git does not prove an active account |
| Public | Per-collection anonymous reads; status-bearing collections require published rows; files use folder and field allowlists |
| Flow | Trigger | Does |
|---|---|---|
| Publish site | Manual on build_queue (the Publish site item at the bottom of the sidebar), with confirmation |
Asynchronous version-before read, one hook POST, context, ten-minute wait, version-after read and editor notification. Distinct notices per failure branch; see Operations and recovery |
| Translation reminder on publish | items.update on content collections |
Checks for published status, logs it and sends the editor an in-app notification to check French and Spanish; no requests |