Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .github/workflows/validate-templates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ jobs:
- name: Validate templates
run: node _scripts/validate-templates.js

- name: Check RBAC codegen outputs
run: node _scripts/rbac-codegen.js --check

build-frontends:
name: Build ${{ matrix.template }}/${{ matrix.frontend }}
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
.env
.DS_Store
.claude/
.cursor/
node_modules
.vscode
**/logs/
.backups
.pnpm-store
**/*.tsbuildinfo

31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ Each template is designed to be:

---

## **Directus Version & Licensing**

These starters run on **Directus 12**. Directus 12 introduces [licensing tiers](https://directus.io/docs/licensing/overview): the free **core** tier has limits on users, collections, flows, and features like custom permission rules. A `LICENSE_KEY` raises those limits and unlocks licensed capabilities — which starter you pick determines whether core is enough (see table below).

| Starter | Default tier | Without a license | With a `LICENSE_KEY` |
| ------------ | ------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| **cms** | Core (free) | `template/` — full RBAC skeleton, flat permission rules (3 seed users) | Apply `template-licensed/` for enforceable filters, validation, field lists |
| **cms-i18n** | Team | Requires a `LICENSE_KEY` (schema exceeds core tier limits) | Licensed RBAC + i18n permission deltas |
| **blank** | Core (free) | Works out of the box | Same unlocks as cms |

RBAC and licensed setup: [`cms/directus/README.md`](cms/directus/README.md).

Directus 12 uses `version=published` for live content (formerly `main`); every versioned item also has a `draft` version. Frontends here handle both keys.

---

## **Available Templates**

### CMS
Expand Down Expand Up @@ -44,7 +60,7 @@ language switcher.
## **Required Environment Variables**

Each framework requires your Directus URL, a server-side token, and a site URL. The token (`DIRECTUS_SERVER_TOKEN`)
comes from your **Webmaster** account and is used server-side for content access, preview, and form submissions. See the
comes from your **admin account** (the one you create during Directus first-launch onboarding) and is used server-side for content access, preview, and form submissions. See the
individual template README for full setup details.

### CMS — Next.js
Expand Down Expand Up @@ -121,9 +137,10 @@ NUXT_PUBLIC_ENABLE_VISUAL_EDITING=true

- Log in to your project using the URL provided in your email or from the Directus Cloud Dashboard.

3. **Create accounts and generate tokens**:
3. **Generate a server token**:

- Go to the **Users Directory** and create a **Webmaster** user account.
- Go to the **Users Directory** and open your admin account.
- For local Docker, this is the admin account you created during first-launch onboarding.
- Scroll down to the **Token** field, generate a static token, and save it — this is your `DIRECTUS_SERVER_TOKEN`.
- For local type generation, you can also generate a token on the **Admin** user — this is your
`DIRECTUS_ADMIN_TOKEN` (never exposed at runtime).
Expand All @@ -145,7 +162,7 @@ Prefer to self-host? You can deploy Directus with the CMS schema pre-loaded dire
This sets up a hosted Directus instance with the CMS template already applied. Once deployed:

1. Open your Railway project and grab the public URL for your Directus service — this is your `DIRECTUS_URL`.
2. Log in to Directus, go to the **Users Directory**, and create a **Webmaster** user.
2. Log in to Directus, go to the **Users Directory**, and open your admin account.
3. Generate a static token for that user — this is your `DIRECTUS_SERVER_TOKEN`.
4. Choose a frontend starter from the table above and follow the setup instructions in its README.

Expand Down Expand Up @@ -184,7 +201,7 @@ This sets up a local project with Docker-based Directus + frontend integration.

- This will start Directus on [http://localhost:8055](http://localhost:8055)
- On first launch, you'll be prompted to complete the admin setup
- After setup, create a **Webmaster** user and generate a static token for `DIRECTUS_SERVER_TOKEN`
- After setup, generate a static token on your admin account for `DIRECTUS_SERVER_TOKEN`
- Optionally generate a token on the Admin user for `DIRECTUS_ADMIN_TOKEN` (type generation only)

---
Expand Down Expand Up @@ -228,4 +245,6 @@ Directories prefixed with `_` or `.` are internal and excluded from the template

### Validation

Run `node _scripts/validate-templates.js` to check all templates have the required structure and metadata.
Run `node _scripts/validate-templates.js` to check template structure.

**RBAC changes (maintainers):** Edit `_shared/rbac/`, run `pnpm rbac:codegen`, commit outputs. CI enforces with `pnpm rbac:codegen:check`. See [`_shared/rbac/README.md`](_shared/rbac/README.md) for why codegen exists and how it differs from `rbac:sync-licensed` (user upgrade tool).
212 changes: 212 additions & 0 deletions _scripts/rbac-codegen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#!/usr/bin/env node

/**
* Generates RBAC files for cms core + licensed template variants from _shared/rbac/.
*
* Usage:
* node _scripts/rbac-codegen.js
* node _scripts/rbac-codegen.js --check
*/

