Services

A config has one stanza per service, plus authorization and security, which configure GenoaCMS itself. Every stanza except deployment is required. The example configs show each of them filled in.

Authentication

Checks that users are who they claim to be. GenoaCMS keeps the session itself, as signed tokens in one cookie; see identity and sessions .

authentication: {
  providers: Record<string, AuthenticationProvider>
  cookieName: string
}
ts

At sign-in the providers are tried one at a time, in key order, until one recognizes the user. Several providers can therefore serve one instance — an identity platform for editors and a fixed list for a break-glass administrator, for example.

Ensure cookie name is valid

Some cloud hosting services strip cookies from requests and allow only specific ones. To avoid breaking auth, set the cookie name to a value that is not stripped.

On Firebase Hosting and behind Google Cloud CDN, only a cookie named __session reaches the backend:

authentication: {
    cookieName: '__session'
}
ts

Getting this wrong does not fail loudly. Sign-in works, and then every renewal is dropped, so users are signed out roughly every accessTokenMinutes with nothing in the logs to explain it.

GenoaCMS keeps the whole session — access token, refresh token, and the family it belongs to — in this one cookie, so a host that forwards a single cookie is enough. It stays well inside the 4096-byte limit.

There is no session secret to configure

Session tokens are signed with a key derived from the root signing seed, so nothing stores or configures it. Rotating the root therefore signs everyone out — which is intended, since the root is rotated when it may have been exposed.

Authorization is not a service

Authorization used to be a service with cloud adapters, on the assumption that a deployment could inherit access control from its cloud platform's IAM. It has been removed. Cloud IAM can grant "read bucket X"; it cannot express which collections or which fields a principal may reach, it would require a cloud identity for every copywriter and translator, and permissions such as pages:publish have no meaning outside GenoaCMS.

Authentication is federated because "who are you?" is a standardized question. Authorization answers "what may you do in this application?", so it is a core module of GenoaCMS with no adapters and no configuration stanza.

Database

The databases GenoaCMS edits, and the collections in each.

database: {
  providers: Record<string, DatabaseProvider>
  databases: Record<string, { provider: string, collections: CollectionReference[] }>
}
ts

Each database names its provider by key. Collections describe data that already exists in your database, so GenoaCMS can edit it in place: genoa init puts them in genoa.config/collections.ts, which every config imports.

import type { CollectionReference } from '@genoacms/contracts/database'
import { storageResource } from '@genoacms/contracts/schemas'

export const collections: CollectionReference[] = [
  {
    name: 'authors',
    primaryKey: { key: 'id', schema: { type: 'string' } },
    schema: {
      type: 'object',
      properties: {
        id: { type: 'string', format: 'uuid' },
        name: { type: 'string' },
        photo: storageResource
      }
    }
  }
]
ts
  • name is the collection's or table's name in the database.
  • primaryKey.key is the field that identifies a document, and primaryKey.schema its type. The AWS adapter accepts only string keys.
  • schema is a JSON Schema object describing a document; the contract types it as JsonSchema.
  • uiSchema, optional, tells the CMS how to show each field, keyed by field name: { body: { showPreview: false } } leaves body out of the collection listing.

@genoacms/contracts/schemas adds fields that point outside the document: storageResource and nullableStorageResource for an object in a bucket, and reference({ collection }) for a document of another collection.

Composed key

Composed keys are not supported.

Storage

The buckets GenoaCMS reads and writes, one of which holds its own data.

storage: {
  providers: Record<string, StorageProvider>
  buckets: Record<string, { provider: string }>
  defaultBucket: string
  pathDelimiter?: string   // default '|->'
}
ts

Each key of buckets is the name of a bucket that already exists, bound to the provider that serves it. defaultBucket names the bucket where GenoaCMS keeps its own data under .genoacms/ — see what GenoaCMS stores . pathDelimiter separates path segments in the storage browser's URLs.

Default bucket should be private

The default bucket holds authorization data and signed documents. Keep it private, and put public assets in a second bucket.

