Developer Guide

API keys

How the CMS stores AI credentials, who may see them, and how a host chooses which vendors Settings offers.

AI Designer (and anything else that calls a model) needs a vendor credential. That credential is not an appsettings value and not a property on CendiaOptions. It is a secret the installation manages under Settings → Configuration → API Keys.

The Settings card is write-only. An administrator can Change Key and can record when the vendor says the key expires. They cannot read the key back. That is the whole point: the CMS must not become a convenient place to harvest credentials.

The short version

  1. Choose a secret store in host configuration (None, DataProtection, later a vault).
  2. Choose which vendors Settings may offer, or register your own.
  3. An administrator pastes the key once in the CMS. The raw value never appears in HTML, logs, or SQL as plaintext.
{
  "Cendia": {
    "SecretStore": {
      "Provider": "DataProtection",
      "AiProviders": [ "Anthropic" ]
    }
  }
}

AiProviders is an allow-list. Omit it, or leave it empty, and every registered vendor appears. Ids that nobody registered are ignored, not shown as empty dropdown rows.

Put this in appsettings.Development.json (or an environment variable), not next to a production connection string you will copy into a support dump.

How configuration reaches the CMS

Same rule as mail. The CMS does not read files itself. The host binds Cendia and AddCendia receives the result:

builder.Services.AddCendia(o =>
    builder.Configuration.GetSection("Cendia").Bind(o));

Cendia:SecretStore:Provider and Cendia:SecretStore:AiProviders:0 (and :1, …) therefore work from JSON, user secrets, or Cendia__SecretStore__Provider.

The store

Provider Where the payload lives When to use it
None (default) Nowhere Fresh clones. Settings lists keys as unconfigured; Change Key names this setting.
DataProtection Ciphertext in cms_ApiKeys, key ring on disk Self-host and local feature work (Server, Arena Development).
AzureKeyVault / AwsSecretsManager Not shipped yet Enterprise hosts. Same Settings card; a package registers the store.

Data Protection persists its key ring under App_Data/data-protection-keys (configurable as KeyRingPath). A restart cannot decrypt ciphertext without that folder. Do not copy the ring into staging together with a production database.

The built-in AI vendors — Anthropic, OpenAI, Azure OpenAI, Gemini, Grok and Ollama — are registered, not compiled into the Razor page. Settings iterates whatever the host has enabled.

Choosing vendors

A commercial site should not pretend every installation talks to the same companies. Two knobs:

Restrict the built-ins with SecretStore:AiProviders, as in the JSON above.

Add a vendor the platform does not ship by registering another AiServiceProvider before or after AddCendia. Last registration under an id wins, so a package can replace a built-in's label or validator.

builder.Services.AddSingleton(new Cendia.Core.Secrets.AiServiceProvider(
    id: "Gemini",
    label: "Google Gemini",
    validateKey: key => key.Length >= 16
        ? null
        : "That value is not a usable API key."));

builder.Services.AddCendia(o =>
    builder.Configuration.GetSection("Cendia").Bind(o));

validateKey returns an administrator-safe message, or null when the value is acceptable. Never put the posted key in that message.

The id is what is stored on cms_ApiKeys. Built-in ids stay Anthropic, OpenAI, AzureOpenAI so a row written today still matches after you add Gemini.

What Settings will and will not do

  • Change Key writes a new current version. The previous ciphertext is kept for a configurable overlap (PreviousOverlapHours, default 24) so a mistake is reversible in the CMS. Revoke the old key at the vendor yourself.
  • Set Expiration records the date you set at Anthropic or OpenAI. It is metadata. The CMS does not disable the key when that date passes; it only shows Expires or Expired.
  • Retire Previous drops the CMS-side overlap. It does not call the vendor.
  • The card shows the last four characters and who last changed the key. It never shows the key.

Key kinds (AiDesigner today) are declared in Cendia.Core, not invented at runtime. A free-form secret name is how two features collide on one vault entry.

Using the key from code

Settings injects ISecretStore and must never inject ISecretResolver. Designer (and only Designer, for now) resolves by kind:

using var lease = await resolver.ResolveAsync(ApiKeyType.AiDesigner);
var header = lease.Reveal(); // one hop: the HTTP client, then dispose

SecretLease.ToString() is always [redacted]. Do not assign Reveal() to a page model, a log line, or IConfiguration.

If no key is configured, ResolveAsync throws SecretStoreException naming Settings. That is a failed generation, not an empty string the author has to diagnose.

What this is not

A static key in Cendia:Ai:ApiKey would work for a weekend demo and fail every leak surface this feature exists to close — configuration dumps, staging database copies, exception graphs.

Identity federation (short-lived tokens from Azure, AWS, GCP, or GitHub Actions) is the better enterprise end-state for a cloud-hosted site: there is no long-lived key to store. The Settings card remains the path for self-host and for any environment without an identity provider. When a federated store exists it will register like Data Protection, not replace this page.

Arena's production host is expected to use a vault or federation once those providers ship. DataProtection in Development is so Change Key works on a laptop.