import { readFileSync, writeFileSync, existsSync, cpSync, rmSync } from 'node:fs';
import { join, resolve } from 'node:path';

const ROOT = resolve(import.meta.dirname, '..');
const RBAC = join(ROOT, '_shared/rbac');
const CMS_TEMPLATE = join(ROOT, 'cms/directus/template');
const CMS_LICENSED = join(ROOT, 'cms/directus/template-licensed');
const I18N_TEMPLATE = join(ROOT, 'cms-i18n/directus/template');
const CHECK = process.argv.includes('--check');

const RBAC_FILES = ['roles.json', 'policies.json', 'access.json', 'permissions.json'];

const LICENSED_MODULES = [
'public-api-filters',
'writer-self-access',
'content-manage-rules',
'live-preview-access',
'form-submission-hardening',
'studio-team-rules',
];

function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}

function loadLicensedPermissions() {
const modulesDir = join(RBAC, 'licensed/modules');

if (existsSync(modulesDir)) {
return LICENSED_MODULES.flatMap((name) => readJson(join(modulesDir, `${name}.json`)));
}
return readJson(join(RBAC, 'licensed/permissions.json'));
}

function writeJson(path, data) {
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
}

/** Flatten permission rows for core tier (no custom rules). */
function flattenPermissions(permissions) {
return permissions.map((row) => ({
...row,
permissions: {},
validation: null,
presets: null,
fields: ['*'],
}));
}

function copyTemplateTree(src, dest) {
if (existsSync(dest)) {
rmSync(dest, { recursive: true });
}

cpSync(src, dest, { recursive: true });
}

function syncLicensedTemplate() {
const licensedPerms = loadLicensedPermissions();
const skeleton = (name) => readJson(join(RBAC, 'core-skeleton', name));

copyTemplateTree(CMS_TEMPLATE, CMS_LICENSED);

const licensedSrc = join(CMS_LICENSED, 'src');
writeJson(join(licensedSrc, 'roles.json'), skeleton('roles.json'));
writeJson(join(licensedSrc, 'policies.json'), skeleton('policies.json'));
writeJson(join(licensedSrc, 'access.json'), skeleton('access.json'));
writeJson(join(licensedSrc, 'permissions.json'), licensedPerms);

// Update licensed template README
const licensedReadme = join(CMS_LICENSED, 'README.md');
if (existsSync(licensedReadme)) {
writeFileSync(
licensedReadme,
`Licensed CMS template variant — same schema as \`../template/\`, with enforceable permission rules. Requires an active \`LICENSE_KEY\`.

Setup and apply instructions: [\`../README.md\`](../README.md#licensing-and-rbac).
`,
'utf8',
);
}
}

function syncCorePermissions() {
const licensedPerms = loadLicensedPermissions();
const flat = flattenPermissions(licensedPerms);
const corePath = join(CMS_TEMPLATE, 'src/permissions.json');
const current = readJson(corePath);

if (!CHECK) {
// Preserve core skeleton files from disk (may include manual tweaks); sync skeleton copies
for (const file of ['roles.json', 'policies.json', 'access.json']) {
const src = join(CMS_TEMPLATE, 'src', file);
writeJson(join(RBAC, 'core-skeleton', file), readJson(src));
}
}

if (CHECK) {
if (JSON.stringify(current) !== JSON.stringify(flat)) {
console.error('Core permissions.json drift — run: pnpm rbac:codegen');
process.exit(1);
}
} else {
writeJson(corePath, flat);
}
}

