refactor(providers): a provider is a vendor and a key, and the table is gone

The dropdown was listing "connections" — a row per adapter + model + key — which
made choosing a model an act of configuration, put a key icon and a bin inside a
menu item, and showed five accounts nobody had created.

A provider is now a vendor Officer curates. Every one is listed, set up or not,
because a vendor you have no key for is exactly what you need to see in order to
add one. The model is chosen per generation, from that provider's entry in the
catalogue, beside it.

With the model off the row, all that remained of a "provider" was the key for
OpenAI — and Officer already stores keys: one active value per purpose, 0600
inside a 0700 directory, outside the backup tarball, created on first use so
nothing needs provisioning. So `atelier_providers` is dropped, along with
`generations.provider_id` that pointed into it. `provider` and `model` stay as
text on each generation, which were always the historical fact rather than a
pointer to configuration.

`getSecret`, never `getKey`: this is a foreign credential, and getKey mints a
random value for a purpose it cannot find — which for someone else's API key
means authenticating with something invented.

Resolution follows: a request names a model, and the catalogue says whose it is,
rather than a database being asked which stored row served it. A model nobody
in the catalogue claims is still accepted — the catalogue is advisory, and a
model released today should work before we ship a file that mentions it.

Two routes replace five: PUT and DELETE on /providers/:name/key. Deleting a key
that came from the environment is refused rather than faked — it belongs to the
install, not to this screen.
This commit is contained in:
2026-08-20 23:02:31 +01:00
parent 3c59bf4d0c
commit 19e94129a0
17 changed files with 441 additions and 766 deletions
+113 -117
View File
@@ -1,156 +1,152 @@
import { getSecret } from 'officerdb/secret-store';
import { getProvider, getProviderByName, listProviders, resolveProviderForModel } from '../db';
import type { Provider } from '../db';
import { decrypt } from '../crypto';
import { ADAPTERS, adapterByName } from './index';
import { getSecret, hasKey, putSecret } from 'officerdb/secret-store';
import { adapterByName } from './index';
import { getCatalogue } from '../catalogue/store';
import { refreshableProviders } from '../catalogue/refresh';
import type { ImageAdapter } from './types';
/**
* The key for a provider: its own, then the adapter's bootstrap credential.
* Which vendors Atelier can generate with, and whether we hold a key for each.
*
* That order because the row is where the owner put a key deliberately. The bootstrap credential is
* how a key gets in before there is a UI to put one in, and how an install supplies one without a
* database write. Several providers sharing an adapter therefore also share the fallback, which is
* right: it is the same account.
* ── There is no provider table any more ──
*
* There used to be one: a row per adapter+model+key, called a "provider", and the model was part of the
* stored thing. That made "which model" a piece of persistent configuration when it is really a choice
* made per generation, and it produced five rows nobody had created — one per model in a hard-coded
* list — which read as five configured accounts.
*
* With the model out of it, what remains of a row is exactly one fact: the key for OpenAI. Officer
* already stores keys, in `secrets/officer-keys.db`, one active value per purpose, 0600 inside a 0700
* directory, outside the backup tarball. So that is where they live, and Atelier keeps no provider
* state in Postgres at all. "Configured" means the secret store has `atelier-<provider>-key`, or the
* environment has `<PROVIDER>_API_KEY`. Nothing else can be true or false about a provider.
*
* The catalogue says which providers exist; the secret store says which of them you can use.
*/
export async function keyForProvider(provider: Provider): Promise<string | undefined> {
if (provider.apiKeyEncrypted) {
try {
return decrypt(provider.apiKeyEncrypted);
} catch (error) {
// A key that will not decrypt is broken, not absent. Falling through to the bootstrap credential
// would hide that the stored credential is unusable.
console.error(`[atelier:providers] stored key for "${provider.name}" failed to decrypt:`, error);
return undefined;
}
}
return bootstrapKeyFor(provider.adapter);
/** `openai` -> `atelier-openai-key`. The store creates purposes on first use, so nothing is provisioned. */
export const secretPurposeFor = (provider: string) => `atelier-${provider}-key`;
/** `openai` -> `OPENAI_API_KEY`. The bootstrap path, for an install that supplies a key without a UI. */
export const envVarFor = (provider: string) => `${provider.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
/**
* The key for a provider: the secret store, then `.env`.
*
* `getSecret`, never `getKey`: this is a FOREIGN credential — OpenAI's, not ours — and `getKey` mints a
* random value for a purpose it does not find, which for a foreign credential means authenticating with
* something invented. The distinction is the whole reason both functions exist.
*/
export function keyFor(provider: string): string | undefined {
try {
const stored = getSecret(secretPurposeFor(provider));
if (stored) return stored;
} catch {
// Unreadable store — fall through to env, as every other secret-store caller in this codebase does.
}
return process.env[envVarFor(provider)];
}
/** Where a key came from, for a UI that should say "from the environment" rather than show an empty field. */
export function keySource(provider: string): 'store' | 'environment' | null {
try {
if (hasKey(secretPurposeFor(provider))) return 'store';
} catch {
/* unreadable store */
}
return process.env[envVarFor(provider)] ? 'environment' : null;
}
/** Save a key. Trimmed, because a pasted key routinely arrives with a newline attached. */
export function setKey(provider: string, apiKey: string): void {
putSecret(secretPurposeFor(provider), apiKey.trim());
}
/**
* The adapter's bootstrap credential — the secret store first, `.env` second.
* Forget a key.
*
* `getSecret`, never `getKey`: this is a foreign credential (OpenAI's, not ours), and `getKey` mints a
* random value for an absent purpose — for a foreign credential that means silently authenticating
* with a fake. `putSecret`/`getSecret` landed 2026-08-19; `bun run secret:set atelier-openai-key`
* writes it. Falls back to `<ADAPTER>_API_KEY` in `.env` so an install that predates the secret store
* keeps working unchanged.
*
* Keyed per-adapter (`atelier-<adapter>-key`), matching `envVarFor`'s own per-adapter shape below —
* several providers on one adapter already share one fallback, and this preserves that.
* Only the stored one can be forgotten: a key in `.env` belongs to the install, not to this screen, and
* a button that appeared to delete it and did not would be worse than no button.
*/
function bootstrapKeyFor(adapter: string): string | undefined {
try {
const stored = getSecret(`atelier-${adapter}-key`);
if (stored) return stored;
} catch {
// Unreadable store — fall through to env, same as every other secret-store caller in this codebase.
}
return process.env[envVarFor(adapter)];
export function clearKey(provider: string): void {
putSecret(secretPurposeFor(provider), '');
}
/** `openai` -> `OPENAI_API_KEY`. Per adapter, not per provider: it is one account. */
export const envVarFor = (adapter: string) => `${adapter.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
export type ResolvedProvider = { providerName: string; adapter: ImageAdapter; apiKey: string };
export type ResolvedProvider = { provider: Provider; adapter: ImageAdapter; apiKey: string };
/** Everything needed to run a generation, or a reason it cannot run. */
/**
* Everything needed to run a generation, or the reason it cannot run.
*
* Resolution is by MODEL now — the caller names a model, the catalogue says whose it is. That replaces
* a database lookup that asked which stored row served a model, which is the same question asked of a
* worse source: the catalogue is the list of what exists, and the rows were only ever a subset of it
* that someone happened to have created.
*
* `provider` may still be named explicitly, and an unknown one is an error rather than a fallback: a
* silent substitution bills a different account than the caller asked for.
*/
export async function resolveForRequest(options: {
providerRef?: string;
model?: string;
}): Promise<ResolvedProvider | { error: string; status: 400 | 404 | 503 }> {
let provider: Provider | undefined;
const catalogue = await getCatalogue();
if (options.providerRef) {
// Named explicitly — a uuid or the provider's name. An unknown one is the caller's mistake and
// must not silently fall back to a different provider that will bill them.
provider = /^[0-9a-f-]{36}$/i.test(options.providerRef)
? await getProvider(options.providerRef)
: await getProviderByName(options.providerRef);
let name = options.providerRef;
if (!provider) return { error: `No provider named "${options.providerRef}".`, status: 404 };
if (!provider.enabled) return { error: `Provider "${provider.name}" is disabled.`, status: 503 };
} else {
provider = await resolveProviderForModel(options.model);
if (!provider) {
return { error: 'No provider is configured. Add one in Settings → Integrations → Atelier.', status: 503 };
if (name) {
if (!catalogue.providers.some((entry) => entry.name === name)) {
return { error: `No provider named "${name}".`, status: 404 };
}
} else if (options.model) {
name = catalogue.providers.find((entry) => entry.models.some((model) => model.id === options.model))?.name;
// A model nobody in the catalogue claims is not refused: the catalogue is advisory, and a model
// released today should work before we have shipped a file that mentions it. The first provider we
// hold a key for gets it, and the vendor gives the real answer.
if (!name) name = catalogue.providers.find((entry) => keyFor(entry.name))?.name;
} else {
name = catalogue.providers.find((entry) => keyFor(entry.name))?.name;
}
const adapter = adapterByName(provider.adapter);
if (!adapter)
return { error: `Provider "${provider.name}" uses unknown adapter "${provider.adapter}".`, status: 400 };
if (!name) return { error: 'No provider has an API key yet. Add one in Atelier.', status: 503 };
const apiKey = await keyForProvider(provider);
if (!apiKey) return { error: `Provider "${provider.name}" has no API key.`, status: 503 };
const adapter = adapterByName(name);
if (!adapter) return { error: `Provider "${name}" has no adapter in this build.`, status: 400 };
return { provider, adapter, apiKey };
}
const apiKey = keyFor(name);
if (!apiKey) return { error: `Provider "${name}" has no API key.`, status: 503 };
/** What the providers screen renders. Never includes a key, in any form. */
export async function providerStatuses() {
const rows = await listProviders();
return Promise.all(
rows.map(async (row) => ({
id: row.id,
name: row.name,
adapter: row.adapter,
model: row.model,
enabled: row.enabled,
is_default: row.isDefault,
configured: Boolean(await keyForProvider(row)),
// Where the key came from, so the UI can say "set as a bootstrap credential" rather than showing
// an empty field next to a provider that works.
key_source: row.apiKeyEncrypted
? ('database' as const)
: (await keyForProvider(row))
? ('environment' as const)
: null,
env_var: envVarFor(row.adapter),
created_at: row.createdAt,
updated_at: row.updatedAt,
})),
);
return { providerName: name, adapter, apiKey };
}
/**
* What a new connection can be built on: the CATALOGUE, filtered to adapters that exist as code.
* What the UI lists: every provider in the catalogue, one per vendor, whether or not it is set up.
*
* The model list used to come off the adapter itself — a hard-coded array beside the HTTP client —
* which meant adding a model was a code change and a release. It comes from the curated catalogue
* now, refreshed daily, and the adapter keeps the one thing only it knows: whether it can edit.
*
* A catalogue provider with no adapter is skipped rather than offered. Listing a vendor we cannot
* actually call is an offer that fails at the moment of use, which is worse than not being offered.
* All of them, always — a vendor you have no key for is precisely what you need to see in order to add
* one. Never includes a key, in any form; not masked either, since a mask still leaks length and shape
* and the only questions are whether one is set and where it came from.
*/
export const adapterCatalogue = async () => {
export async function providerStatuses() {
const catalogue = await getCatalogue();
const refreshable = refreshableProviders();
return catalogue.providers.flatMap((provider) => {
return catalogue.providers.map((provider) => {
const adapter = adapterByName(provider.name);
if (!adapter) return [];
// Deprecated models stay in the catalogue so old rows keep their labels; they are not offered.
const models = provider.models.filter((model) => model.status !== 'deprecated');
const source = keySource(provider.name);
return [
{
name: provider.name,
label: provider.label,
models: models.map((model) => model.id),
/** The full records, for a UI that offers per-model sizes and qualities rather than one list. */
model_details: models,
default_model: provider.defaultModel ?? adapter.defaultModel,
env_var: provider.envVar || envVarFor(provider.name),
supports_edit: typeof adapter.edit === 'function',
/** When this provider's models were last refreshed; null while only the shipped file has been read. */
updated_at: catalogue.updatedAt ?? null,
refreshable: refreshableProviders().includes(provider.name),
},
];
return {
name: provider.name,
label: provider.label,
configured: Boolean(keyFor(provider.name)),
key_source: source,
env_var: envVarFor(provider.name),
/** False for a catalogue entry we have no code for — listed, but not yet usable. */
usable: Boolean(adapter),
supports_edit: typeof adapter?.edit === 'function',
refreshable: refreshable.includes(provider.name),
default_model: provider.defaultModel ?? adapter?.defaultModel ?? models[0]?.id ?? null,
models: models.map((model) => model.id),
model_details: models,
updated_at: catalogue.updatedAt ?? null,
};
});
};
}