// The headless CMS backend for the {{projectName}} project. Read by `voltro dev`. // // It composes @voltro/cms — a LIBRARY, not a plugin — with real editor auth: // // • @voltro/cms — content types declared as code (`content/*.contentType.ts` // via `defineContentType` + the `Schema` field DSL). `contentTypeToEntities` // compiles each to a `_drafts` + `_published` table pair (see // database/schema.ts), and the write pipeline (`saveDraft` → derive+validate, // `publish`/`unpublish`) drives the lifecycle. There is NO framework // discovery of `*.contentType.ts` — the schema file imports the types and // registers their tables, which is what makes them migrate. // • @voltro/plugin-auth — password sign-up/in/out over an HttpOnly session // cookie so only signed-in editors reach the write surface, and // `voltroPasswordStrategy` resolving that cookie into a typed `Subject`. // // Content is tenant-scoped: every derived content table carries `tenant()`, so // the runtime AND-merges `tenantId = subject.tenantId` into reads and stamps it // on writes — one tenant's drafts never leak to another. Boots ZERO-infra // (`store: 'memory'`, in-process users); the README shows the production swaps. import { defineEnv, envVar } from '@voltro/env' import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@voltro/plugin-auth' import { resolveScopes } from './authz' export const env = defineEnv({ LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }), // HMAC key that signs + verifies the session cookie AND the CMS preview // tokens (`previewToken` reads VOLTRO_SESSION_SECRET). `generate` means this // project mints its OWN key into a gitignored `.env.local` on first boot — no // value ships with the template (a shipped placeholder would be a signing key // published to everyone who downloads it). Production mints its own the same // way; a missing secret is a hard boot failure in serve/build/start. VOLTRO_SESSION_SECRET: envVar.secret({ generate: 'base64url', description: 'HMAC key that signs session cookies and CMS preview tokens.', }), }) // In-process user store — zero infra. `postgresUserStore({ sql })` for durable // editor accounts (it manages its own `_voltro_auth_*` tables, auto-migrated). const userStore = memoryUserStore() export default { type: 'api' as const, name: '{{capProjectName}}{{capAppName}}', store: 'memory' as const, env, plugins: [ authRoutesPlugin({ store: userStore, // New editor sign-ups land in this tenant; all content they author is // scoped to it. defaultTenantId: 'acme', successRedirect: '/', cookieSecure: process.env.NODE_ENV === 'production', }), ], auth: { // Resolves the session cookie into the request Subject the handlers read // (`ctx.request.subject`) and the store spine scopes writes by. strategies: [voltroPasswordStrategy()], // …and turns that identity into AUTHORITY. A session cookie carries no // `scopes` by design (identity is settled at sign-in; authority is re-read // per request), so without this the `content:*` guards on every procedure // below would be unsatisfiable — the editor would boot and answer // `ScopeError` to everyone, which is an outage, not security. // // It runs ONLY on a subject a strategy matched, never for anonymous. That // asymmetry is what the guards actually enforce here: signed in → editor // scopes; signed out → nothing. See `authz.ts` for the vocabulary and for // what to replace this with when roles live in your own tables. resolveScopes, }, // Point `voltro doctor` / `voltro check` at the scope vocabulary so the // unknown-scope rule is LIVE here. This app does scope-based authorization // without `@voltro/plugin-rbac`, and the plugin is what normally publishes // that set — without this pointer the rule stays dormant and a guard naming a // scope nothing grants (a typo, a rename) becomes a permanently uncallable // procedure that nothing reports. doctor: { scopeVocabulary: './authz.ts#EDITOR_SCOPES' }, }