Files
atelier/PLUGIN.md
T
pastilhas ccfde75aae PLUGIN.md: record the real bundler build as verification
generatePluginsModule + rebuildFrontend, called directly with a synthetic install state, exercises
the same Bun.build + Tailwind path a real install performs — no server running, no port bound. It
compiled cleanly: 3 outputs, 2.2s, zero errors. Stronger than tsgo alone (catches bundler-side module
resolution and JSX/CSS processing), but does not confirm the result renders correctly in a browser —
noted as still open.
2026-08-20 04:11:31 +00:00

12 KiB

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).
  • The real production SPA bundle builds successfully with the plugin included. Called generatePluginsModule and rebuildFrontend directly (the same functions refreshPluginMounts calls during a real install) against a synthetic "atelier is installed and enabled" state — no server running, no port bound. Bun.build, with the real Tailwind plugin, compiled Plugins.gen.tsx, all three panels, and the new AtelierProviders settings section in 2.2s with zero errors: 3 outputs, no warnings. This is the same build step an install performs and exercises everything bunx tsgo cannot — module resolution through Bun's own bundler, JSX compilation, Tailwind processing — without needing a running server. What it does not prove is that the result renders correctly in a browser; see below.
  • 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.