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
+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() });
});