From ba20155a09bcac9191274606b7700fcf7e7067e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 04:18:40 +0000 Subject: [PATCH] the store's README shows in the platform, not just on the website MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin's page in the Marketplace screen now renders the README the marketplace holds — the same text the public site shows, in the pane where somebody actually decides whether to install it. Fetched by the SERVER and proxied, though CORS would have allowed the browser to ask directly. An Officer client is routinely somewhere its server is not: a phone on the tailnet, a laptop with the store blocked. The server already had to reach the marketplace for the catalogue, so routing through it means the README appears in exactly the cases the plugin list did — rather than in a strictly smaller set, which is the kind of difference nobody reports and everybody notices. Separate request from the list, because the list endpoint deliberately carries no READMEs: three of them is ~38KB of markdown on the request that draws the tiles. This one fires when a plugin is opened. Absence renders nothing at all — no error, no empty state. A plugin without a README is normal and a store that is down must not put a failure message on a page whose job is installing things. Under `/plugins` so the existing permission covers it: reading the store and installing from it are the same privilege, and a second key would be a second thing to grant for no separate decision. tsgo clean, frontend builds, 873 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 --- src/servers/api/plugins/router.ts | 13 ++++++- src/servers/plugins/marketplace.ts | 30 ++++++++++++++ .../src/apps/Plugins/PluginDetail.tsx | 39 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 950a01cb..261304db 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -4,7 +4,7 @@ import { isSuperAdmin } from '../../super-admin'; import { mountPrefix } from '../../plugins/manifest'; import { snapshotPlugins } from '../../plugins/mount'; import { manualInstallHint, reportDependencies } from '../../plugins/os-deps'; -import { fetchCatalogue } from '../../plugins/marketplace'; +import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace'; import { iconUrl } from '../../app-store/assets'; import { installPlugin, @@ -40,6 +40,17 @@ pluginsRouter.use(async (ctx, next) => { * show the owner, and refusing the whole list because one plugin is malformed would hide the nine that * are fine. */ +// GET /plugins/catalogue/:appName/readme — the store's own description of a plugin, for the detail +// pane of something not installed yet. +// +// Under `/plugins` so the existing `plugins` permission covers it: reading the store is the same +// privilege as installing from it, and a second key would be a second thing to grant for no separate +// decision. +pluginsRouter.get('/catalogue/:appName/readme', async (ctx) => { + const readme = await fetchReadme(ctx.req.param('appName')); + return ctx.json({ readme }); +}); + pluginsRouter.get('/', async (ctx) => { const { states, broken } = await snapshotPlugins(); // Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 731ba8ed..08b29007 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -264,6 +264,36 @@ export async function catalogueEntry(appName: string): Promise e.appName === appName) ?? null; } +/** + * A plugin's README, from the marketplace's single-entry endpoint. + * + * Separate from `fetchCatalogue` because the list endpoint deliberately does not carry READMEs — three + * of them is already 38KB of markdown nobody has asked to read, on the request that draws the tiles. + * + * Fetched by the SERVER rather than by the browser, though CORS would allow either. An Officer client is + * routinely somewhere its server is not: a phone on the tailnet, a laptop with the store blocked. The + * server has the internet connection that already had to work for the catalogue itself, so routing + * through it means the README appears in exactly the cases the plugin list did. + * + * Null on any failure. A missing README is a section that does not render, never an error state on a + * page whose actual job is installing things. + */ +export async function fetchReadme(appName: string): Promise { + try { + const res = await fetch(`${MARKETPLACE_URL}/api/plugins/${encodeURIComponent(appName)}`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { accept: 'application/json' }, + }); + if (!res.ok) return null; + const body: unknown = await res.json(); + if (!body || typeof body !== 'object') return null; + const readme = (body as { readme?: unknown }).readme; + return typeof readme === 'string' && readme.length > 0 ? readme : null; + } catch { + return null; + } +} + /** Testing seam. The cache is process-lifetime, so a test that does not clear it leaks into the next. */ export function clearCatalogueCache(): void { cache = null; diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index 785283a7..f8e0fe40 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -1,5 +1,9 @@ import { Link, useSearchParams } from 'react-router'; import { resolveIcon } from '../../utils/resolve-icon'; +import { useQuery } from '@tanstack/react-query'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { useClient } from 'hooks/useClient'; import { usePlugins, type AvailablePlugin, @@ -234,9 +238,44 @@ const AvailableDetail = ({ + + ); +/** + * What the marketplace says about a plugin, in the pane where somebody decides whether to install it. + * + * Fetched separately from the plugin list on purpose: the list draws tiles and three READMEs is ~38KB of + * markdown that nobody has asked to read yet. This request happens when a plugin is actually opened. + * + * Absence renders NOTHING — no error, no empty state, no spinner left behind. A plugin without a README + * is normal, and the store being unreachable must not put a failure message on a page whose job is + * installing things. The list itself already degrades; this follows it. + * + * Styled with `chat-md` — the app's one markdown stylesheet (`styles/prose.css`) — rather than a new + * class. The same content should not have two looks in the same application. + */ +const Readme = ({ appName }: { appName: string }) => { + const { get } = useClient(); + const { data } = useQuery({ + queryKey: ['plugins', 'readme', appName], + queryFn: () => get<{ readme: string | null }>(`/plugins/catalogue/${appName}/readme`), + // The store caches for five minutes server-side; re-reading it on every tab focus would be a + // network round trip to redraw identical markdown. + staleTime: 5 * 60 * 1000, + retry: false, + }); + + if (!data?.readme) return null; + + return ( +
+ {data.readme} +
+ ); +}; + export const PluginDetail = () => { const { plugins, available, steps, result, running, run } = usePlugins(); const [params] = useSearchParams();