PLUGIN.md and README.md

PLUGIN.md is the record: what moved, what changed on the way (authenticated image serving, the
secret-store OpenAI key fallback, provider_settings left behind), what stays running untouched (the
standalone service the mobile app still talks to), why there is no db/ directory, and what verification
did and deliberately did not cover.

README.md is the marketplace shop window — what it does, what it needs (its own Postgres database,
an OpenAI key), and an honest limitations section: two Ateliers can exist side by side right now, and
there is no per-user data model yet.
This commit is contained in:
2026-08-20 04:09:27 +00:00
parent 859fb5767c
commit 4bff81ab2a
2 changed files with 242 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
# Atelier — extracted from a standalone service into a plugin
**Status: extracted 2026-08-20, unattended overnight run.** Read `BLOCKERS.md` alongside this file —
it has the decisions made without being able to ask, in the order they were found. This file is what
moved, what didn't, and why; that one is what was uncertain.
Atelier is 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. It came from
`~/officer-dev/dockers/officer-atelier`, a standalone Bun/Hono/Postgres server predating the plugin
system, still running in production as `officer-atelier` (PM2, port 3002, `atelier.officer.dev`) — see
"What did not move" below before assuming it is safe to touch.
---
## What moved, and what changed on the way
**Nothing pointed the other direction.** Unlike offscale and music, a grep of the whole platform for
`atelier` found no reverse dependency — no widget, no relay, nothing importing from the standalone
service. It came out whole with no seam to design around, which is the exception rather than the rule
for this kind of extraction.
### The backend — `sidecar/`
Providers (adapter-shaped: `types.ts`, `openai.ts`, an `index.ts` that dispatches — OpenAI is the only
adapter today, and the point of the shape is that a second one is a new file, not a new screen), the
generation queue (Postgres-backed, `MAX_CONCURRENT_GENERATIONS` workers), collections, and the database
layer moved essentially unchanged. Three things did not:
1. **Images are served from `GET /files/:filename`, authenticated, not an unauthenticated `PUBLIC_URL`
route.** The standalone service served `/images/*` off Bun's own route table with no session,
because every image needed "a permanent URL" reachable by the mobile app and anything else on the
tailnet. Inside Officer that is not an option — see "What you may NOT do" in the kickoff prompt. The
platform's own `?token=` escape hatch (already used by `FileViewer/file-types.ts` and the music
plugin's `MusicPlayerHost` for exactly this — a browser cannot put an `Authorization` header on an
`<img src>`) covers it: the JSON API returns `/files/<name>` (relative, no host, no token — the
sidecar cannot know the platform's public origin any more and does not need to), and the frontend
appends its own token when rendering (`web/lib/api.ts`'s `fileUrl`).
2. **The OpenAI-key bootstrap fallback reads the secret store before `.env`.** `keyForProvider` still
prefers a provider row's own encrypted key; the fallback that used to be `process.env.OPENAI_API_KEY`
directly is now `getSecret('atelier-<adapter>-key')` first, `.env` second — `getSecret`, never
`getKey`, because a foreign credential must not be silently substituted with a random one. Set it
with `bun run secret:set atelier-openai-key`.
3. **`provider_settings` did not come along.** It is the one-row-per-adapter shape `providers` replaced
(see the standalone repo's own `providers/schema.ts` comment and `scripts/migrate-providers.ts`), and
grepping the server code found no caller anywhere except that migration script — which also did not
move. Its table and its one row stay in the `atelier` database, untouched. If a machine's migration
never ran, that is a real gap, but it is the standalone service's gap, not one this extraction
introduced.
`ping`/`echo` (smoke-test routes, no caller) did not move either — the sidecar's `/_health` covers
liveness, matching every other HTTP sidecar in this codebase.
### The database — stays separate, does not fold into officer_db
Decision 3 said to check whether `collections` and `provider_settings` were empty before folding them
into `officer_db`. Both had rows (2 and 1), so the `atelier` Postgres database stays separate — the
sidecar keeps its own connection (`sidecar/db/db.ts`), reading `ATELIER_POSTGRES_URL` from the platform
`.env`. **This is new territory for the plugin system**: `db/schema.ts` at a plugin's root is the
`officer_db`-prefixed-tables convention (`pushPluginSchema`, `<appname>_` prefix), and this plugin has no
such file — `discoverPlugins()` correctly reports `schema: null` for it, and the install flow's schema
step is simply skipped. Nothing in `src/servers/plugins/` needed to change for this, because "no
`db/schema.ts`" is already a valid state the discovery contract handles — a plugin with an internal,
self-managed database is indistinguishable from a plugin with no tables at all, as far as the platform
is concerned. Worth knowing before assuming every plugin's data lives in `officer_db`.
**Not set automatically.** `ATELIER_POSTGRES_URL` has no default — see `BLOCKERS.md` for why (mainly:
pointing it at the live production `atelier` database while that database's own standalone service is
still running risks two processes racing the same generation queue). The owner has to add it before
installing.
**Migrating into `officer_db` as `atelier_*`-prefixed tables is real follow-up work**, not done here —
five tables (`generations`, `collections`, `collection_items`, `providers`, and the dead
`provider_settings`, which should be dropped rather than migrated), real data, needs a human watching it.
### The frontend — three panels, plus a settings screen
`web/panels.ts` + `web/layout.ts`: Generate, Gallery (+ Lightbox), Collections — the ≈1,200 lines of
real work the brief identified, ported with the shadcn components Officer already ships
(`@/components/ui/*` — the standalone service's own `src/apps/frontend/components/ui/*` did not move,
per decision 1). Providers did not become a fourth panel:
**Providers is a platform Settings screen** (`Settings → Integrations → Atelier`,
`src/apps/officer-web/.../IntegrationsSettings/AtelierProviders.tsx`), per decision 2. Worth flagging
loudly: **`IntegrationsSettings/index.tsx` itself carries a comment, written five days earlier when email
was extracted, that says the opposite** — "a plugin contributes panels, not settings sections, so it
now lives inside the email workspace itself." Decision 2 was explicit and pre-made, so it was followed
anyway, and the contradiction is recorded in `BLOCKERS.md` rather than silently resolved either way.
`AtelierProviders.tsx` calls `/api/atelier/providers` over plain HTTP via `useClient()` — it imports
nothing from `plugins/atelier/`, so nothing here reaches back across the plugin boundary; it is exactly
the same relationship any authenticated frontend has with any authenticated backend.
Panel coordination is URL + one channel, never props (a panel component only ever receives `panelId`):
- Gallery's lightbox is `?selected=<id>`; Collections' detail is `?collection=<id>` — two different
names because both panels render on the same route at once and would otherwise collide.
- Generate bumps `useAtelierRefresh` (added to `src/workspaces/officerdev/src/channels.ts`, same
stable-bump shape as `useFilesRefresh`) when a job finishes; Gallery subscribes to it. This replaces
the standalone app's `onFinished` callback prop, which only worked because `App.tsx` lifted the
gallery's state above both tabs — not an option once they are independent panels.
The default layout is a fixed horizontal three-way split (Generate 26% / Gallery 48% / Collections
26%) rather than the standalone app's hash-based tabs — the `LayoutNode` type has no tab-group concept,
only `group` (split) and `panel`, so three panels means three panes. Users can resize or rearrange
freely; this is only the starting arrangement.
### `sharp` — promoted to a direct dependency
`generations/inspect.ts` needs it to measure width/height/alpha-channel/opacity of a written image —
load-bearing, not incidental: it is how a `background: transparent` request that silently produced an
opaque image gets caught, which the standalone repo's own comments call "the single most consequential
failure for an asset destined to be an app icon." It was already resolved in `bun.lock` as an optional
transitive dependency of `next`; adding it as a direct dependency pulled in no new package versions —
verified by reading the lockfile diff before committing it.
---
## What did not move, and must not be touched
**The standalone `officer-atelier` service keeps running, unchanged.** Its own `ecosystem.config.cjs`
says plainly: "this is the instance the mobile app talks to, so it holds still." It serves
`https://atelier.officer.dev`, holds real data (87 generations, 2 collections, 5 providers on the
machine this was extracted from), and this extraction did not stop it, did not touch its nginx config,
and did not touch the mobile app. **Cutting the mobile app over to the plugin's route — or deciding
whether to at all — is the owner's call, not something this run made unilaterally.** Until that happens,
having both installed means two independent Atelier deployments existing side by side, which is
expected, not a bug.
---
## Verification
- `bunx tsgo`: clean.
- `bun test`: baseline was 1041 pass / 0 fail across 90 files; after this extraction, 1069 pass / 0 fail
across 92 files — the two new files are `sidecar/serialize.test.ts` and
`sidecar/generations/response.test.ts`, both ported from the standalone repo (URLs updated for the new
`/files/` scheme). No regressions, no file count drop.
- `discoverPlugins()` finds the plugin, the manifest validates, `schema: null` as expected (separate
database).
- The platform-side proxy router (`api/router.ts`) mounts and correctly answers `503 atelier sidecar
not available` with no sidecar running — same behaviour as offscale's.
- The sidecar's internal Hono app was exercised directly (`buildHonoApp()`, real HTTP requests, no port
bound) against the `atelier_dev` Postgres database: `/providers`, `/providers/adapters`,
`/collections`, `/images` all answered `200` with real rows; `/files/<missing>` answered `404`; an
unknown route answered the OpenAI-shaped `404` envelope. Old provider rows correctly failed to
decrypt (a fresh `secret.key` was generated for this checkout, as intended — a key that will not
decrypt is broken, not absent, and `keyForProvider` reports `configured: false` rather than crashing).
- **Not done**: a live PM2 install/enable/disable/uninstall/purge cycle through `/marketplace`, per the
runbook's own verification table. This machine's live `officer` process serves the owner's real
session and its own `plugins/` directory is the main checkout, not this worktree — installing through
it would write real rows into the shared `officer` Postgres database from code that only exists here.
That crosses into "affects shared systems beyond the local environment," so it was not done
unattended. The safer checks above cover the same ground read-only or against an isolated database.
---
## Open, for whoever picks this up
1. **Publish to `gitea.officer.dev/plugins/atelier` and register it in the marketplace.** Not done — see
`BLOCKERS.md` for why and the exact commands.
2. **`ATELIER_POSTGRES_URL` has to be set before install.** No default, on purpose.
3. **The `atelier` → `officer_db` migration**, described above, once someone can watch it happen.
4. **`assets/icon.png` and `assets/splash.png` are `[placeholder]`** flat swatches — see
`assets/PLACEHOLDER.md`.
5. **A real PM2 install/enable/disable/uninstall/purge cycle**, on a machine where doing so does not
touch the owner's live session or shared database.
6. **Deciding whether and when to cut the mobile app over** from `atelier.officer.dev` to the plugin's
route, and what happens to the standalone service afterward.
+73
View File
@@ -0,0 +1,73 @@
# Atelier
Image generation for an Officer server: a provider-shaped API over OpenAI's image endpoints, storing
what it makes so every image has a durable reference — a gallery you can browse, collections you can
organise, and a job an agent can poll rather than block on.
Atelier was a standalone service before this plugin existed
(`~/officer-dev/dockers/officer-atelier`, still running as `officer-atelier`, still what the mobile app
talks to). This plugin is a fresh extraction into Officer, not a replacement for it yet — see
`PLUGIN.md` before assuming the two are interchangeable.
## What you get
- **Generate** — a prompt, a model, size/quality/background, and a job you watch settle. `n` images
become `n` independently-tracked rows, so one failure out of four does not discard the three that
worked.
- **Gallery** — everything generated, newest first, with a lightbox: pan, zoom about the cursor,
checkerboard/dark/light backdrops for telling a transparent background from an opaque one that only
looks it.
- **Collections** — named sets of images. A generation request can name one by name and it is created on
first use; the gallery and the lightbox can file an image into one after the fact.
- **A provider-shaped backend.** "OpenAI" is code, written once; a *provider* is a row — which model,
whose key, on or off — and several can share one adapter. Today OpenAI is the only adapter; the point
of the shape is that a second one is a new file, not a redesign.
- **An OpenAI-compatible job API**, for agents. `POST /api/atelier/images/generations` accepts OpenAI's
request body verbatim and answers `202` with a job id rather than blocking for the length of the
generation — the caller polls `GET /api/atelier/images/generations/:id`, optionally with `?wait=` to
long-poll instead of retrying in a loop.
## Requirements
- An Officer server, version 1.0.0 or later.
- A **separate Postgres database**, and its own environment variable: `ATELIER_POSTGRES_URL`. Atelier's
tables (`generations`, `collections`, `collection_items`, `providers`) do not live in `officer_db` —
see `PLUGIN.md` for why, and set this before installing. There is no default.
- An OpenAI API key, set with `bun run secret:set atelier-openai-key`, or configured per-provider in
Settings → Integrations → Atelier, or (for an install that predates the secret store) as
`OPENAI_API_KEY` in `.env`.
## Install
From the Officer marketplace: find Atelier, install. That mounts `/api/atelier` and `/atelier`, starts
the `officer-atelier` sidecar under PM2, and connects it to whatever `ATELIER_POSTGRES_URL` points at —
which has to already have the schema (the standalone repo's own `bun db:push` creates it; this plugin
does not run migrations against a database it does not own the schema of).
Uninstalling stops the sidecar and unmounts the route. **It never touches your database** — Atelier's
tables and rows are not this plugin's to drop, since it does not own the schema push for them either.
## Permissions
One permission, `atelier`, grantable per role at read or write.
- **Read** lets an account use Generate, browse the Gallery and Collections, and see which providers are
configured (never a key, in any form — not even masked).
- **Write** additionally lets an account add, edit or delete providers, including their API keys. A
provider's key authorises real spend on someone's OpenAI account, so treat write here the way you
would treat write on any billing-adjacent surface.
Providers is not a panel — it is a platform Settings screen (Settings → Integrations → Atelier), because
it is owner-level configuration rather than a working view. See `PLUGIN.md` for why.
## Limitations, honestly
- **Two Ateliers can exist at once.** This plugin and the standalone service are independent — installing
this one does not migrate the standalone one's data, stop it, or repoint the mobile app. See
`PLUGIN.md`.
- **No per-user data.** Every account with `read` sees the same gallery and the same collections — there
is no ownership model yet, the same simple shape offscale shipped with first.
- **The database is separate from `officer_db`**, which means Atelier's tables are not covered by
`bun db:push`, are not part of the platform's usual backup story unless you back up the second
database too, and need their own connection string. Migrating them in is real, tracked follow-up work
— see `PLUGIN.md`.