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:
2026-08-20 03:53:53 +00:00
commit f23ec1283b
36 changed files with 2759 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
*.log
+21
View File
@@ -0,0 +1,21 @@
import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { mountPrefix } from '@@/plugins/manifest';
import { manifest } from '../manifest';
// /api/atelier/* — auth, then forward to officer-atelier. No routes of its own and no Atelier
// knowledge: this file must never grow app logic. See plugins/offscale/api/router.ts, the pattern
// this follows.
//
// Generation can run for tens of seconds and the queue can be mid-flight when a caller polls, so the
// proxy is given a longer idle timeout than the default — the sidecar itself already bounds a single
// poll at MAX_WAIT_SECONDS (sidecar/api/images.ts), this only has to outlast that plus network slack.
const proxy = createSidecarProxy({
name: 'atelier',
prefix: `/api${mountPrefix({ appName: 'atelier', manifest })}`,
timeoutSeconds: 260,
});
export const router = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getAtelierServerUrl = proxy.getHttpUrl;
+35
View File
@@ -0,0 +1,35 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Atelier — image generation as a service: a provider-shaped API over OpenAI's image endpoints,
// storing what it makes so every image has a durable reference inside Officer.
//
// Extracted 2026-08-20 from a standalone server (~/officer-dev/dockers/officer-atelier) that predates
// the plugin system. See PLUGIN.md for what moved, what did not, and why — most notably that the
// `atelier` Postgres database stays separate rather than folding into officer_db (both `collections`
// and `provider_settings` had rows on the machine this was extracted from), and that generated images
// are now served through an authenticated route instead of an unauthenticated `PUBLIC_URL`.
//
// api/router.ts a thin auth-gated proxy — no Atelier knowledge, and it must never grow app logic
// sidecar/ the whole image-generation contract: providers, the queue, the database
// web/ three panels — Generate, Gallery, Collections — and a layout
//
// Providers (which adapter, which model, whose key) is NOT a panel — it moved to
// Settings → Integrations → Atelier, per PLUGIN.md's "Providers as a settings screen" decision.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Atelier',
summary: 'Image generation — prompt, gallery, collections, backed by OpenAI’s image models',
icon: 'Palette',
color: '#f59e0b',
permissions: [
{
key: 'atelier',
label: 'Atelier',
description: 'Image generation: prompts, gallery, collections and provider configuration',
},
],
};
+138
View File
@@ -0,0 +1,138 @@
import {
addToCollection,
collectionImages,
createCollection,
deleteCollection,
getCollection,
getCollectionByName,
listCollections,
removeFromCollection,
renameCollection,
} from '../db';
import { createRouter } from '../create-router';
import { openaiError, UUID_PATTERN } from '../errors';
import { serializeCollection, serializeCollectionSummary, serializeGeneration } from '../serialize';
export const collectionsRouter = createRouter();
const notFound = (id: string) => openaiError(`No collection with id "${id}".`, 'not_found');
/** Trimmed, and required to be something. A collection called " " is a collection nobody can name again. */
function cleanName(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
collectionsRouter.get('/', async (ctx) =>
ctx.json({ data: (await listCollections()).map(serializeCollectionSummary) }),
);
collectionsRouter.post('/', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as { name?: unknown; description?: unknown } | null;
const name = cleanName(body?.name);
if (!name) return ctx.json(openaiError("'name' is required.", 'invalid_request_error', 'name'), 400);
// Answered as a conflict rather than silently returning the existing one: POST here is an explicit
// "make a new collection", and quietly handing back someone else's is worse than refusing.
// The generation API's ?collection= is the find-or-create path.
if (await getCollectionByName(name)) {
return ctx.json(openaiError(`A collection named "${name}" already exists.`, 'invalid_request_error', 'name'), 409);
}
const description = typeof body?.description === 'string' ? body.description : null;
return ctx.json({ data: serializeCollection(await createCollection(name, description)) }, 201);
});
collectionsRouter.get('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id)) return ctx.json(notFound(id), 404);
const collection = await getCollection(id);
if (!collection) return ctx.json(notFound(id), 404);
return ctx.json({
data: { ...serializeCollection(collection), images: (await collectionImages(id)).map(serializeGeneration) },
});
});
collectionsRouter.patch('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
const body = (await ctx.req.json().catch(() => null)) as { name?: unknown; description?: unknown } | null;
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
const values: { name?: string; description?: string | null } = {};
if (body.name !== undefined) {
const name = cleanName(body.name);
if (!name) return ctx.json(openaiError("'name' must be a non-empty string.", 'invalid_request_error', 'name'), 400);
const existing = await getCollectionByName(name);
if (existing && existing.id !== id) {
return ctx.json(
openaiError(`A collection named "${name}" already exists.`, 'invalid_request_error', 'name'),
409,
);
}
values.name = name;
}
if (body.description !== undefined) {
values.description = typeof body.description === 'string' ? body.description : null;
}
if (Object.keys(values).length === 0) {
return ctx.json(openaiError('Nothing to update.', 'invalid_request_error'), 400);
}
const updated = await renameCollection(id, values);
return ctx.json({ data: updated ? serializeCollection(updated) : null });
});
/** Removes the collection and its memberships. The images are untouched and stay in the gallery. */
collectionsRouter.delete('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
await deleteCollection(id);
return ctx.json({ data: { id, deleted: true } });
});
/** Add images. Ones already in the collection are counted as added and change nothing. */
collectionsRouter.post('/:id/items', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
const body = (await ctx.req.json().catch(() => null)) as { generationIds?: unknown } | null;
const ids = Array.isArray(body?.generationIds) ? body.generationIds : null;
if (!ids || ids.some((value) => typeof value !== 'string' || !UUID_PATTERN.test(value))) {
return ctx.json(
openaiError("'generationIds' must be an array of uuids.", 'invalid_request_error', 'generationIds'),
400,
);
}
try {
const added = await addToCollection(id, ids as string[]);
return ctx.json({ data: { added, images: (await collectionImages(id)).map(serializeGeneration) } });
} catch {
// The foreign key is the check: an id that names no generation cannot be filed against one.
return ctx.json(
openaiError('One or more generationIds do not exist.', 'invalid_request_error', 'generationIds'),
400,
);
}
});
collectionsRouter.delete('/:id/items/:generationId', async (ctx) => {
const id = ctx.req.param('id');
const generationId = ctx.req.param('generationId');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
if (!UUID_PATTERN.test(generationId)) {
return ctx.json(openaiError('Not a valid generation id.', 'invalid_request_error', 'generationId'), 400);
}
await removeFromCollection(id, [generationId]);
return ctx.json({ data: { removed: generationId, images: (await collectionImages(id)).map(serializeGeneration) } });
});
+24
View File
@@ -0,0 +1,24 @@
import { createRouter } from '../create-router';
import { IMAGES_DIR } from '../env';
// `GET /files/:filename` — the generated image bytes.
//
// Was an unauthenticated top-level `/images/*` route with a durable `PUBLIC_URL`-prefixed link on the
// standalone service. Inside Officer it is mounted like every other route — the permission gate on the
// platform side already authenticated the request before the proxy forwarded it here, so this route
// itself needs no auth of its own; it only needs to refuse path traversal.
//
// Named `/files`, not `/images`, to avoid any ambiguity with the JSON API mounted at `/images` in
// hono.ts — `/images/:filename` here would sit one segment away from `/images/generations/:id`.
export const filesRouter = createRouter();
filesRouter.get('/:filename', async (ctx) => {
const name = ctx.req.param('filename');
// No traversal: the name is a filename, so anything with a separator is not one.
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
return ctx.text('Not found', 404);
}
const file = Bun.file(`${IMAGES_DIR}/${name}`);
if (!(await file.exists())) return ctx.text('Not found', 404);
return new Response(file, { headers: { 'cache-control': 'private, max-age=31536000, immutable' } });
});
+295
View File
@@ -0,0 +1,295 @@
import { randomUUID } from 'node:crypto';
import { extname, join } from 'node:path';
import {
addToCollection,
createRequest,
findOrCreateCollection,
getCollection,
getRequest,
listGenerations,
} from '../db';
import type { NewGeneration } from '../db';
import { createRouter } from '../create-router';
import { resolveForRequest } from '../providers/credentials';
import { validateImageEditRequest, validateImageRequest } from '../generations/validate';
import { INPUTS_DIR } from '../env';
import { pump } from '../generations/queue';
import { imagesResponse, isSettled, jobStatus } from '../generations/response';
import { serializeGeneration } from '../serialize';
export const imagesRouter = createRouter();
/**
* The longest a caller may ask us to hold a connection.
*
* Three timeouts sit in front of the answer and the innermost has to be the one that fires, or the
* caller gets a dropped connection instead of a status:
*
* nginx proxy_read_timeout 320s (data/nginx/custom/server_proxy.conf)
* Bun idleTimeout 255s (server.ts — 255 is Bun's hard ceiling, 256 is rejected)
* here MAX_WAIT_SECONDS 240s
*
* 240 rather than the 300 the documentation once promised: Bun cannot be told to wait longer than
* 255, so a 300s wait would be cut by our own runtime at 255 with no body — the exact failure this
* ordering exists to prevent. The ceiling is Bun's, not the proxy's, and raising it further means
* changing runtimes rather than configuration.
*
* Was 55 until the proxy was raised from its 60s default, which forced every caller to poll two to
* six times for a single 40s generation.
*/
const MAX_WAIT_SECONDS = 240;
/**
* Resolve `?collection=` to a collection, creating one if the value is a name we have not seen.
*
* A query parameter and not a body field, deliberately. The body is documented as OpenAI's, byte for
* byte, so that a client written against their API needs no changes — putting an Atelier-only key in
* it would make that false for the sake of one string.
*
* A uuid must already exist: it can only have come from us, so a miss is a real mistake and answering
* it by creating a collection named after a uuid helps nobody. A name is find-or-create, which is the
* whole point — "put this afternoon's work in one place" should not need a setup step first.
*/
async function resolveCollection(value: string | undefined) {
const name = value?.trim();
if (!name) return { collection: undefined };
if (UUID_PATTERN.test(name)) {
const collection = await getCollection(name);
return collection ? { collection } : { error: `No collection with id "${name}".` };
}
return { collection: await findOrCreateCollection(name) };
}
import { openaiError, UUID_PATTERN } from '../errors';
/**
* `POST /api/images/generations` — OpenAI's request body, verbatim.
*
* Answers 202 with a job rather than the image. OpenAI's own endpoint blocks for the length of the
* generation; holding that open for `n: 10` would tie a connection to work that takes minutes, and the
* callers here are agents that can poll. The finished job returns their exact response shape.
*/
imagesRouter.post('/generations', async (ctx) => {
const body = await ctx.req.json().catch(() => null);
const parsed = validateImageRequest(body);
if ('error' in parsed) {
return ctx.json(openaiError(parsed.error.message, 'invalid_request_error', parsed.error.param), 400);
}
const request = parsed.request;
// `?provider=` names one explicitly; otherwise the model decides, falling back to the default.
const resolvedProvider = await resolveForRequest({ providerRef: ctx.req.query('provider'), model: request.model });
if ('error' in resolvedProvider) {
const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error';
return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status);
}
const { provider, adapter } = resolvedProvider;
const resolved = await resolveCollection(ctx.req.query('collection'));
if (resolved.error) return ctx.json(openaiError(resolved.error, 'not_found', 'collection'), 404);
// The request's model wins over the provider's, so a provider can serve a model nobody configured.
const model = request.model ?? provider.model;
const requestId = randomUUID();
const count = request.n ?? 1;
// Everything except prompt/model/n — those have columns of their own, and duplicating them into
// options would give two answers to the same question the first time one was edited.
const { prompt, model: _model, n: _n, ...options } = request;
const rows: NewGeneration[] = Array.from({ length: count }, (_, index) => ({
requestId,
index,
status: 'queued' as const,
provider: adapter.name,
providerId: provider.id,
model,
prompt,
options,
}));
const created = await createRequest(rows);
// Filed at request time rather than on completion: the collection is a statement about what was
// asked for, and a generation that fails is still part of the afternoon it belonged to.
if (resolved.collection)
await addToCollection(
resolved.collection.id,
created.map((row) => row.id),
);
pump();
return ctx.json(
{
id: requestId,
status: 'queued',
created: Math.floor(Date.now() / 1000),
n: count,
...(resolved.collection ? { collection: { id: resolved.collection.id, name: resolved.collection.name } } : {}),
},
202,
);
});
/**
* `GET /api/images/generations/:id` — poll, or wait.
*
* `?wait=<seconds>` holds the request open until the job settles, up to 300s. Agents are the callers
* here and a long poll spares every one of them from writing the same retry loop; without the
* parameter it answers immediately.
*/
imagesRouter.get('/generations/:id', async (ctx) => {
const id = ctx.req.param('id');
// Checked before the query, because Postgres rejects a malformed uuid at the cast and the failure
// surfaces as a 500 — a client mistake reported as a server fault, outside the error envelope.
// A caller cannot tell "never existed" from "does not exist" and should not have to.
if (!UUID_PATTERN.test(id)) {
return ctx.json(openaiError(`No generation request with id "${id}".`, 'not_found'), 404);
}
const waitParam = Number(ctx.req.query('wait') ?? 0);
const waitMs = Math.min(Math.max(Number.isFinite(waitParam) ? waitParam : 0, 0), MAX_WAIT_SECONDS) * 1000;
const deadline = Date.now() + waitMs;
for (;;) {
const rows = await getRequest(id);
if (rows.length === 0) return ctx.json(openaiError(`No generation request with id "${id}".`, 'not_found'), 404);
const status = jobStatus(rows);
if (isSettled(status) || Date.now() >= deadline) {
if (!isSettled(status)) {
return ctx.json({ id, status, created: Math.floor(rows[0]!.createdAt.getTime() / 1000) }, 202);
}
const includeBase64 = (rows[0]!.options as { response_format?: string } | null)?.response_format === 'b64_json';
return ctx.json(await imagesResponse(rows, includeBase64), status === 'failed' ? 500 : 200);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
});
/** Everything generated, newest first. Not OpenAI's — the history is ours. */
imagesRouter.get('/', async (ctx) => {
const limit = Math.min(Math.max(Number(ctx.req.query('limit') ?? 50), 1), 200);
const rows = await listGenerations(limit);
return ctx.json({ data: rows.map(serializeGeneration) });
});
/** png / webp / jpg, per what the endpoint accepts. Kept from the upload so the provider sees a real name. */
const SAFE_EXTENSION = /^\.(png|jpe?g|webp)$/i;
/**
* `POST /api/images/edits` — OpenAI's multipart body, verbatim.
*
* Same job model as generations: 202 with an id, the finished job returns their response shape.
*
* The uploads are written to disk before the row is queued, and that ordering is the point. The work
* happens after the request has returned, so bytes held only in the request's memory would be gone by
* the time anything needed them — and an edit whose input has vanished cannot even be retried.
*/
imagesRouter.post('/edits', async (ctx) => {
let form: FormData;
try {
form = await ctx.req.formData();
} catch {
return ctx.json(openaiError('Request must be multipart/form-data.', 'invalid_request_error'), 400);
}
// Both `image` and `image[]` — the endpoint takes either, and clients send both spellings.
const uploads = [...form.getAll('image'), ...form.getAll('image[]')].filter(
(value): value is File => value instanceof File && value.size > 0,
);
if (uploads.length === 0) {
return ctx.json(openaiError("'image' is required.", 'invalid_request_error', 'image'), 400);
}
if (uploads.length > 16) {
return ctx.json(openaiError('At most 16 images may be provided.', 'invalid_request_error', 'image'), 400);
}
const maskValue = form.get('mask');
const mask = maskValue instanceof File && maskValue.size > 0 ? maskValue : undefined;
const fields: Record<string, string> = {};
for (const [key, value] of form.entries()) {
if (typeof value === 'string') fields[key] = value;
}
const parsed = validateImageEditRequest(fields);
if ('error' in parsed) {
return ctx.json(openaiError(parsed.error.message, 'invalid_request_error', parsed.error.param), 400);
}
const request = parsed.request;
const resolvedProvider = await resolveForRequest({ providerRef: ctx.req.query('provider'), model: request.model });
if ('error' in resolvedProvider) {
const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error';
return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status);
}
const { provider, adapter } = resolvedProvider;
if (!adapter.edit) {
return ctx.json(openaiError(`Adapter "${adapter.name}" cannot edit images.`, 'invalid_request_error'), 400);
}
const store = async (file: File) => {
const extension = SAFE_EXTENSION.test(extname(file.name)) ? extname(file.name).toLowerCase() : '.png';
const name = `${randomUUID()}${extension}`;
await Bun.write(join(INPUTS_DIR, name), await file.arrayBuffer());
return name;
};
const inputImages = await Promise.all(uploads.map(store));
const maskImage = mask ? await store(mask) : null;
const resolved = await resolveCollection(ctx.req.query('collection'));
if (resolved.error) return ctx.json(openaiError(resolved.error, 'not_found', 'collection'), 404);
const model = request.model ?? provider.model;
const requestId = randomUUID();
const count = request.n ?? 1;
const { prompt, model: _model, n: _n, ...options } = request;
const created = await createRequest(
Array.from({ length: count }, (_, index) => ({
requestId,
index,
operation: 'edit' as const,
status: 'queued' as const,
provider: adapter.name,
providerId: provider.id,
model,
prompt,
options,
inputImages,
maskImage,
})),
);
if (resolved.collection)
await addToCollection(
resolved.collection.id,
created.map((row) => row.id),
);
pump();
return ctx.json(
{
id: requestId,
status: 'queued',
created: Math.floor(Date.now() / 1000),
n: count,
...(resolved.collection ? { collection: { id: resolved.collection.id, name: resolved.collection.name } } : {}),
},
202,
);
});
+111
View File
@@ -0,0 +1,111 @@
import { createProvider, deleteProvider, getProvider, getProviderByName, updateProvider } from '../db';
import { createRouter } from '../create-router';
import { encrypt } from '../crypto';
import { adapterByName } from '../providers';
import { adapterCatalogue, providerStatuses } from '../providers/credentials';
import { openaiError, UUID_PATTERN } from '../errors';
export const providersRouter = createRouter();
const notFound = (id: string) => openaiError(`No provider with id "${id}".`, 'not_found');
/**
* Providers are rows; adapters are code.
*
* `GET /api/providers` lists what is configured — several may share an adapter, which is the point.
* `GET /api/providers/adapters` lists what a new one can be built on.
*
* No response includes a key, in any form. Not masked either: a mask still leaks length and shape,
* and nothing needs it. The only questions are whether one is set and where it came from.
*/
providersRouter.get('/', async (ctx) => ctx.json({ data: await providerStatuses() }));
providersRouter.get('/adapters', (ctx) => ctx.json({ data: adapterCatalogue() }));
providersRouter.post('/', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
const adapterName = typeof body.adapter === 'string' ? body.adapter : '';
const adapter = adapterByName(adapterName);
if (!adapter) {
return ctx.json(openaiError(`Unknown adapter "${adapterName}".`, 'invalid_request_error', 'adapter'), 400);
}
const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : adapter.defaultModel;
// Named after what it is, unless the caller had a better idea. Two providers on one adapter need
// distinguishable names, and "OpenAI" alone stops being one the moment there are two.
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : `${adapter.label} · ${model}`;
if (await getProviderByName(name)) {
return ctx.json(openaiError(`A provider named "${name}" already exists.`, 'invalid_request_error', 'name'), 409);
}
const row = await createProvider({
name,
adapter: adapter.name,
model,
apiKeyEncrypted: typeof body.apiKey === 'string' && body.apiKey.trim() ? encrypt(body.apiKey.trim()) : null,
enabled: body.enabled === undefined ? true : Boolean(body.enabled),
isDefault: Boolean(body.isDefault),
});
return ctx.json({ data: await providerStatuses(), created: row.id }, 201);
});
providersRouter.patch('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getProvider(id))) return ctx.json(notFound(id), 404);
const body = (await ctx.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
const values: Record<string, unknown> = {};
// Undefined means "leave alone" and null means "clear" — the UI sends `enabled` on its own when
// toggling, and a naive assignment would blank the credential every time someone flipped a switch.
if (body.apiKey !== undefined) {
if (body.apiKey === null || body.apiKey === '') values.apiKeyEncrypted = null;
else if (typeof body.apiKey === 'string') values.apiKeyEncrypted = encrypt(body.apiKey.trim());
else return ctx.json(openaiError("'apiKey' must be a string or null.", 'invalid_request_error', 'apiKey'), 400);
}
if (body.model !== undefined) {
if (typeof body.model !== 'string' || !body.model.trim()) {
return ctx.json(openaiError("'model' must be a non-empty string.", 'invalid_request_error', 'model'), 400);
}
values.model = body.model.trim();
}
if (body.name !== undefined) {
if (typeof body.name !== 'string' || !body.name.trim()) {
return ctx.json(openaiError("'name' must be a non-empty string.", 'invalid_request_error', 'name'), 400);
}
const clash = await getProviderByName(body.name.trim());
if (clash && clash.id !== id) {
return ctx.json(
openaiError(`A provider named "${body.name.trim()}" already exists.`, 'invalid_request_error', 'name'),
409,
);
}
values.name = body.name.trim();
}
if (body.enabled !== undefined) values.enabled = Boolean(body.enabled);
if (body.isDefault !== undefined) values.isDefault = Boolean(body.isDefault);
if (Object.keys(values).length === 0)
return ctx.json(openaiError('Nothing to update.', 'invalid_request_error'), 400);
await updateProvider(id, values);
return ctx.json({ data: await providerStatuses() });
});
/** Removes the configuration. Images it made keep their adapter and model as text and are unaffected. */
providersRouter.delete('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getProvider(id))) return ctx.json(notFound(id), 404);
await deleteProvider(id);
return ctx.json({ data: await providerStatuses() });
});
+11
View File
@@ -0,0 +1,11 @@
import { Hono } from 'hono';
/**
* Request-scoped state shared by every router.
*
* Empty for now. Fields land here as middleware starts setting them, so that a route reading
* `ctx.get(...)` is typed by the same declaration that the middleware writing it is checked against.
*/
export type HonoVariables = {};
export const createRouter = () => new Hono<{ Variables: HonoVariables }>();
+68
View File
@@ -0,0 +1,68 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_DIR, projectEnv } from './env';
/**
* At-rest encryption for provider API keys.
*
* The keys are the one genuinely dangerous thing Atelier stores. Everything else in the database is a
* prompt or a timestamp; a leaked provider key is someone else's bill. So they are encrypted in the
* column rather than sitting in plaintext next to them, and the encryption key lives outside Postgres
* — a database dump on its own decrypts nothing.
*
* AES-256-GCM: authenticated, so a tampered ciphertext fails to decrypt rather than yielding garbage
* that gets sent to a provider as a credential.
*/
const KEY_FILE = join(DATA_DIR, 'secret.key');
/**
* Generated on first use rather than demanded as configuration.
*
* Requiring ATELIER_SECRET_KEY would mean the server refuses to start until someone generates 32 random
* bytes by hand, and the predictable outcome of that is a memorable passphrase padded to length. A
* generated key in a 0600 file beside the data is stronger than the one a human would have typed.
*/
function loadKey(): Buffer {
const configured = projectEnv('ATELIER_SECRET_KEY');
if (configured) {
const key = Buffer.from(configured, 'base64');
if (key.length !== 32) throw new Error('ATELIER_SECRET_KEY must be 32 bytes, base64-encoded.');
return key;
}
if (existsSync(KEY_FILE)) return Buffer.from(readFileSync(KEY_FILE, 'utf-8').trim(), 'base64');
const key = randomBytes(32);
writeFileSync(KEY_FILE, key.toString('base64'), { mode: 0o600 });
chmodSync(KEY_FILE, 0o600);
console.log(`[crypto] generated a new encryption key at ${KEY_FILE}`);
return key;
}
let cached: Buffer | undefined;
const key = () => (cached ??= loadKey());
/** `iv:tag:ciphertext`, all base64. Self-describing, so nothing has to remember a layout. */
export function encrypt(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key(), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
return [iv.toString('base64'), cipher.getAuthTag().toString('base64'), encrypted.toString('base64')].join(':');
}
export function decrypt(payload: string): string {
const [iv, tag, data] = payload.split(':');
if (!iv || !tag || !data) throw new Error('Malformed ciphertext');
const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf-8');
}
/** `sk-abc…wxyz` — enough to recognise which key is set, not enough to use it. */
export function maskKey(plaintext: string): string {
if (plaintext.length <= 11) return '•'.repeat(plaintext.length);
return `${plaintext.slice(0, 6)}…${plaintext.slice(-4)}`;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
+133
View File
@@ -0,0 +1,133 @@
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { db } from '../db';
import { generations } from '../generations/schema';
import { collectionItems, collections } from './schema';
export type Collection = typeof collections.$inferSelect;
export type CollectionWithCount = Collection & { itemCount: number; coverImagePath: string | null };
/**
* Every collection, with how many images it holds and one to show for it.
*
* Counted in the query rather than by loading the memberships and calling `.length`: the list screen
* asks for this on every render, and the alternative reads every row of every collection to produce
* a number.
*/
export async function listCollections(): Promise<CollectionWithCount[]> {
const rows = await db
.select({
id: collections.id,
name: collections.name,
description: collections.description,
createdAt: collections.createdAt,
updatedAt: collections.updatedAt,
itemCount: sql<number>`count(${collectionItems.generationId})::int`,
// The newest image that actually has a file — a failed generation makes a poor cover.
coverImagePath: sql<string | null>`
(select g.image_path from ${collectionItems} ci
join ${generations} g on g.id = ci.generation_id
where ci.collection_id = ${collections.id} and g.image_path is not null
order by g.created_at desc limit 1)
`,
})
.from(collections)
.leftJoin(collectionItems, eq(collectionItems.collectionId, collections.id))
.groupBy(collections.id)
.orderBy(desc(collections.updatedAt));
return rows;
}
export async function getCollection(id: string): Promise<Collection | undefined> {
const [row] = await db.select().from(collections).where(eq(collections.id, id)).limit(1);
return row;
}
export async function getCollectionByName(name: string): Promise<Collection | undefined> {
const [row] = await db.select().from(collections).where(eq(collections.name, name)).limit(1);
return row;
}
export async function createCollection(name: string, description?: string | null): Promise<Collection> {
const [row] = await db.insert(collections).values({ name, description }).returning();
return row!;
}
/**
* Find by name, or make it.
*
* `onConflictDoUpdate` rather than select-then-insert: two requests naming the same new collection
* arrive together often — that is exactly what "put this afternoon's work in one place" produces —
* and the check-then-act version loses that race and fails one of them on the unique constraint.
*/
export async function findOrCreateCollection(name: string): Promise<Collection> {
const [row] = await db
.insert(collections)
.values({ name })
.onConflictDoUpdate({ target: collections.name, set: { updatedAt: new Date() } })
.returning();
return row!;
}
export async function renameCollection(id: string, values: { name?: string; description?: string | null }) {
const [row] = await db
.update(collections)
.set({ ...values, updatedAt: new Date() })
.where(eq(collections.id, id))
.returning();
return row;
}
/** The memberships go with it; the images do not. */
export async function deleteCollection(id: string): Promise<void> {
await db.delete(collections).where(eq(collections.id, id));
}
/** Adding something already in the collection is a no-op, not an error. */
export async function addToCollection(collectionId: string, generationIds: string[]): Promise<number> {
if (generationIds.length === 0) return 0;
const rows = await db
.insert(collectionItems)
.values(generationIds.map((generationId) => ({ collectionId, generationId })))
.onConflictDoNothing()
.returning({ generationId: collectionItems.generationId });
await db.update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));
return rows.length;
}
export async function removeFromCollection(collectionId: string, generationIds: string[]): Promise<void> {
if (generationIds.length === 0) return;
await db
.delete(collectionItems)
.where(and(eq(collectionItems.collectionId, collectionId), inArray(collectionItems.generationId, generationIds)));
await db.update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));
}
/** The images in a collection, newest first. */
export async function collectionImages(collectionId: string) {
return db
.select({ generation: generations, addedAt: collectionItems.addedAt })
.from(collectionItems)
.innerJoin(generations, eq(generations.id, collectionItems.generationId))
.where(eq(collectionItems.collectionId, collectionId))
.orderBy(desc(generations.createdAt))
.then((rows) => rows.map((row) => row.generation));
}
/** Which collections a generation is in — what the "add to collection" control needs to render state. */
export async function collectionsForGenerations(generationIds: string[]): Promise<Record<string, string[]>> {
if (generationIds.length === 0) return {};
const rows = await db
.select({ generationId: collectionItems.generationId, collectionId: collectionItems.collectionId })
.from(collectionItems)
.where(inArray(collectionItems.generationId, generationIds));
const map: Record<string, string[]> = {};
for (const row of rows) (map[row.generationId] ??= []).push(row.collectionId);
return map;
}
+46
View File
@@ -0,0 +1,46 @@
import { pgTable, uuid, text, timestamp, index, primaryKey } from 'drizzle-orm/pg-core';
import { generations } from '../generations/schema';
/**
* A named set of images — an afternoon's work, an icon suite, a client.
*
* `name` is unique, and that is load-bearing rather than tidy: the generation API accepts a
* collection by name and creates it when it does not exist, so without the constraint a second
* request with the same name would quietly start a second collection and split the batch in two.
*/
export const collections = pgTable('collections', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
/**
* Membership, as a join table rather than a column on `generations`.
*
* An image belongs to as many collections as it earns: the icon that came out of Tuesday's session is
* in "tuesday" and also in "shipped", and removing it from one must not touch the other. A
* collection_id column would force a choice at generation time and make re-filing a destructive edit.
*
* Both sides cascade. Deleting a collection removes its memberships and NOT its images — a collection
* is a way of looking at generations, not a container that owns them. Deleting a generation removes
* its memberships, since a row pointing at nothing is only a way to crash a join later.
*/
export const collectionItems = pgTable(
'collection_items',
{
collectionId: uuid('collection_id')
.notNull()
.references(() => collections.id, { onDelete: 'cascade' }),
generationId: uuid('generation_id')
.notNull()
.references(() => generations.id, { onDelete: 'cascade' }),
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// The composite key is what makes adding an image twice a no-op instead of a duplicate.
primaryKey({ columns: [table.collectionId, table.generationId] }),
index('collection_items_generation_idx').on(table.generationId),
],
);
+72
View File
@@ -0,0 +1,72 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
// The `atelier` Postgres database — separate from officer_db, on purpose. See PLUGIN.md.
//
// `ATELIER_POSTGRES_URL` rather than officer's own `OFFICERDEV_POSTGRES_URL`/`POSTGRES_URL`: this
// plugin talks to a different database on (most likely) the same Postgres server, and the platform's
// own connection string must never be reused here by accident — the standalone service's
// `atelier_db/src/env.ts` had a whole enforcement mechanism for exactly this mistake ("a development
// process cannot reach the production database"), which no longer applies once this runs as one
// process under one PM2 entry, but the principle — don't let this plugin's queries land on the
// platform's own tables — still does.
const POSTGRES_URL = process.env.ATELIER_POSTGRES_URL;
if (!POSTGRES_URL) {
throw new Error(
'ATELIER_POSTGRES_URL environment variable is required — set it in the platform .env to the ' +
'`atelier` Postgres database this plugin should use. See plugins/atelier/PLUGIN.md.',
);
}
/**
* One client per PROCESS, not one per module evaluation — see the standalone repo's original comment
* (kept because the reasoning still holds under PM2's own occasional module reloads): a fresh pool per
* reload abandons the previous one's open sockets, and a query in flight when the swap happens never
* resolves and never releases its connection.
*/
const globalForDb = globalThis as unknown as { __atelierPgClient?: ReturnType<typeof postgres> };
const client = (globalForDb.__atelierPgClient ??= postgres(POSTGRES_URL, {
max: 10,
idle_timeout: 30,
connect_timeout: 10,
connection: {
statement_timeout: 15_000,
},
}));
export const db = drizzle(client, { schema });
/**
* Block until Postgres answers, or give up after `timeoutMs`.
*
* `postgres()` is lazy — nothing fails at import when the database is not up yet — so startup work
* that queries fires once, fails, and is swallowed by whatever `.catch()` it was written with. The
* restart sweep that fails interrupted rows (`failInterrupted`) is exactly that kind of work.
*/
export async function waitForDatabase(timeoutMs = 30_000): Promise<boolean> {
const started = Date.now();
let announced = false;
for (;;) {
try {
await client`select 1`;
if (announced) console.log('[atelier:db] Postgres is up');
return true;
} catch (error) {
if (Date.now() - started >= timeoutMs) {
console.error(
`[atelier:db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`,
error instanceof Error ? error.message : error,
);
return false;
}
if (!announced) {
console.log('[atelier:db] waiting for Postgres…');
announced = true;
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
+99
View File
@@ -0,0 +1,99 @@
import { and, asc, desc, eq, inArray } from 'drizzle-orm';
import { db } from '../db';
import { generations } from './schema';
import type { GenerationStatus } from './schema';
export type Generation = typeof generations.$inferSelect;
export type NewGeneration = typeof generations.$inferInsert;
export async function createGeneration(values: NewGeneration): Promise<Generation> {
const [row] = await db.insert(generations).values(values).returning();
return row!;
}
export async function getGeneration(id: string): Promise<Generation | undefined> {
const [row] = await db.select().from(generations).where(eq(generations.id, id)).limit(1);
return row;
}
export async function listGenerations(limit = 50): Promise<Generation[]> {
return db.select().from(generations).orderBy(desc(generations.createdAt)).limit(limit);
}
/** Every image of one request, in the order it was asked for. This is what polling a job reads. */
export async function getRequest(requestId: string): Promise<Generation[]> {
return db.select().from(generations).where(eq(generations.requestId, requestId)).orderBy(asc(generations.index));
}
export async function createRequest(rows: NewGeneration[]): Promise<Generation[]> {
return db.insert(generations).values(rows).returning();
}
/** The backlog, oldest first — what the queue picks up on a tick and after a restart. */
export async function claimableGenerations(limit: number): Promise<Generation[]> {
return db
.select()
.from(generations)
.where(eq(generations.status, 'queued'))
.orderBy(asc(generations.createdAt))
.limit(limit);
}
/**
* Move a row to `running`, but only from `queued`.
*
* The `and(... eq(status, 'queued'))` is the whole point: two workers reaching for the same row both
* update, and only the one that got there first matches. The loser sees no returned row and moves on,
* so a generation is never paid for twice.
*/
export async function markRunning(id: string): Promise<Generation | undefined> {
const [row] = await db
.update(generations)
.set({ status: 'running', startedAt: new Date() })
.where(and(eq(generations.id, id), eq(generations.status, 'queued')))
.returning();
return row;
}
export async function markSucceeded(
id: string,
values: {
imagePath: string;
width?: number | null;
height?: number | null;
hasAlpha?: boolean | null;
opaque?: boolean | null;
costUsd?: string | null;
usage?: Record<string, unknown> | null;
result?: Record<string, unknown> | null;
durationMs: number;
},
): Promise<void> {
await db
.update(generations)
.set({ ...values, status: 'succeeded', finishedAt: new Date() })
.where(eq(generations.id, id));
}
export async function markFailed(id: string, error: string, durationMs?: number): Promise<void> {
await db
.update(generations)
.set({ status: 'failed', error, durationMs: durationMs ?? null, finishedAt: new Date() })
.where(eq(generations.id, id));
}
/**
* Fail whatever was mid-flight when the process died.
*
* A row left `running` has no worker behind it — the only thing that would have finished it went away
* with the last process. Without this it stays running forever, and a caller polling it waits on
* something that will never answer.
*/
export async function failInterrupted(): Promise<number> {
const rows = await db
.update(generations)
.set({ status: 'failed', error: 'Interrupted by a server restart', finishedAt: new Date() })
.where(inArray(generations.status, ['running' satisfies GenerationStatus]))
.returning({ id: generations.id });
return rows.length;
}
+133
View File
@@ -0,0 +1,133 @@
import { pgTable, uuid, text, jsonb, integer, numeric, boolean, timestamp, index, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
/**
* The set of states a generation can be in. Closed, so it is a `check()` rather than free text —
* otherwise a status the code stopped writing sits in old rows with nothing structural saying it
* became illegal.
*/
export const GENERATION_STATUSES = ['queued', 'running', 'succeeded', 'failed'] as const;
export type GenerationStatus = (typeof GENERATION_STATUSES)[number];
/**
* Which provider endpoint made the row. Closed, so a check() rather than free text.
*
* One table for both rather than a second one for edits: everything that makes a generation worth
* recording — prompt, model, cost, duration, what landed on disk — is identical, and history that has
* to be read from two places is history that gets read from one.
*/
export const GENERATION_OPERATIONS = ['generate', 'edit'] as const;
export type GenerationOperation = (typeof GENERATION_OPERATIONS)[number];
/**
* One row per image we were asked to make, whatever became of it.
*
* The row is written when the request arrives, not when it succeeds: a generation that failed, or that
* a restart interrupted, is a thing that happened and cost time, and the prompt that produced it is
* worth as much as one that worked.
*
* ── Why the image is a path and not bytes ──
*
* The files live under ATELIER_DATA_DIR, outside the repo and outside Postgres. They are served
* unauthenticated as ordinary images, so they need to be a file a static route can hand to
* `new Response(Bun.file(...))` — not a column something has to decode first.
*/
export const generations = pgTable(
'generations',
{
/**
* Also the filename on disk. One id means one image, so a row can be turned into a served URL
* without a second lookup.
*/
id: uuid('id').primaryKey().defaultRandom(),
/**
* The request these images came from — `n: 4` is four rows sharing one requestId, and the job the
* caller polls. Per-image rows rather than an array column because cost, duration and failure are
* per image: one of four can fail, and a row that averaged them would say nothing true.
*/
requestId: uuid('request_id').notNull(),
/** Position within the request, so `data[]` comes back in the order it was asked for. */
index: integer('index').notNull().default(0),
operation: text('operation').notNull().default('generate').$type<GenerationOperation>(),
status: text('status').notNull().default('queued').$type<GenerationStatus>(),
/**
* The adapter and model, as text, denormalised on purpose.
*
* `providerId` below points at the configured provider, but that row can be renamed, repointed at
* another model, or deleted — and none of that may change what an existing image was made with.
* The row is a pointer to configuration; these two are the historical fact.
*/
provider: text('provider').notNull(),
model: text('model').notNull(),
/** The provider row this came from, when there was one. Null for rows made before providers existed. */
providerId: uuid('provider_id'),
prompt: text('prompt').notNull(),
/** Whatever else the caller asked for — size, quality, background. Provider-shaped, so opaque here. */
options: jsonb('options').$type<Record<string, unknown>>(),
/**
* For edits: the uploaded files this was derived from, as names under the inputs directory.
*
* Kept because an edit without its input is unreadable afterwards — the prompt alone does not say
* what was edited, and "make the hat blue" means nothing without the hat. Names rather than bytes,
* for the same reason the output is a file.
*/
inputImages: jsonb('input_images').$type<string[]>(),
maskImage: text('mask_image'),
/** Filename under the images directory. Null until the bytes exist. */
imagePath: text('image_path'),
// ── What actually landed on disk ──
//
// Measured from the written file, never echoed from the request. What was asked for and what
// arrived are exactly the two things that come apart, and the caller is an agent that cannot look
// at the image to tell the difference.
//
// `hasAlpha` and `opaque` are deliberately both here and they are not the same question.
// `hasAlpha` is whether the file carries an alpha channel; `opaque` is whether every pixel in it
// is fully opaque. A PNG with an alpha channel that the model never used is hasAlpha: true,
// opaque: true — which is a request for a transparent background that silently did not happen,
// and the single most consequential failure for an asset destined to be an app icon.
width: integer('width'),
height: integer('height'),
hasAlpha: boolean('has_alpha'),
opaque: boolean('opaque'),
/**
* Cost in USD. `numeric` rather than a float: money that goes through binary floating point comes
* back slightly wrong, and these get summed.
*/
costUsd: numeric('cost_usd', { precision: 12, scale: 6 }),
/** Raw usage the provider reported, so cost can be recomputed if the pricing we applied was wrong. */
usage: jsonb('usage').$type<Record<string, unknown>>(),
/** What the provider said back, minus the image bytes — `revised_prompt`, echoed size and quality. */
result: jsonb('result').$type<Record<string, unknown>>(),
durationMs: integer('duration_ms'),
error: text('error'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
startedAt: timestamp('started_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
},
(table) => [
check('generations_status_check', sql`${table.status} in ('queued', 'running', 'succeeded', 'failed')`),
check('generations_operation_check', sql`${table.operation} in ('generate', 'edit')`),
// The queue reads "what is still to do, oldest first" on every tick and after every restart.
index('generations_status_created_idx').on(table.status, table.createdAt),
// Every poll of a job is this lookup.
index('generations_request_idx').on(table.requestId, table.index),
],
);
+13
View File
@@ -0,0 +1,13 @@
// One `export *` per feature, and nothing else. What a feature exports is declared in its own
// index.ts, beside the code it describes.
//
// `provider_settings` is NOT here. It is legacy — the one-row-per-adapter shape `providers` replaced —
// and nothing in this sidecar reads or writes it any more (confirmed: no reference outside the
// standalone repo's own migrate-providers.ts, which did not move). Its table and its one leftover row
// stay in the `atelier` database, untouched. See PLUGIN.md.
export * from './generations';
export * from './collections';
export * from './providers';
// The connection itself, so callers do not reach past the package for it.
export { db, waitForDatabase } from './db';
+2
View File
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
+100
View File
@@ -0,0 +1,100 @@
import { and, asc, eq, ne } from 'drizzle-orm';
import { db } from '../db';
import { providers } from './schema';
export type Provider = typeof providers.$inferSelect;
export type NewProvider = typeof providers.$inferInsert;
export async function listProviders(): Promise<Provider[]> {
return db.select().from(providers).orderBy(asc(providers.name));
}
export async function getProvider(id: string): Promise<Provider | undefined> {
const [row] = await db.select().from(providers).where(eq(providers.id, id)).limit(1);
return row;
}
export async function getProviderByName(name: string): Promise<Provider | undefined> {
const [row] = await db.select().from(providers).where(eq(providers.name, name)).limit(1);
return row;
}
/**
* Which provider serves a request, in the order a caller would expect.
*
* 1. the model, if exactly one enabled provider offers it
* 2. the model, preferring the default when several do — two keys for the same model is an
* ordinary thing and the tie has to break somewhere stable
* 3. the default provider, for a model nothing is configured for
*
* Step 3 is deliberate rather than a 404. Vendors ship models faster than rows get added, and the
* request still names a model the adapter will happily pass along — refusing it would make Atelier
* the reason a brand-new model cannot be used.
*/
export async function resolveProviderForModel(model: string | undefined): Promise<Provider | undefined> {
if (model) {
const candidates = await db
.select()
.from(providers)
.where(and(eq(providers.model, model), eq(providers.enabled, true)))
.orderBy(asc(providers.createdAt));
const match = candidates.find((row) => row.isDefault) ?? candidates[0];
if (match) return match;
}
return defaultProvider();
}
export async function defaultProvider(): Promise<Provider | undefined> {
const [byFlag] = await db
.select()
.from(providers)
.where(and(eq(providers.isDefault, true), eq(providers.enabled, true)))
.limit(1);
if (byFlag) return byFlag;
// No flag set — the oldest enabled row, so a single-provider install needs no configuration at all.
const [oldest] = await db
.select()
.from(providers)
.where(eq(providers.enabled, true))
.orderBy(asc(providers.createdAt))
.limit(1);
return oldest;
}
export async function createProvider(values: NewProvider): Promise<Provider> {
const [row] = await db.insert(providers).values(values).returning();
if (row?.isDefault) await clearOtherDefaults(row.id);
return row!;
}
export async function updateProvider(id: string, values: Partial<NewProvider>): Promise<Provider | undefined> {
const [row] = await db
.update(providers)
.set({ ...values, updatedAt: new Date() })
.where(eq(providers.id, id))
.returning();
if (row?.isDefault) await clearOtherDefaults(id);
return row;
}
export async function deleteProvider(id: string): Promise<void> {
await db.delete(providers).where(eq(providers.id, id));
}
/**
* Exactly one default.
*
* Enforced by clearing the others on write rather than by a partial unique index, because the write
* that sets a new default would otherwise have to clear the old one first — two statements racing to
* leave zero defaults instead of two.
*/
async function clearOtherDefaults(keepId: string): Promise<void> {
await db
.update(providers)
.set({ isDefault: false })
.where(and(eq(providers.isDefault, true), ne(providers.id, keepId)));
}
+55
View File
@@ -0,0 +1,55 @@
import { pgTable, uuid, text, boolean, timestamp, index } from 'drizzle-orm/pg-core';
/**
* A configured connection to a model: adapter + model + credentials.
*
* ── Why this is a row and the adapter is not ──
*
* "OpenAI" is not a thing you can generate with. "OpenAI's gpt-image-1, with this key" is. The two
* differ in how often they change: an ADAPTER is code that learns a vendor's HTTP API and is written
* once; a PROVIDER is a decision — which model, whose key, is it on — and you make several of those
* per adapter and change them at runtime.
*
* So `openai + gpt-image-1` and `openai + dall-e-3` are two rows sharing one adapter and possibly one
* key. The previous design keyed configuration on the adapter name, which made that unsayable: there
* was exactly one OpenAI, and its model was a property of it rather than the point of it.
*
* Nothing here constrains `model` against the adapter. Vendors ship models faster than a list in this
* repo is updated, and refusing a new one on release day is a worse failure than passing it through
* and letting the vendor answer.
*/
export const providers = pgTable(
'providers',
{
id: uuid('id').primaryKey().defaultRandom(),
/** What a human calls it — "OpenAI · gpt-image-1". Unique, so it can be named in a request. */
name: text('name').notNull().unique(),
/** Which adapter drives it. Text, not an enum: adding an adapter must not be a schema change. */
adapter: text('adapter').notNull(),
model: text('model').notNull(),
/** AES-256-GCM via servers/crypto.ts. Never leaves the server and never appears in a response. */
apiKeyEncrypted: text('api_key_encrypted'),
enabled: boolean('enabled').notNull().default(true),
/**
* The one to use when a request names no provider and no model, or names a model nothing serves.
*
* A column rather than "the oldest row" because it is a decision the owner makes and should be
* able to change without deleting and recreating rows. Exactly one row should carry it; the
* queries below enforce that by clearing the others when one is set.
*/
isDefault: boolean('is_default').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// Resolving a request by model is the hot path: every generation does it once.
index('providers_model_idx').on(table.model, table.enabled),
],
);
+10
View File
@@ -0,0 +1,10 @@
// What drizzle-kit creates, and the runtime's view of the tables — in the SEPARATE `atelier` database,
// not officer_db. See PLUGIN.md for why this plugin keeps its own database rather than folding into
// officer_db's <appname>_-prefixed convention: both `collections` and `provider_settings` had rows on
// the machine this was extracted from, and losing someone's generated images to a schema decision is
// not a trade to make unattended.
//
// `provider_settings` is deliberately not exported here — see ./index.ts.
export * from './generations/schema';
export * from './collections/schema';
export * from './providers/schema';
+38
View File
@@ -0,0 +1,38 @@
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { OFFICER_ROOT } from '@@/data-path';
/**
* Where generated images and anything else durable live.
*
* `$OFFICER_ROOT/data/atelier/` — a sibling of the plugin's own directory, not inside it. A plugin
* directory is a git clone that an update replaces, so artefacts kept there would vanish on upgrade
* with nothing logging it. Same reasoning and same shape as `plugins/kiosk/artefacts.ts`, which solved
* this first.
*/
export const DATA_DIR = join(OFFICER_ROOT, 'data', 'atelier');
export const IMAGES_DIR = join(DATA_DIR, 'images');
/**
* Uploaded inputs for edits, kept separate from generated output.
*
* Only IMAGES_DIR is served: an edit's input is whatever the caller uploaded, and publishing it
* because it happens to sit next to the results is not a decision anyone made.
*/
export const INPUTS_DIR = join(DATA_DIR, 'inputs');
mkdirSync(IMAGES_DIR, { recursive: true });
mkdirSync(INPUTS_DIR, { recursive: true });
/**
* How many images may be in flight at OpenAI at once.
*
* A cap rather than unbounded parallelism: `n: 10` is ten HTTP requests, and several callers at once
* multiply that. The work is all awaited I/O, so the process stays responsive either way — the
* provider's rate limit is the real edge, and hitting it turns a slow job into a failed one.
*/
export const MAX_CONCURRENT_GENERATIONS = Number(process.env.ATELIER_MAX_CONCURRENCY ?? 3);
/** For `crypto.ts`'s locally-generated at-rest key. Optional escape hatch, same as the standalone service. */
export const projectEnv = (key: string): string | undefined => process.env[key];
+14
View File
@@ -0,0 +1,14 @@
/**
* OpenAI's error envelope, shared by every route.
*
* In one place because it is a contract, not a convenience. A client written against their API reads
* `err.error.message`, so an error that answers with a bare string — as the framework's own notFound
* and onError handlers did until 2026-08-16 — does not merely return the wrong status: it breaks the
* parse. A caller checking `type` sees nothing at all.
*/
export const openaiError = (message: string, type: string, param?: string) => ({
error: { message, type, param: param ?? null, code: null },
});
/** Ids come back to us from our own responses; anything else never named a job that could exist. */
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+52
View File
@@ -0,0 +1,52 @@
import sharp from 'sharp';
export type ImageFacts = {
width: number | null;
height: number | null;
hasAlpha: boolean | null;
opaque: boolean | null;
};
const UNKNOWN: ImageFacts = { width: null, height: null, hasAlpha: null, opaque: null };
/**
* Measure the file that was actually written.
*
* Read from disk rather than echoed from the request, because the request is what was asked for and
* this is what arrived — and those are precisely the two things that come apart. The caller is an
* agent that cannot look at the image, so anything it is not told, it cannot know.
*
* `hasAlpha` and `opaque` answer different questions and the gap between them is the point:
*
* hasAlpha: false no alpha channel at all
* hasAlpha: true, opaque: false transparency present and used
* hasAlpha: true, opaque: true an alpha channel the model never used
*
* The third is the failure worth catching. `background: "transparent"` is accepted, the job succeeds,
* and every pixel comes back fully opaque — which for an app icon is the difference between usable and
* a black square, and is invisible from both ends without this field.
*
* Never throws. A generation that produced real bytes must not be recorded as failed because the
* measurement of it did; nulls say "not known" and the image is still there.
*/
export async function inspectImage(path: string): Promise<ImageFacts> {
try {
const image = sharp(path);
const metadata = await image.metadata();
// stats() decodes the pixels — the only way to answer "is anything actually transparent", since
// the header can only say whether a channel exists. Skipped when there is no alpha channel at
// all, where the answer is already known and the decode would be waste.
const opaque = metadata.hasAlpha ? (await image.stats()).isOpaque : true;
return {
width: metadata.width ?? null,
height: metadata.height ?? null,
hasAlpha: metadata.hasAlpha ?? null,
opaque,
};
} catch (error) {
console.error(`[inspect] could not read ${path}:`, error instanceof Error ? error.message : error);
return UNKNOWN;
}
}
+144
View File
@@ -0,0 +1,144 @@
import { join } from 'node:path';
import { claimableGenerations, failInterrupted, getProvider, markFailed, markRunning, markSucceeded } from '../db';
import type { Generation } from '../db';
import { IMAGES_DIR, INPUTS_DIR, MAX_CONCURRENT_GENERATIONS } from '../env';
import { adapterByName } from '../providers';
import { keyForProvider } from '../providers/credentials';
import { inspectImage } from './inspect';
import type { ImageEditRequest, ImageRequest, UploadedImage } from '../providers';
/**
* The worker pool.
*
* Postgres holds the queue, not memory. A row is the job, so a restart loses nothing that was waiting
* and `failInterrupted()` can tell the caller the truth about what was mid-flight. The only in-memory
* state is how many workers are awake, which is exactly the state that should not survive a restart.
*/
let running = 0;
let sweeping = false;
/** Wake the pool. Safe to call on every enqueue and after every completion; it settles on its own. */
export function pump(): void {
if (sweeping) return;
sweeping = true;
void sweep().finally(() => {
sweeping = false;
});
}
async function sweep(): Promise<void> {
while (running < MAX_CONCURRENT_GENERATIONS) {
const free = MAX_CONCURRENT_GENERATIONS - running;
const candidates = await claimableGenerations(free);
if (candidates.length === 0) return;
let started = 0;
for (const candidate of candidates) {
// markRunning only transitions from 'queued', so two sweeps racing for one row cannot both win
// and the image is never paid for twice.
const claimed = await markRunning(candidate.id);
if (!claimed) continue;
started += 1;
running += 1;
void run(claimed).finally(() => {
running -= 1;
pump();
});
}
// Everything on offer was taken by someone else; nothing to do until the next enqueue.
if (started === 0) return;
}
}
async function run(row: Generation): Promise<void> {
const started = Date.now();
try {
const adapter = adapterByName(row.provider);
if (!adapter) throw new Error(`Unknown adapter "${row.provider}"`);
// The provider row is looked up by id, but the adapter and model come from the ROW's own columns:
// a provider renamed or repointed after this was queued must not change what gets generated.
const provider = row.providerId ? await getProvider(row.providerId) : undefined;
const apiKey = provider
? await keyForProvider(provider)
: await keyForProvider({ adapter: row.provider, apiKeyEncrypted: null, name: row.provider } as never);
if (!apiKey) throw new Error(`No API key for adapter "${row.provider}"`);
const result =
row.operation === 'edit'
? await runEdit(row, adapter, apiKey)
: await adapter.generate(
{ ...(row.options ?? {}), prompt: row.prompt, model: row.model, n: 1 } as ImageRequest,
apiKey,
);
const image = result.images[0];
if (!image) throw new Error('Provider returned no image');
// The row id is the filename: one lookup gets you from a served URL back to the prompt that made it.
const filename = `${row.id}.${image.format}`;
const path = join(IMAGES_DIR, filename);
await Bun.write(path, image.bytes);
// Measured after the write, from the file itself.
const facts = await inspectImage(path);
await markSucceeded(row.id, {
imagePath: filename,
...facts,
costUsd: adapter.costUsd(row.model, result.usage),
usage: result.usage ?? null,
result: { ...result.echoed, revised_prompt: image.revisedPrompt },
durationMs: Date.now() - started,
});
} catch (error) {
await markFailed(row.id, error instanceof Error ? error.message : String(error), Date.now() - started);
}
}
const MIME: Record<string, string> = {
'.png': 'image/png',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};
/** Read the uploads back off disk and hand them to the provider. */
async function runEdit(row: Generation, adapter: NonNullable<ReturnType<typeof adapterByName>>, apiKey: string) {
if (!adapter.edit) throw new Error(`Adapter "${adapter.name}" cannot edit images`);
const load = async (name: string): Promise<UploadedImage> => {
const file = Bun.file(join(INPUTS_DIR, name));
if (!(await file.exists())) throw new Error(`Input image ${name} is missing`);
const extension = name.slice(name.lastIndexOf('.')).toLowerCase();
return { filename: name, bytes: new Uint8Array(await file.arrayBuffer()), type: MIME[extension] ?? 'image/png' };
};
const images = await Promise.all((row.inputImages ?? []).map(load));
const mask = row.maskImage ? await load(row.maskImage) : undefined;
const request = { ...(row.options ?? {}), prompt: row.prompt, model: row.model } as ImageEditRequest;
return adapter.edit(request, images, mask, apiKey);
}
/**
* Called once at boot, before the pool starts.
*
* Rows left `running` have no worker behind them — whatever was going to finish them died with the
* last process. Left alone they stay running forever and a caller polling one waits on an answer that
* is never coming.
*/
export async function startQueue(): Promise<void> {
// Once per process, matching the standalone repo's own guard against `bun --hot` double-starting the
// sweep loop. This process is not hot-reloaded under PM2, but the guard is free and correct either way.
const globalForQueue = globalThis as unknown as { __atelierQueueStarted?: boolean };
if (globalForQueue.__atelierQueueStarted) return;
globalForQueue.__atelierQueueStarted = true;
const interrupted = await failInterrupted();
if (interrupted > 0) console.log(`[atelier:queue] failed ${interrupted} generation(s) interrupted by a restart`);
pump();
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test';
import type { Generation } from '../db';
import { imagesResponse, isSettled, jobStatus } from './response';
const row = (over: Partial<Generation> = {}): Generation =>
({
id: '11111111-1111-1111-1111-111111111111',
requestId: '22222222-2222-2222-2222-222222222222',
index: 0,
status: 'succeeded',
provider: 'openai',
model: 'gpt-image-1',
prompt: 'a voxel duck',
options: {},
imagePath: '11111111-1111-1111-1111-111111111111.png',
costUsd: '0.010000',
usage: {
total_tokens: 100,
input_tokens: 20,
output_tokens: 80,
input_tokens_details: { text_tokens: 20, image_tokens: 0 },
},
result: {},
durationMs: 4200,
error: null,
createdAt: new Date('2026-08-16T12:00:00Z'),
startedAt: new Date('2026-08-16T12:00:01Z'),
finishedAt: new Date('2026-08-16T12:00:05Z'),
...over,
}) as Generation;
describe('jobStatus', () => {
test('running beats queued', () =>
expect(jobStatus([row({ status: 'queued' }), row({ status: 'running' })])).toBe('running'));
test('queued while any is waiting', () => expect(jobStatus([row(), row({ status: 'queued' })])).toBe('queued'));
test('succeeded when all did', () => expect(jobStatus([row(), row()])).toBe('succeeded'));
test('failed when all did', () =>
expect(jobStatus([row({ status: 'failed' }), row({ status: 'failed' })])).toBe('failed'));
// The case n>1 exists for: three images made, one refused. Calling that "failed" discards work that
// was paid for; calling it "succeeded" hides a failure.
test('partial when some failed and some did not', () =>
expect(jobStatus([row(), row({ status: 'failed' })])).toBe('partial'));
});
describe('isSettled', () => {
test.each(['succeeded', 'failed', 'partial'] as const)('%s is settled', (s) => expect(isSettled(s)).toBe(true));
test.each(['queued', 'running'] as const)('%s is not', (s) => expect(isSettled(s)).toBe(false));
});
describe('imagesResponse', () => {
test('shapes an OpenAI response, with url populated', async () => {
const body = await imagesResponse([row()], false);
expect(body.created).toBe(Math.floor(new Date('2026-08-16T12:00:00Z').getTime() / 1000));
expect(body.data).toHaveLength(1);
// The divergence that is the point of Atelier: OpenAI never returns url for GPT image models.
expect(body.data[0]!.url).toMatch(/\/files\/11111111-1111-1111-1111-111111111111\.png$/);
expect(body.data[0]).not.toHaveProperty('b64_json');
});
test('omits failed images from data but keeps them in the atelier block', async () => {
const body = await imagesResponse([row(), row({ status: 'failed', imagePath: null, error: 'refused' })], false);
expect(body.data).toHaveLength(1);
expect(body.atelier.images).toHaveLength(2);
expect(body.atelier.status).toBe('partial');
expect(body.atelier.images[1]!.error).toBe('refused');
});
test('sums usage and cost across images', async () => {
const body = await imagesResponse([row(), row({ costUsd: '0.020000' })], false);
expect(body.usage.total_tokens).toBe(200);
expect(body.usage.output_tokens).toBe(160);
expect(body.atelier.cost_usd).toBe('0.030000');
});
test('reports revised_prompt when the provider rewrote it', async () => {
const body = await imagesResponse([row({ result: { revised_prompt: 'a cheerful voxel duck' } })], false);
expect(body.data[0]!.revised_prompt).toBe('a cheerful voxel duck');
});
});
+109
View File
@@ -0,0 +1,109 @@
import { join } from 'node:path';
import type { Generation } from '../db';
import { IMAGES_DIR } from '../env';
import { serializeGenerationImage } from '../serialize';
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'partial';
/**
* The status of a request, from the rows of its images.
*
* `partial` exists because `n` images can end differently: three succeed and one trips the content
* filter. Reporting that as `failed` throws away three images that exist and were paid for, and
* reporting it as `succeeded` hides a failure the caller should see.
*/
export function jobStatus(rows: Generation[]): JobStatus {
if (rows.some((row) => row.status === 'running')) return 'running';
if (rows.some((row) => row.status === 'queued')) return 'queued';
const failed = rows.filter((row) => row.status === 'failed').length;
if (failed === 0) return 'succeeded';
if (failed === rows.length) return 'failed';
return 'partial';
}
export const isSettled = (status: JobStatus) => status === 'succeeded' || status === 'failed' || status === 'partial';
/** `/files/<name>`, relative from this plugin's own mount. See serialize.ts for why. */
export const imageUrl = (row: Generation) => `/files/${row.imagePath}`;
/**
* OpenAI's `ImagesResponse`, built from our rows.
*
* Deliberate divergence from OpenAI's own shape, and the reason Atelier exists: `url` is always
* populated, including for the GPT image models, where OpenAI returns base64 only. `b64_json` is
* included only when the caller asked for it, so the common response stays small.
*/
export async function imagesResponse(rows: Generation[], includeBase64: boolean) {
const succeeded = rows.filter((row) => row.status === 'succeeded' && row.imagePath);
const data = await Promise.all(
succeeded.map(async (row) => {
const item: Record<string, unknown> = { url: imageUrl(row) };
const revised = (row.result as { revised_prompt?: string } | null)?.revised_prompt;
if (revised) item.revised_prompt = revised;
if (includeBase64) {
const bytes = await Bun.file(join(IMAGES_DIR, row.imagePath!)).arrayBuffer();
item.b64_json = Buffer.from(bytes).toString('base64');
}
return item;
}),
);
const first = rows[0];
const echoed = (first?.result ?? {}) as Record<string, unknown>;
const totalCost = rows.reduce((sum, row) => sum + Number(row.costUsd ?? 0), 0);
return {
created: Math.floor((first?.createdAt?.getTime() ?? Date.now()) / 1000),
data,
...(echoed.background ? { background: echoed.background } : {}),
...(echoed.output_format ? { output_format: echoed.output_format } : {}),
...(echoed.size ? { size: echoed.size } : {}),
...(echoed.quality ? { quality: echoed.quality } : {}),
usage: mergeUsage(rows),
// Not OpenAI's, and namespaced so it cannot collide with a field they add later.
atelier: {
id: first?.requestId,
status: jobStatus(rows),
model: first?.model,
provider: first?.provider,
cost_usd: totalCost > 0 ? totalCost.toFixed(6) : null,
duration_ms: rows.reduce((max, row) => Math.max(max, row.durationMs ?? 0), 0),
// Through the shared serializer, so a field here and the same field in /images cannot drift
// into two spellings again.
images: rows.map(serializeGenerationImage),
},
};
}
/** Sum the per-image usage back into one object shaped like OpenAI's. */
function mergeUsage(rows: Generation[]) {
const totals = { total_tokens: 0, input_tokens: 0, output_tokens: 0, text_tokens: 0, image_tokens: 0 };
for (const row of rows) {
const usage = row.usage as {
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
input_tokens_details?: Record<string, number>;
} | null;
if (!usage) continue;
totals.total_tokens += usage.total_tokens ?? 0;
totals.input_tokens += usage.input_tokens ?? 0;
totals.output_tokens += usage.output_tokens ?? 0;
totals.text_tokens += usage.input_tokens_details?.text_tokens ?? 0;
totals.image_tokens += usage.input_tokens_details?.image_tokens ?? 0;
}
return {
total_tokens: totals.total_tokens,
input_tokens: totals.input_tokens,
output_tokens: totals.output_tokens,
input_tokens_details: { text_tokens: totals.text_tokens, image_tokens: totals.image_tokens },
};
}
+180
View File
@@ -0,0 +1,180 @@
import type { ImageEditRequest, ImageRequest } from '../providers';
/**
* Validate a request body against OpenAI's `POST /v1/images/generations` schema.
*
* Every parameter they accept is accepted here, with their enums and their bounds. What is NOT done is
* rejecting a combination that only some models allow — `style` on `gpt-image-1`, `background` on
* `dall-e-3`. Those rules change per model and per release, and a stale copy of them here would refuse
* requests the provider would have served. The provider is the authority; its error comes back intact.
*/
const QUALITIES = ['auto', 'high', 'medium', 'low', 'hd', 'standard'] as const;
const RESPONSE_FORMATS = ['url', 'b64_json'] as const;
const OUTPUT_FORMATS = ['png', 'jpeg', 'webp'] as const;
const BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const;
const MODERATIONS = ['low', 'auto'] as const;
const STYLES = ['vivid', 'natural'] as const;
/** `auto`, one of the named sizes, or `WIDTHxHEIGHT` — which gpt-image-2 accepts arbitrarily. */
const SIZE_PATTERN = /^(auto|\d{2,5}x\d{2,5})$/;
export type ValidationError = { message: string; param?: string };
export function validateImageRequest(input: unknown): { request: ImageRequest } | { error: ValidationError } {
if (typeof input !== 'object' || input === null) {
return { error: { message: 'Request body must be a JSON object.' } };
}
const body = input as Record<string, unknown>;
const request: ImageRequest = { prompt: '' };
if (typeof body.prompt !== 'string' || body.prompt.length === 0) {
return { error: { message: "'prompt' is required and must be a non-empty string.", param: 'prompt' } };
}
request.prompt = body.prompt;
if (body.model !== undefined && body.model !== null) {
if (typeof body.model !== 'string') return { error: { message: "'model' must be a string.", param: 'model' } };
request.model = body.model;
}
if (body.n !== undefined && body.n !== null) {
if (typeof body.n !== 'number' || !Number.isInteger(body.n) || body.n < 1 || body.n > 10) {
return { error: { message: "'n' must be an integer between 1 and 10.", param: 'n' } };
}
request.n = body.n;
}
if (body.size !== undefined && body.size !== null) {
if (typeof body.size !== 'string' || !SIZE_PATTERN.test(body.size)) {
return { error: { message: "'size' must be 'auto' or a WIDTHxHEIGHT string.", param: 'size' } };
}
request.size = body.size;
}
if (body.output_compression !== undefined && body.output_compression !== null) {
const value = body.output_compression;
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100) {
return {
error: { message: "'output_compression' must be an integer between 0 and 100.", param: 'output_compression' },
};
}
request.output_compression = value;
}
if (body.partial_images !== undefined && body.partial_images !== null) {
const value = body.partial_images;
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 3) {
return { error: { message: "'partial_images' must be an integer between 0 and 3.", param: 'partial_images' } };
}
request.partial_images = value;
}
if (body.stream !== undefined && body.stream !== null) {
if (typeof body.stream !== 'boolean') {
return { error: { message: "'stream' must be a boolean.", param: 'stream' } };
}
// Accepted and ignored. Generation here is a job the caller polls, so there is no response stream
// to put partial images on; saying so beats pretending the flag did something.
request.stream = body.stream;
}
if (body.user !== undefined && body.user !== null) {
if (typeof body.user !== 'string') return { error: { message: "'user' must be a string.", param: 'user' } };
request.user = body.user;
}
const enums = [
['quality', QUALITIES],
['response_format', RESPONSE_FORMATS],
['output_format', OUTPUT_FORMATS],
['background', BACKGROUNDS],
['moderation', MODERATIONS],
['style', STYLES],
] as const;
for (const [param, allowed] of enums) {
const value = body[param];
if (value === undefined || value === null) continue;
if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) {
return { error: { message: `'${param}' must be one of: ${allowed.join(', ')}.`, param } };
}
(request as Record<string, unknown>)[param] = value;
}
return { request };
}
// ── Edits ──
//
// A separate validator, not a flag on the one above, because OpenAI's two endpoints genuinely differ:
// edits take `input_fidelity`, have no `style` or `moderation`, and their `quality` has no `hd`.
// Sharing one validator would mean accepting each endpoint's parameters on the other.
const EDIT_QUALITIES = ['auto', 'high', 'medium', 'low', 'standard'] as const;
const INPUT_FIDELITIES = ['high', 'low'] as const;
/** Fields arrive as form strings, so numbers and booleans come in as text. */
export function validateImageEditRequest(
fields: Record<string, string>,
): { request: ImageEditRequest } | { error: ValidationError } {
const request: ImageEditRequest = { prompt: '' };
if (!fields.prompt) {
return { error: { message: "'prompt' is required.", param: 'prompt' } };
}
request.prompt = fields.prompt;
if (fields.model !== undefined) request.model = fields.model;
if (fields.user !== undefined) request.user = fields.user;
const integers = [
['n', 1, 10],
['output_compression', 0, 100],
['partial_images', 0, 3],
] as const;
for (const [param, min, max] of integers) {
const raw = fields[param];
if (raw === undefined) continue;
const value = Number(raw);
if (!Number.isInteger(value) || value < min || value > max) {
return { error: { message: `'${param}' must be an integer between ${min} and ${max}.`, param } };
}
(request as Record<string, unknown>)[param] = value;
}
if (fields.size !== undefined) {
if (!SIZE_PATTERN.test(fields.size)) {
return { error: { message: "'size' must be 'auto' or a WIDTHxHEIGHT string.", param: 'size' } };
}
request.size = fields.size;
}
if (fields.stream !== undefined) {
if (fields.stream !== 'true' && fields.stream !== 'false') {
return { error: { message: "'stream' must be true or false.", param: 'stream' } };
}
request.stream = fields.stream === 'true';
}
const enums = [
['quality', EDIT_QUALITIES],
['response_format', RESPONSE_FORMATS],
['output_format', OUTPUT_FORMATS],
['background', BACKGROUNDS],
['input_fidelity', INPUT_FIDELITIES],
] as const;
for (const [param, allowed] of enums) {
const value = fields[param];
if (value === undefined) continue;
if (!(allowed as readonly string[]).includes(value)) {
return { error: { message: `'${param}' must be one of: ${allowed.join(', ')}.`, param } };
}
(request as Record<string, unknown>)[param] = value;
}
return { request };
}
+40
View File
@@ -0,0 +1,40 @@
import { createRouter } from './create-router';
import { openaiError } from './errors';
import type { HonoVariables } from './create-router';
import type { Hono } from 'hono';
import { imagesRouter } from './api/images';
import { providersRouter } from './api/providers';
import { collectionsRouter } from './api/collections';
import { filesRouter } from './api/files';
/**
* Every router this sidecar answers, and the prefix it answers on.
*
* Bare — no `/api` prefix of its own. `createSidecarProxy` (../api/router.ts, on the platform side)
* already strips `/api/atelier` before forwarding, so what arrives here is exactly the subpath: a
* request for `/api/atelier/images/generations` arrives as `/images/generations`.
*
* `ping` and `echo` from the standalone service are not here — they were smoke-test routes with no
* caller; the sidecar's own `/_health` (see index.ts) covers liveness instead.
*/
const MOUNTS: [string, Hono<{ Variables: HonoVariables }>][] = [
['/images', imagesRouter],
['/providers', providersRouter],
['/collections', collectionsRouter],
['/files', filesRouter],
];
export function buildHonoApp() {
const app = createRouter();
for (const [prefix, router] of MOUNTS) app.route(prefix, router);
app.notFound((ctx) => ctx.json(openaiError('Not found', 'not_found'), 404));
app.onError((error, ctx) => {
console.error('[atelier]', error);
return ctx.json(openaiError('Internal Server Error', 'server_error'), 500);
});
return app;
}
+96
View File
@@ -0,0 +1,96 @@
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { API_URL } from '@@/officer-url.mjs';
import { buildHonoApp } from './hono';
import { waitForDatabase } from './db';
import { startQueue } from './generations/queue';
// The officer-atelier sidecar. Owns the whole image-generation contract: providers, the generation
// queue, and the `atelier` Postgres database (kept separate from officer_db — see ../PLUGIN.md).
//
// The platform API (../api/router.ts) is a thin auth-gated forwarder holding no provider credentials
// of its own. This process binds a loopback port and reports it over the registration socket, exactly
// like offscale's sidecar; `createSidecarProxy` on the platform side learns the port from the
// `atelier:server` event and forwards `/api/atelier/*` here with the prefix stripped.
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probe.port;
probe.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const app = buildHonoApp();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Generation runs 20-60s and a caller may long-poll for up to MAX_WAIT_SECONDS (sidecar/api/images.ts)
// — the ceiling Bun allows, one below the ceiling rejected outright.
idleTimeout: 255,
// OpenAI's edits endpoint takes up to 16 images at 50MB each.
maxRequestBodySize: 1024 * 1024 * 900,
fetch(req, srv) {
const url = new URL(req.url);
// Liveness only — matches the shape every other HTTP sidecar in this codebase uses.
if (url.pathname === '/_health') return Response.json({ ok: true });
return app.fetch(req, srv);
},
});
console.log(`[atelier] listening on 127.0.0.1:${port}`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'atelier',
handles: ['atelier'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
// Tell the API where we're listening, so it can forward /api/atelier/* here.
connection.send({ type: 'atelier:server', port });
console.log(`[atelier] reported port ${port} to API`);
},
});
// Awaited, not fired and forgotten. postgres-js is lazy, so on a cold boot the first query loses a
// race with Postgres and the restart sweep (`failInterrupted`) — the only thing that corrects rows
// left `running` by the last process — would fail once and be swallowed silently.
void (async () => {
if (await waitForDatabase()) await startQueue();
else console.error('[atelier:queue] not started: no database');
})();
function shutdown(signal: string) {
console.log(`[atelier] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+127
View File
@@ -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',
}));
+21
View File
@@ -0,0 +1,21 @@
import { openaiAdapter } from './openai';
import type { ImageAdapter } from './types';
export * from './types';
/**
* Every adapter Atelier can speak — the CODE side.
*
* An adapter knows a vendor's HTTP API. It is written once and rarely changes. What model to use,
* whose key, and whether it is switched on are not its business: those are a `providers` row, made
* and edited at runtime. Several providers can share one adapter, which is the entire point — OpenAI
* with gpt-image-1 and OpenAI with dall-e-3 are two configurations of one piece of code.
*
* A list in one place, the way `hono.ts` keeps its mounts: adding an adapter is a new file plus a
* line here.
*/
export const ADAPTERS: ImageAdapter[] = [openaiAdapter];
export function adapterByName(name: string): ImageAdapter | undefined {
return ADAPTERS.find((adapter) => adapter.name === name);
}
+172
View File
@@ -0,0 +1,172 @@
import type { ImageAdapter, ImageEditRequest, ImageRequest, ImageUsage, ProviderResult, UploadedImage } from './types';
const GENERATIONS_ENDPOINT = 'https://api.openai.com/v1/images/generations';
const EDITS_ENDPOINT = 'https://api.openai.com/v1/images/edits';
/**
* USD per million tokens, from OpenAI's published pricing.
*
* A table rather than a computed rate, and one that is allowed to be incomplete: a model missing here
* yields a null cost, which is honest. Guessing a price for a model we do not know produces a number
* that looks authoritative and sums into a total that is wrong. Every row also stores its raw `usage`,
* so any cost here can be recomputed later if these figures move.
*/
const PRICING: Record<string, { textInput: number; imageInput: number; imageOutput: number }> = {
'gpt-image-1': { textInput: 5, imageInput: 10, imageOutput: 40 },
'gpt-image-1-mini': { textInput: 2, imageInput: 2.5, imageOutput: 8 },
'gpt-image-1.5': { textInput: 5, imageInput: 8, imageOutput: 32 },
};
/** Only the parameters OpenAI accepts, and only those the caller actually set. */
function body(request: ImageRequest, n: number): Record<string, unknown> {
const out: Record<string, unknown> = { prompt: request.prompt, model: request.model, n };
for (const key of [
'quality',
'size',
'response_format',
'output_format',
'output_compression',
'background',
'moderation',
'style',
'user',
] as const) {
if (request[key] !== undefined) out[key] = request[key];
}
return out;
}
export const openaiAdapter: ImageAdapter = {
name: 'openai',
label: 'OpenAI',
models: ['gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini', 'dall-e-3', 'dall-e-2'],
defaultModel: 'gpt-image-1',
async generate(request, apiKey, signal): Promise<ProviderResult> {
// One image per call, even when the caller asked for several. Each is its own row with its own
// cost and duration, and one failure out of four should not discard the three that worked.
const response = await fetch(GENERATIONS_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body(request, 1)),
signal,
});
return decodeImagesResponse(response, request.output_format, signal);
},
async edit(request, images, mask, apiKey, signal): Promise<ProviderResult> {
// multipart/form-data, because that is what the endpoint takes. Built rather than translated:
// this is a bridge, and every field goes across under the name OpenAI gave it.
const form = new FormData();
form.append('prompt', request.prompt);
form.append('n', '1');
// Repeated `image[]` — the wire form of an array of files, and what the API expects for the GPT
// image models. One image still goes as `image[]`; the endpoint accepts either.
for (const image of images) {
form.append('image[]', new Blob([image.bytes as BlobPart], { type: image.type }), image.filename);
}
if (mask) form.append('mask', new Blob([mask.bytes as BlobPart], { type: mask.type }), mask.filename);
for (const key of [
'model',
'quality',
'size',
'response_format',
'output_format',
'output_compression',
'background',
'input_fidelity',
'user',
] as const) {
const value = request[key];
if (value !== undefined) form.append(key, String(value));
}
// No content-type header: fetch sets it, with the multipart boundary that only it knows.
const response = await fetch(EDITS_ENDPOINT, {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}` },
body: form,
signal,
});
return decodeImagesResponse(response, request.output_format, signal);
},
costUsd(model, usage) {
const price = PRICING[model];
if (!price || !usage) return null;
const textTokens = usage.input_tokens_details?.text_tokens ?? usage.input_tokens ?? 0;
const imageTokens = usage.input_tokens_details?.image_tokens ?? 0;
const outputTokens = usage.output_tokens ?? 0;
const total =
(textTokens * price.textInput + imageTokens * price.imageInput + outputTokens * price.imageOutput) / 1_000_000;
return total.toFixed(6);
},
};
/**
* Turn OpenAI's ImagesResponse into bytes, whichever endpoint produced it.
*
* Shared by generate and edit because the response shape is identical — only the request differs.
*/
async function decodeImagesResponse(
response: Response,
requestedFormat: string | undefined,
signal?: AbortSignal,
): Promise<ProviderResult> {
if (!response.ok) {
const text = await response.text();
// Surface OpenAI's own message where there is one — "content policy violation" is worth keeping.
let detail = text.slice(0, 500);
try {
const parsed = JSON.parse(text) as { error?: { message?: string } };
if (parsed.error?.message) detail = parsed.error.message;
} catch {}
throw new Error(`OpenAI ${response.status}: ${detail}`);
}
const payload = (await response.json()) as {
data?: { b64_json?: string; url?: string; revised_prompt?: string }[];
usage?: ImageUsage;
background?: string;
output_format?: string;
size?: string;
quality?: string;
};
const format = payload.output_format ?? requestedFormat ?? 'png';
const images = await Promise.all(
(payload.data ?? []).map(async (item) => {
if (item.b64_json) {
return { bytes: Buffer.from(item.b64_json, 'base64'), format, revisedPrompt: item.revised_prompt };
}
// dall-e-2 with the default response_format hands back a URL that expires in an hour. Fetch it
// now: the whole point of Atelier is that the image outlives the request.
if (item.url) {
const bytes = new Uint8Array(await (await fetch(item.url, { signal })).arrayBuffer());
return { bytes, format, revisedPrompt: item.revised_prompt };
}
throw new Error('OpenAI returned neither b64_json nor url');
}),
);
return {
images,
usage: payload.usage,
echoed: {
background: payload.background,
output_format: payload.output_format,
size: payload.size,
quality: payload.quality,
},
};
}
+106
View File
@@ -0,0 +1,106 @@
/**
* The request Atelier accepts, which is OpenAI's `POST /v1/images/generations` body verbatim.
*
* Kept as their vocabulary rather than a translated one of our own: a caller that already knows the
* OpenAI API knows this, and a provider added later is the one that has to do the translating —
* which is where the knowledge of that provider already lives.
*/
export type ImageRequest = {
prompt: string;
model?: string;
n?: number;
quality?: 'auto' | 'high' | 'medium' | 'low' | 'hd' | 'standard';
size?: string;
response_format?: 'url' | 'b64_json';
output_format?: 'png' | 'jpeg' | 'webp';
output_compression?: number;
background?: 'transparent' | 'opaque' | 'auto';
moderation?: 'low' | 'auto';
style?: 'vivid' | 'natural';
stream?: boolean;
partial_images?: number;
user?: string;
};
/**
* OpenAI's `POST /v1/images/edits` body, minus the files themselves.
*
* A separate type rather than flags on ImageRequest, because the parameter sets genuinely differ:
* edits take `input_fidelity` and no `style` or `moderation`, its `quality` has no `hd`, and its
* model default is gpt-image-1.5 where generations default to dall-e-2.
*/
export type ImageEditRequest = {
prompt: string;
model?: string;
n?: number;
quality?: 'auto' | 'high' | 'medium' | 'low' | 'standard';
size?: string;
response_format?: 'url' | 'b64_json';
output_format?: 'png' | 'jpeg' | 'webp';
output_compression?: number;
background?: 'transparent' | 'opaque' | 'auto';
input_fidelity?: 'high' | 'low';
stream?: boolean;
partial_images?: number;
user?: string;
};
/** An uploaded file on its way to a provider. */
export type UploadedImage = { filename: string; bytes: Uint8Array; type: string };
/** OpenAI's usage object. Stored raw so a cost computed with the wrong price can be recomputed. */
export type ImageUsage = {
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
input_tokens_details?: { text_tokens?: number; image_tokens?: number };
};
/** One generated image, as the provider returned it. */
export type ProviderImage = {
/** Raw bytes. Providers hand back base64; decoding is theirs to do, so the queue only writes files. */
bytes: Uint8Array;
/** png / jpeg / webp — decides the extension on disk and the served content type. */
format: string;
revisedPrompt?: string;
};
export type ProviderResult = {
images: ProviderImage[];
usage?: ImageUsage;
/** Echoed back by the provider: the size, quality and background actually used. */
echoed: Record<string, unknown>;
};
/**
* An adapter: how to talk to one vendor's image API.
*
* Deliberately holds no key, no chosen model and no enabled flag. Those live in a `providers` row,
* because they are decisions rather than knowledge, and several rows share one adapter.
*/
export type ImageAdapter = {
readonly name: string;
/** For display — "OpenAI" rather than "openai", used to name providers built from it. */
readonly label: string;
/** Models this adapter is known to serve. Advisory — an unknown model is passed through. */
readonly models: readonly string[];
readonly defaultModel: string;
/**
* The key is passed in rather than read here: it can come from the database or the environment, and
* which one is a question about configuration, not about how to talk to this provider.
*/
generate(request: ImageRequest, apiKey: string, signal?: AbortSignal): Promise<ProviderResult>;
/**
* Edit. Optional: a provider that cannot do it says so by not having it, and the route answers 400
* rather than the queue discovering it after the row is written.
*/
edit?(
request: ImageEditRequest,
images: UploadedImage[],
mask: UploadedImage | undefined,
apiKey: string,
signal?: AbortSignal,
): Promise<ProviderResult>;
/** USD for the reported usage, or null when this model's price is not known. */
costUsd(model: string, usage: ImageUsage | undefined): string | null;
};
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, test } from 'bun:test';
import * as serializers from './serialize';
import type { Collection, CollectionWithCount, Generation } from './db';
const generation = (over: Partial<Generation> = {}): Generation =>
({
id: '11111111-1111-1111-1111-111111111111',
requestId: '22222222-2222-2222-2222-222222222222',
index: 0,
operation: 'generate',
status: 'succeeded',
provider: 'openai',
model: 'gpt-image-1',
prompt: 'a duck',
options: { size: '1024x1024' },
inputImages: null,
maskImage: null,
imagePath: '11111111-1111-1111-1111-111111111111.png',
width: 1024,
height: 1024,
hasAlpha: true,
opaque: false,
costUsd: '0.167075',
usage: null,
result: null,
durationMs: 40903,
error: null,
createdAt: new Date(),
startedAt: null,
finishedAt: null,
...over,
}) as Generation;
const collection = (over: Partial<CollectionWithCount> = {}): CollectionWithCount =>
({
id: '33333333-3333-3333-3333-333333333333',
name: 'OfficerAppIcons',
description: null,
createdAt: new Date(),
updatedAt: new Date(),
itemCount: 6,
coverImagePath: '11111111-1111-1111-1111-111111111111.png',
...over,
}) as CollectionWithCount;
/**
* One sample per serialiser.
*
* The test below fails if this map does not cover every function exported from ./serialize. That is
* the actual fix for the bug this file exists because of: the previous version tested the two
* generation serialisers by name, so when a collections payload started going out raw it sailed past
* a suite that was green. Now a new serialiser cannot be added without a case.
*/
const CASES: Record<string, () => unknown> = {
serializeGeneration: () => serializers.serializeGeneration(generation()),
serializeGenerationImage: () => serializers.serializeGenerationImage(generation()),
serializeCollection: () => serializers.serializeCollection(collection() as Collection),
serializeCollectionSummary: () => serializers.serializeCollectionSummary(collection()),
};
const exported = Object.entries(serializers)
.filter(([, value]) => typeof value === 'function')
.map(([name]) => name);
const CAMEL = /[a-z][A-Z]/;
describe('every serialiser', () => {
test('has a case in this file', () => {
expect(exported.filter((name) => !(name in CASES))).toEqual([]);
});
test.each(exported)('%s emits no camelCase keys', (name) => {
const output = CASES[name]!() as Record<string, unknown>;
expect(Object.keys(output).filter((key) => CAMEL.test(key))).toEqual([]);
});
test.each(exported)('%s emits no key ending in Id, At or Path', (name) => {
// The specific spellings that leaked: createdAt, updatedAt, itemCount, coverImagePath.
const output = CASES[name]!() as Record<string, unknown>;
expect(Object.keys(output).filter((key) => /(Id|At|Path|Count|Url)$/.test(key))).toEqual([]);
});
});
describe('shapes that must agree', () => {
test("the compact image form uses the full form's spellings", () => {
const full = Object.keys(serializers.serializeGeneration(generation()));
const compact = Object.keys(serializers.serializeGenerationImage(generation()));
expect(compact.filter((key) => !full.includes(key))).toEqual([]);
});
test('a collection summary is a collection plus counts', () => {
const base = Object.keys(serializers.serializeCollection(collection() as Collection));
const summary = Object.keys(serializers.serializeCollectionSummary(collection()));
expect(base.filter((key) => !summary.includes(key))).toEqual([]);
});
});
describe('values', () => {
test('cost stays a string, so totals do not round', () => {
expect(serializers.serializeGeneration(generation()).cost_usd).toBe('0.167075');
});
test('a row with no file has a null url rather than a broken one', () => {
expect(serializers.serializeGeneration(generation({ imagePath: null })).url).toBeNull();
});
test('an empty collection has a null cover, not a url to nothing', () => {
expect(serializers.serializeCollectionSummary(collection({ coverImagePath: null })).cover_url).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
import type { Collection, CollectionWithCount, Generation } from './db';
/**
* The one place a database row becomes JSON.
*
* Every API serialiser lives in this file — a structural choice, not tidiness: it is the one place
* that can enforce snake_case across every endpoint. See the standalone repo's test for the incident
* this guarded against (`serialize.test.ts`, ported below).
*
* Snake case throughout, because the response body is deliberately OpenAI-shaped and OpenAI is snake.
*/
/**
* `/files/<name>`, relative from this plugin's own mount — NOT an absolute `PUBLIC_URL` any more.
*
* The standalone service served images unauthenticated with a durable absolute URL; inside Officer the
* route is behind the permission gate like everything else, so there is no host to bake in and no
* reason to. An API caller already holding a bearer token re-fetches this path with the same
* `Authorization` header they used to get here. The Officer frontend (which cannot set that header on
* an `<img src>`) appends its own `?token=` — see `plugins/atelier/web/lib/api.ts` — the same escape
* hatch `FileViewer` and the music plugin already use for exactly this reason.
*/
const imageUrl = (row: Generation) => (row.imagePath ? `/files/${row.imagePath}` : null);
/** What every image reports about itself, wherever it appears. */
const measured = (row: Generation) => ({
width: row.width,
height: row.height,
// `has_alpha` is whether the file carries an alpha channel; `opaque` is whether every pixel in it is
// fully opaque. Both, because an alpha channel the model never used is the failure worth catching.
has_alpha: row.hasAlpha,
opaque: row.opaque,
cost_usd: row.costUsd,
duration_ms: row.durationMs,
});
/** The compact form, for `atelier.images[]` — no prompt, which at n:10 would repeat 32000 chars ten times. */
export const serializeGenerationImage = (row: Generation) => ({
id: row.id,
status: row.status,
url: imageUrl(row),
...measured(row),
error: row.error,
});
/**
* The full row, for history and collections.
*
* `prompt`, `options` and `input_images` are here deliberately and are not to be normalised or
* truncated: every disappointing result so far has been traced to what was asked for, and these three
* are what make that diagnosis possible after the fact.
*/
export const serializeGeneration = (row: Generation) => ({
id: row.id,
request_id: row.requestId,
index: row.index,
operation: row.operation,
status: row.status,
provider: row.provider,
model: row.model,
prompt: row.prompt,
options: row.options,
input_images: row.inputImages,
mask_image: row.maskImage,
image_path: row.imagePath,
url: imageUrl(row),
...measured(row),
error: row.error,
created_at: row.createdAt,
started_at: row.startedAt,
finished_at: row.finishedAt,
});
/**
* A collection, as the API returns it.
*
* Drizzle hands back camelCase because that is what the schema declares in TypeScript. Returning the
* row untouched is how a camelCase field would reach the wire in a snake_case API — this is where one
* becomes the other.
*/
export const serializeCollection = (row: Collection) => ({
id: row.id,
name: row.name,
description: row.description,
created_at: row.createdAt,
updated_at: row.updatedAt,
});
/** The list form, with the counts the index screen needs. */
export const serializeCollectionSummary = (row: CollectionWithCount) => ({
...serializeCollection(row),
item_count: row.itemCount,
cover_image_path: row.coverImagePath,
cover_url: row.coverImagePath ? `/files/${row.coverImagePath}` : null,
});