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