manifest, thin proxy router, and the sidecar backend
ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only adapter today), the generation queue, collections, and the database layer — kept on its own Postgres database rather than folded into officer_db, since both collections and provider_settings had rows on the machine this was extracted from. what changed rather than just moved: - images are served from an authenticated GET /files/:filename instead of an unauthenticated PUBLIC_URL-prefixed static route - the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter - provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has no callers left anywhere in the server code, only a migration script that also did not move ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers liveness. not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not been written, so this is not yet a discoverable plugin.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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 type { ImageAdapter } from './types';
|
||||
|
||||
/**
|
||||
* The key for a provider: its own, then the adapter's bootstrap credential.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter's bootstrap credential — the secret store first, `.env` second.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
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)];
|
||||
}
|
||||
|
||||
/** `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 = { provider: Provider; adapter: ImageAdapter; apiKey: string };
|
||||
|
||||
/** Everything needed to run a generation, or a reason it cannot run. */
|
||||
export async function resolveForRequest(options: {
|
||||
providerRef?: string;
|
||||
model?: string;
|
||||
}): Promise<ResolvedProvider | { error: string; status: 400 | 404 | 503 }> {
|
||||
let provider: Provider | undefined;
|
||||
|
||||
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);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = adapterByName(provider.adapter);
|
||||
if (!adapter)
|
||||
return { error: `Provider "${provider.name}" uses unknown adapter "${provider.adapter}".`, status: 400 };
|
||||
|
||||
const apiKey = await keyForProvider(provider);
|
||||
if (!apiKey) return { error: `Provider "${provider.name}" has no API key.`, status: 503 };
|
||||
|
||||
return { provider, adapter, apiKey };
|
||||
}
|
||||
|
||||
/** 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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** The adapters a provider can be built on, for the "add provider" form. */
|
||||
export const adapterCatalogue = () =>
|
||||
ADAPTERS.map((adapter) => ({
|
||||
name: adapter.name,
|
||||
label: adapter.label,
|
||||
models: adapter.models,
|
||||
default_model: adapter.defaultModel,
|
||||
env_var: envVarFor(adapter.name),
|
||||
supports_edit: typeof adapter.edit === 'function',
|
||||
}));
|
||||
Reference in New Issue
Block a user