Secrets

Holds what must live neither in the bucket nor in the config: GenoaCMS's signing seeds, and every credential a config refers to with secret().

secrets: {
  providers: Record<string, SecretsProvider>   // exactly one
}
ts
Exactly one provider

Only one secret store may be configured. A secret store is a single authority: with two, a write has no defensible target, and a key present in one but not the other would make behavior depend on lookup order.

The local store is for development

@genoacms/adapter-secrets-env keeps secrets in plaintext in .genoacms/secrets.env, and a production build refuses it. Use a secret manager in production; the contract is identical, so only the configuration changes. How a config refers to a secret is under secrets .

What GenoaCMS stores here

KeyPurpose
GENOACMS_ROOT_KEY_SEEDThe root signing key. See below.
GENOACMS_SUBORDINATE_KEY_SEED_…One per signing key, named by its key id.
GENOACMS_KEY_REGISTRY_SEQUENCEGuards against an old key registry being restored.

GenoaCMS creates all of these itself. Nothing here has to be set by hand.

Subordinate seeds accumulate

Keys rotate on an interval, so a new GENOACMS_SUBORDINATE_KEY_SEED_… appears each time. A seed is only needed to sign, so seeds for superseded keys can be removed — the public key stays in .genoacms/keys/public.json, and everything that key signed keeps verifying. Removing the seed of the current key breaks signing until the next rotation, so check keys/public.json for which key is current before pruning.

The root signing key

On first start GenoaCMS generates a root trust anchor — the key at the top of its signing chain — and stores its seed as GENOACMS_ROOT_KEY_SEED. A line naming the new key's id is written to the log when this happens:

[genoacms:signing] generated a new root trust anchor, keyId 9f2c41ab8d7e0355.
text

Only the seed is stored; the keypair is derived from it at startup. That keeps one short value in the secret manager rather than a multi-kilobyte key, and holding the seed is equivalent to holding the key, so nothing is given away by storing the smaller thing.

Back up the seed, and treat it as the key

Losing GENOACMS_ROOT_KEY_SEED means losing the trust anchor. GenoaCMS would generate a new one on the next start, and every consumer holding the old public key would then reject everything this instance signs — recoverable only by redeploying those consumers.

Anyone who can read the seed can sign as your instance. It belongs in a secret manager with the same care as a production database credential, never in the repository.

Starting several instances at once is safe

Instances that start together do not each generate a key. Creation is an atomic claim: exactly one wins, and the others wait briefly and adopt its key. An instance that cannot obtain a value fails to start rather than inventing its own — which would leave two instances signing with keys that disagree, invisible until a consumer rejected a legitimate artifact.

If startup reports that a key was claimed but never given a value, an earlier instance died part-way through first-time setup. Delete the key from the secret store so it can be created again.

To provision the key yourself instead of letting it be generated — for a multi-region deployment, or to keep the anchor under existing key management — write the seed to the secret store before first start. GenoaCMS generates one only when none is present.

Languages

The languages components may be authored in, keyed by the language a component records.

languages: {
  providers: Record<string, LanguageProvider>
}
ts
languages: {
  providers: {
    typescript: languageProvider('@genoacms/language-adapter-ts', { target: 'es2020' })
  }
}
ts

A component records its language, and GenoaCMS resolves the adapter from it, so several languages can coexist. See adding a language .

Deployment

Where genoa build and genoa deploy send GenoaCMS.

deployment?: {
  targets: Record<string, DeploymentTarget>
  default?: string
}
ts

A target is an adapter that knows how to build for and publish to one platform. genoa deploy gcp picks the target by key; without one it takes default, else the first key. A config used only by genoa dev may leave the stanza out; a build requires it.

A target's options — credentials included — are read only on the machine running genoa deploy. They never enter the build.

Authorization and security

Neither is a service: they configure GenoaCMS itself.

authorization: {
  roles?: Record<string, Grant[]>
  assignments?: Record<string, string[]>
  lockRoles?: boolean
}

