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