the store's README shows in the platform, not just on the website

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 04:18:40 +00:00
co-authored by Claude Opus 5
parent 53742a256f
commit ba20155a09
3 changed files with 81 additions and 1 deletions
+12 -1
View File
@@ -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
+30
View File
@@ -264,6 +264,36 @@ export async function catalogueEntry(appName: string): Promise<CatalogueEntry |
return all.find((e) => 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<string | null> {
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;
@@ -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 = ({
</div>
<ActionLog steps={steps} result={result} running={running} />
<Readme appName={plugin.appName} />
</div>
);
/**
* 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 (
<section className="chat-md mt-8 border-t border-duck-dark/10 pt-6">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.readme}</ReactMarkdown>
</section>
);
};
export const PluginDetail = () => {
const { plugins, available, steps, result, running, run } = usePlugins();
const [params] = useSearchParams();