/** cms-i18n: licensed permissions + translation collection rows */
function syncI18nPermissions() {
const licensedPerms = loadLicensedPermissions();
const i18nDeltaPath = join(RBAC, 'cms-i18n-permissions.delta.json');
const i18nPermsPath = join(I18N_TEMPLATE, 'src/permissions.json');

let merged = [...licensedPerms];

if (existsSync(i18nDeltaPath)) {
const delta = readJson(i18nDeltaPath);
const key = (r) => `${r.policy}|${r.collection}|${r.action}`;
const map = new Map(merged.map((r) => [key(r), r]));

for (const row of delta) {
map.set(key(row), row);
}
merged = [...map.values()];
} else if (existsSync(i18nPermsPath)) {
// Build delta from current i18n minus licensed collections on first run
const current = readJson(i18nPermsPath);

const licensedKeys = new Set(licensedPerms.map((r) => `${r.policy}|${r.collection}|${r.action}`));
const delta = current.filter((r) => !licensedKeys.has(`${r.policy}|${r.collection}|${r.action}`));
if (!CHECK) {
writeJson(i18nDeltaPath, delta);
}
merged = [...licensedPerms, ...delta];
}

// Sync i18n skeleton RBAC (use cms core-skeleton; i18n uses translated policy label for Public only)
const i18nPolicies = readJson(join(I18N_TEMPLATE, 'src/policies.json'));
const publicPolicy = i18nPolicies.find((p) => p.id === 'abf8a154-5b1c-4a46-ac9c-7300570f4f17');
if (publicPolicy) {
publicPolicy.name = '$t:public_label';
}

if (!CHECK) {
for (const file of ['roles.json', 'access.json']) {
writeJson(join(I18N_TEMPLATE, 'src', file), readJson(join(RBAC, 'core-skeleton', file)));
}
}

if (CHECK) {
const currentPerms = readJson(i18nPermsPath);
if (JSON.stringify(currentPerms) !== JSON.stringify(merged)) {
console.error('cms-i18n permissions.json drift — run: pnpm rbac:codegen');
process.exit(1);
}
const currentPolicies = readJson(join(I18N_TEMPLATE, 'src/policies.json'));
if (JSON.stringify(currentPolicies) !== JSON.stringify(i18nPolicies)) {
console.error('cms-i18n policies.json drift — run: pnpm rbac:codegen');
process.exit(1);
}
} else {
writeJson(i18nPermsPath, merged);
writeJson(join(I18N_TEMPLATE, 'src/policies.json'), i18nPolicies);
}
}

function checkLicensedTemplate(licensedPerms) {
if (!existsSync(CMS_LICENSED)) {
console.error('template-licensed/ missing — run: pnpm rbac:codegen');
process.exit(1);
}

for (const file of RBAC_FILES) {
const expected = file === 'permissions.json' ? licensedPerms : readJson(join(RBAC, 'core-skeleton', file));
const actual = readJson(join(CMS_LICENSED, 'src', file));
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
console.error(`template-licensed/src/${file} drift — run: pnpm rbac:codegen`);
process.exit(1);
}
}
}

console.log('RBAC codegen...\n');

const licensedPerms = loadLicensedPermissions();

syncCorePermissions();
if (!CHECK) {
writeJson(join(RBAC, 'licensed/permissions.json'), licensedPerms);
syncLicensedTemplate();
}
syncI18nPermissions();

if (CHECK) {
checkLicensedTemplate(licensedPerms);
console.log('RBAC outputs in sync.');
} else {
console.log('Generated:');
console.log(' - cms/directus/template/src/permissions.json (core, flat)');
console.log(' - cms/directus/template-licensed/ (licensed variant)');
console.log(' - cms-i18n/directus/template/src/permissions.json');
}
Loading
Loading