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:
@@ -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.
|
||||
Reference in New Issue
Block a user