security: {
  accessTokenMinutes?: number           // default 15
  refreshTokenDays?: number             // default 14
  grantCacheSeconds?: number            // default 30
  subordinateKeyRotationDays?: number   // default 90
  maxFuel?: number                      // default 1 000 000
  maxDepth?: number                     // default 100
  maxAllocation?: number                // default 10 000 000
  fetchOrigins?: string[]               // default [], which permits nothing
}
ts

maxFuel, maxDepth and maxAllocation are the ceilings a dynamic component runs under: loop iterations and recursive branches per render, call depth, and elements and bytes allocated per render. They are compiled into each published component and covered by its signature, so a consumer may run below them but never above. fetchOrigins lists the origins — scheme, host and optional port — a dynamic component's data bridge may reach.

Two stanzas rather than one, because they behave oppositely: everything in authorization is authority, re-read on every resolution, while everything in security is a seed consumed once at first start.

roles declares roles by name; assignments maps a subject to the roles it holds. A subject is the provider-issued identifier from the authentication service, never an email address.

A new instance needs at least one assignment, or nobody can administer it:

authorization: {
  roles: {
    Administrator: [{ permission: '*', resource: '*' }]
  },
  assignments: {
    'the-subject-of-your-first-administrator': ['Administrator']
  }
}
ts
Declarations are authoritative and immutable

What genoa.config declares cannot be changed through the CMS. Editing or deleting a declared role or assignment at runtime is refused, not silently reverted later.

Both are resolved without reading storage, so they still apply on an instance whose stored authorization data is missing or cannot be trusted — which is what makes them the way back in.

Removing a declaration removes it from the instance. Deleting a line here revokes the access it granted; it does not leave an editable copy behind.

Runtime administration remains free to create roles and assignments that genoa.config does not name. Set authorization.lockRoles to true to disable runtime administration entirely, for an instance whose authorization should be fixed at deployment — it sits beside the declarations because it governs exactly them.

security.subordinateKeyRotationDays sets how long a signing key stays current — see key rotation .

These are seed values, not the live ones

The security stanza supplies the values a new instance starts from. They are then held in a signed document in the bucket ( security/policy.json ), which is what a running instance reads and what an administrator changes at runtime.

Editing genoa.config afterwards therefore has no effect on an instance that has already started.

When a config does not load

The loader reports every problem at once, each with the path of the offending field, under config/invalid. Each problem has one of these codes:

CodeRaised when
config/not-foundno config file exists where it was looked for
config/evaluation-failedthe file throws while it is evaluated, or imports something missing
config/not-an-objectthe default export is not a plain object
config/missing-stanzaa required stanza is absent or not an object
config/invalid-provider-entrya provider entry is not { adapter, options }; use the helpers
config/descriptor-not-foundthe adapter specifier cannot be loaded; is the package installed?
config/descriptor-invalidthe module at the specifier is not an adapter descriptor
config/kind-mismatchan adapter is used in the wrong stanza, such as a storage adapter under database
config/invalid-optionsthe adapter's own validation refused the options; the message gives its reason
config/not-serializablean option holds something that is not data, such as a function or a class instance
config/bare-secreta credential option holds a plain value; wrap it in secret(), env() or inline()
config/misplaced-referencea reference sits in an option the adapter does not declare as a credential
config/bootstrap-secretthe secrets provider's own options use secret()
config/invalid-secret-keya secret() key does not match [A-Za-z_][A-Za-z0-9_]*
config/secrets-provider-countsecrets.providers holds no provider, or more than one
config/unknown-providera bucket, database or deployment.default names a key that does not exist
config/unknown-bucketdefaultBucket is not a key of buckets
config/integer-keya provider, bucket or database key is an integer, which JavaScript reorders
config/development-onlya production build uses a development-only adapter
config/inlinea warning, in production: an inline() value is written into the build
config/inline-forbidden--no-inline was given and the config uses inline()

At build time, config/unknown-target means the target named on the command line is not a key of deployment.targets, and config/no-deployment-target that a target was requested from a config that declares none.