plugins contribute dock tiles, and the doc says what is actually built

offscale had no dock tile: that field comes from the app store's catalogue, so a
plugin installed through the plugin system was reachable only by typing its url
or following the link on /plugins.

built at runtime rather than baked into the generated bundle, deliberately —
WHO sees a tile is a permission question, and a grant takes effect on the next
request rather than the next build. presentation comes from the manifest and the
route from mountPrefix, so there is one source for both, and  is the
plugin's first permission so the endpoint can filter a tile out for an account
that cannot reach the screen.

two sources for tiles today, because the app store still has its own catalogue.
one when it is rebuilt on this.

the doc now records the system as complete rather than half-stubbed, including
the three bugs the extraction found — the sidecar-before-mount ordering, the
missing tailwind, and the build that could delete its own shell — and what is
genuinely still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:31:54 +00:00
co-authored by Claude Opus 5
parent 8587ae20b7
commit 8b6cb34ae0
3 changed files with 90 additions and 37 deletions
+5 -1
View File
@@ -7,6 +7,7 @@ import type { UserRole } from 'officerdb';
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
import { capabilityAvailability } from '../../app-store/availability';
import { pluginDockManifests } from '../../plugins/mount';
// Two audiences, deliberately split.
//
@@ -33,6 +34,9 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
// capability the owner holds unconditionally still means nothing if its sidecar was never installed,
// and the owner is as subject to that as a member — see app-store/availability.ts.
const { unavailable, manifests } = await capabilityAvailability();
// Plugin tiles, alongside the app store's. Two sources today because the app store still has its own
// catalogue; when it is rebuilt on the plugin system this becomes one.
const pluginManifests = await pluginDockManifests();
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to
// remember to special-case. One shape for both audiences means one code path in the UI.
@@ -57,7 +61,7 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
* of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of
* privacy that lasts until someone opens the network tab.
*/
plugins: manifests.filter((m) => !m.capability || heldKeys.has(m.capability)),
plugins: [...manifests, ...pluginManifests].filter((m) => !m.capability || heldKeys.has(m.capability)),
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
+40
View File
@@ -162,3 +162,43 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
...(frontend ? { frontend } : {}),
};
}
/**
* Dock tiles for installed, enabled plugins, in the shape the shell already renders
* (`dockItemsFromPlugins`).
*
* Presentation comes from the manifest and the route from `mountPrefix`, so there is one source for both
* and nothing to keep in step. `capability` is the plugin's first permission when it has one, which is
* what lets `/api/user/capabilities` filter the tile out for an account that cannot reach the screen —
* a member must not be handed the manifest of a feature they may not use, even to hide it.
*
* Built here rather than baked into the generated frontend module on purpose: WHO sees a tile is a
* runtime question, because a grant takes effect on the next request rather than the next build.
*/
export async function pluginDockManifests(): Promise<
Array<{
sidecarId: string;
capability: string | null;
name: string;
icon?: string;
color: string;
rootRoute: string;
routes: string[];
}>
> {
const { states } = await snapshotPlugins();
return states
.filter((s) => s.install?.enabled && s.plugin.web)
.map(({ plugin }) => {
const prefix = mountPrefix(plugin);
return {
sidecarId: plugin.appName,
capability: plugin.manifest.permissions[0]?.key ?? null,
name: plugin.manifest.label,
icon: plugin.manifest.icon,
color: plugin.manifest.color,
rootRoute: prefix,
routes: [prefix],
};
});
}