Skip to content

Admin portal

The portal is a React single-page application bundled into the gateway jar by the Maven build and served at / behind the OIDC login. It holds no tokens; the session cookie is its only credential.

A fixed sidebar, grouped:

Group Item Destination
Gateway Overview /
Gateway Review queue /review — with the count of snapshots awaiting a decision, absent at zero
Gateway Marketplaces /marketplaces
Oversight Audit log /audit
Oversight Adoption /adoption
Configuration Vetting chain /vetting — shown only to administrators
Configuration Integrations → Webhooks /integrations/webhooks
Configuration Integrations → Audit sinks /integrations/sinks
Reference API reference /docs — the Scalar API reference, not a portal route
Reference Documentation this manual — leaves the portal, opens in a new tab
Reference Source code the project repository — leaves the portal, opens in a new tab

Oversight holds what is read; Configuration holds what is set. The outbound integrations — webhook subscribers and audit export sinks, the same kind of object — are two sections listed beneath Integrations on every page; /integrations opens the first. The earlier address /webhooks redirects to /integrations/webhooks, so existing links keep working.

Inside a marketplace, the sidebar lists that marketplace beneath Marketplaces with its sections — Review (with its own awaiting count), Snapshots, Activity and Settings — and the current section is the one highlighted entry. Leaving the marketplace closes the list; it is read from the address, never remembered. Snapshot contents is reached from a snapshot's card. Access tokens is a per-user concern, not estate-wide navigation — it is reached from the user menu, described next, and from the Overview page's Access tokens card; /tokens remains a resolvable address for existing bookmarks and links.

The sidebar footer states the running build's version — 0.3.0 — read from GET /api/v1/me. It is the build artifact's own version and nothing a deployment can set, so it cannot claim to be a version it is not; a gateway run from an exploded build carries no build information and the footer is then absent entirely rather than saying "unknown".

User menu

The header's breadcrumb sits opposite a menu named after the signed-in user (GET /api/v1/me). Opening it shows:

  • Signed in as the username.
  • Roles — one line per effective role the session holds, said in the reader's own terms rather than the API's wire values: admin — from your identity provider, approver of acme-skills — granted in the portal, and so on for a role sourced from configuration or from the local development authentication escape hatch. A session holding no role is told so, together with what it can still do (read the portal, manage its own access tokens), rather than shown an empty list. If the identity provider truncated the membership claim, a line says the role list may be incomplete.
  • The theme control — three-state, cycling system → light → dark: it defaults to system (follow the operating system's appearance), and its icon and label state the chosen mode — a monitor for system, a sun for light, a moon for dark. The choice is remembered in the browser (localStorage); system tracks the OS setting as it changes.
  • Your tokens, linking to Access tokens.
  • Sign out, which ends the browser session and returns to a fresh login. Under the skills-gateway.dev-insecure-auth development escape hatch there is no session to end, so the menu states that instead of offering a control that would appear to fail.

The menu only reports what the session holds; it never hides or disables a control by role — the server is the sole authority on what a request may do, and refuses what it must regardless of what the menu shows.

Conventions across pages

  • Every mutation reports through a toast — the action's success message, or the server's RFC 7807 detail on failure.
  • Destructive actions (Reject, Revoke) fire immediately; there are no confirmation dialogs.
  • Buttons disable while their mutation is in flight and swap to a progressive label ("Registering…", "Ingesting…").
  • A form's submit button is disabled until every required field is valid, and a muted hint beneath the form (bound to the fields with aria-describedby) states what it is waiting for. The client rules mirror the server's; whitespace never counts as a value. Rules the client cannot know — the URL scheme allowlist — stay server-side and surface as an error toast.
  • Every list renders a bare "Loading…" and, on failure, an error paragraph with role="alert".
  • Timestamps render in the reader's own locale and time zone. The exact instant is never lost: it stays in the <time datetime> attribute and on the hover tooltip, so an auditor can always recover the recorded value. A timestamp the portal cannot parse is shown verbatim rather than hidden, and an absent one renders as an em dash.

Authorization

The server refuses mutations, the audit surface and the snapshot contents reads to sessions without an applicable role — see Delegated administration; the portal surfaces those refusals as errors rather than hiding controls.

Access tokens are scoped per principal server-side regardless: you only ever see and revoke your own.


Overview

Route: / · Heading: Gateway Overview

The landing page. Three cards, each with counts derived client-side and one action leading elsewhere. Read-only — nothing here changes state.

Marketplaces — chips for the total marketplace count and how many snapshots are held, approved and rejected across all of them. When any snapshot is held, a secondary badge reads "{n} awaiting review" and links to the review queue. Action: Manage marketplaces.

Fetch ledger — a chip with how many entries the ledger holds: "{n} ledger entries", or "about {n} ledger entries" once the ledger is large enough that the server estimates rather than counts (see GET /api/v1/audit). It counts every entry: fetches, administrative actions and vetting bookkeeping. Action: Open audit log.

Access tokens — chips for active and revoked counts. Action: Manage tokens.

Data comes from GET /api/v1/marketplaces, GET /api/v1/tokens and GET /api/v1/audit; counts are computed in the browser, as there is no summary endpoint. While the queries are in flight the chips show an ellipsis.


Review queue

Route: /review · Heading: Review queue

Every snapshot awaiting a decision — held or revoked, not deleted — across every marketplace, newest first. One row each: Marketplace (a link to it), Commit (12 characters, a link that opens the snapshot on its marketplace's Review), State, Vetting outcome, and Ingested (when, and by whom).

The queue offers no Approve or Reject. A decision is made on the snapshot's card, below the verdicts, diff and contents it rests on; the queue is where the work is found, not where it is done. Empty: "Nothing awaits a decision."

Data comes from GET /api/v1/marketplaces, the same read the sidebar count uses, and one GET /api/v1/snapshots/{id}/vetting per row.


Marketplaces

Route: /marketplaces · Heading: Marketplaces

The working surface: registered upstreams and their quarantined, held and approved snapshots.

Register marketplace

The header action opens a dialog stating the constraint up front — "The gateway ingests the upstream default branch; the ref is not selectable."

Field Validation
Name ^[a-z0-9][a-z0-9_-]{0,62}$ — the hint reads "Up to 63 lowercase letters, digits, - and _; must start with a letter or digit. It is the /git/{name} path your clients clone."; a name that breaks it gets the API's own 422 wording as its field error
Clone URL Must be a valid URL. The scheme allowlist, and whether the gateway can read the upstream, are checked server-side. A refusal surfaces as an error toast that gives the reason and a next step.

Register stays disabled until both fields are valid; it enables as soon as the name matches the pattern and the clone URL parses.

If the clone URL already matches a registered marketplace — comparison ignores case, a trailing slash and a .git suffix — the dialog shows a warning naming the existing marketplace(s) and holds Register shut until Register anyway is ticked. This is a warning, not a block: the same upstream under a different name is a legitimate test setup, so the server still accepts it; the acknowledgement only makes the collision deliberate rather than silent.

Submits POST /api/v1/marketplaces; toasts Marketplace '{name}' registered. The server runs the identical duplicate-URL check independently against every registered marketplace, not only the list this dialog already had loaded, and returns what it found in the response's warnings; each one is shown as its own warning toast, so a collision this dialog's own check missed still reaches the operator.

Marketplace table

One sortable row per marketplace:

Column Contents
Name The marketplace name, a link to its page.
Source The detected forge (or the clone URL's host), with the clone URL beneath.
Latest snapshot The newest snapshot's state badge and its vetting outcome badge.
Upstream updated The last upstream update if known, else "—". Sortable.
Snapshots A count badge.
Awaiting How many snapshots await a decision, a link to the marketplace's Review; "—" at zero. Sortable.

The table is an index. It does not expand, and it offers no Approve, Reject or Ingest: ingestion is on the marketplace's header, and a decision is made on its Review, beside the evidence.

Empty state: "No marketplaces registered yet."


Marketplace detail

Routes: /marketplaces/{name} (Review), /marketplaces/{name}/snapshots, /marketplaces/{name}/activity, /marketplaces/{name}/settings · Heading: the marketplace name

Reached from a marketplace's name, from the review queue, or from the sidebar once inside. The page resolves {name} from the marketplace list already held in the browser — there is no per-marketplace endpoint — so an unknown name renders "Marketplace '{name}' not found." with a link back.

A marketplace is divided by what a visit is for, most frequent first:

Section Holds
Review (default) What awaits a decision — the snapshot cards and the decision
Snapshots What is served, and every earlier snapshot
Activity This marketplace's slice of the audit log
Settings The upstream facts and, for an administrator, the vetting chain and removal

On every section: the name, the clone URL, and one line stating what the facade serves — Serving {sha12}, or Not served — a clone is answered with 404 until a snapshot is approved. "Serving" is the marketplace read's servedSha, read from the published repository, not inferred from an approved snapshot existing — which is wrong after a withdrawal that left one approved and serves nothing.

When the last ingest failed, a further line reads Last ingest failed {when}: {reason}. The reason includes the root cause and a next step. It is the marketplace read's lastIngestReason, so it covers ingests by the sync sweep, a webhook or a push as well as by hand. A failed Ingest also refreshes the line, and the next successful ingest removes it.

Two actions, both outline buttons so that Approve stays the page's one primary control:

  • Ingest (POST /api/v1/marketplaces/{name}/ingest) toasts Snapshot {sha12} is {state} and opens what arrived on Review.
  • Connect a client opens the client wizard under the header.

Upstream

On Settings. Forge metadata captured at registration, best effort: Clone URL, Forge, Project, Description, Last upstream update, Last ingest (its outcome and time, or "never"), Registered, Registered by. Anything not captured shows "—".

Vetting chain (administrators)

On Settings, shown only to a session holding the admin role. Fed by GET /api/v1/marketplaces/{name}/vetting-chain, which the server refuses to anyone else — the switch that governs the chain, and the visibility of its settings, sit above the content they govern.

The card's description links to Vetting chain, which governs the default every setting here falls back to and lists every marketplace that departs from it.

It draws the same flow without verdicts, headed by how much of the chain runs and what is off:

2 of 3 vetters run · secret-scan off for this marketplace

Every configured vetter appears in the order it runs, each showing enabled or disabled, a chip naming where that state came from (this marketplace, global, default), and — for a vetter that is off — who switched it off. Opening a node gives the same source in full, the vetter's version, whether it is built in or external, when the deciding setting was last set, and the note left with it.

The node's detail also carries the switch: an optional reason field and a single control that enables or disables that vetter for this marketplace, calling PUT /api/v1/vetting/vetters/{name}/toggle. A per-marketplace setting overrides the global one. Every change is written to the audit ledger with the acting administrator, the vetter, the scope, the new state and the reason.

Switching vetters off narrows the evidence behind an approval; it never makes one automatic. What that means for a run is described in Switching a vetter off.

Beneath the drawing sit the two chain-level controls, fed by GET /api/v1/marketplaces/{name}/vetting-chain-settings.

When a vetter fails is a two-option control — Run every vetter or Stop after a failure — calling PUT /api/v1/vetting/chain-mode for this marketplace, with an optional reason. The hint beside it states the cost rather than leaving it to be discovered on a snapshot: stopping early spares the vetters after a failure, and it also means a reviewer no longer sees everything that is wrong with a snapshot in one pass, and that a run which stopped is blocked until it has been run again — even after the finding that stopped it is waived. The line ends with where the current mode came from.

The order they run in is a numbered list with a Move vetter up and a Move vetter down control on every row. The controls are ordinary buttons with names of their own, so the reordering is operable by keyboard and announced as what it does; there is no drag gesture to have an equivalent for. Movements are local until Save order writes the whole arrangement through PUT /api/v1/vetting/chain-order — one intended reordering is one audited change, not one per hop — and Discard changes puts it back. While an arrangement is unsaved the drawing above shows the proposed order and the list says it is not saved yet.

Remove marketplace (administrators)

On Settings, last, and shown only to a session holding the admin role. Remove name… opens a confirmation titled with the marketplace's name. The confirmation states what removal does: every approved snapshot is withdrawn, so the facade serves nothing under the name; tokens lose their grant to publish to it and keep their grant to fetch it; the record, the snapshots and the ledger are kept; and the name can be registered again as a new marketplace. It also asks for a Reason, which is recorded on the removal and on each withdrawal. Remove name is disabled until the reason holds something other than whitespace, and reads Removing… while the request is in flight.

The confirmation calls DELETE /api/v1/marketplaces/{name}. On success the portal goes to Marketplaces and a toast states how many approved snapshots were withdrawn. A refusal is shown as a toast with the server's reason, and the confirmation stays open.

A marketplace that the declarative estate declares is not offered for removal. The next reconciliation would register the name again as a new, empty marketplace. The button is disabled, and a line beneath it says to remove the declaration and restart the gateway first. The portal reads this from the last reconciliation report, GET /api/v1/estate. If that report cannot be read, the button stays available, because the server accepts the removal.

Review and Snapshots

One snapshot is open at a time in either section, so neither grows longer with every ingest; the rest are one line each, and Open on a line opens that snapshot's card in its place.

Section Holds Shown as
Review — Awaiting decision (n) Every held or revoked snapshot that is not deleted, newest first The newest open as a card; the rest one line each, with its delta. Empty: "Nothing awaits a decision."
Snapshots — Serving The snapshot whose commit the facade answers with Open by default. "Nothing is served." when servedSha is null — and, if a snapshot is still recorded approved, that it was withdrawn
Snapshots — Earlier snapshots (n) Everything else: rejected, deleted, approved but no longer served One line each

Two snapshots can await a decision at once. The open snapshot, its tab, its file and the line in that file are in the address, so a link to the evidence restores all four:

/marketplaces/acme?snapshot=41&tab=contents&path=plugins/hello/skills/hello/SKILL.md&line=12

line is optional. When present, the file opens at that line with the line focused. Choosing another file drops it.

An address naming a snapshot that no longer awaits a decision — an older link to one since approved — still opens it on Review, under Linked snapshot, with a link to it among the snapshots.

The card

Top to bottom, in this order on purpose:

  1. Identity — the short SHA, the state badge, when it was ingested and by whom, who decided it, and the retention control (Delete / Restore).
  2. The delta line — what is arriving, how big it is, and against what:

    1 skill added, 1 modified · 3 files · +48 −7 · vs 65f64622
    

    File and line counts come from GET /api/v1/snapshots/{id}/diff (against what is served); skill counts from GET /api/v1/snapshots/{id}/content-diff (against the last approved snapshot). Skill counts are left out when those two baselines differ, rather than mixing counts taken against two commits. The file and line counts are the gateway's own totals over the whole diff, however large. With nothing served the line says approving serves all of it. 3. The violation and, for an approved or revoked snapshot, the re-vetting panel. 4. Tabs — the evidence. Only the open tab loads.

    Tab Shows
    Vetting (default) The vetting report: chain outcome, verdicts, findings, waivers. Every path:line location is a link that opens the file at that line in Contents (not in the approve dialog, which stays on the decision)
    Contents The file explorer, inside the card
    Diff Changes since the last approved snapshot — plugins and skills marked added, changed, moved or removed, only what changed, from content-diff. With nothing approved yet it says there is no baseline. Below it, Files changed against the served commit lists every changed path from diff, 500 at a time, with Show N more, the totals over the whole diff, and each file's diff opened in place
    Inventory What the snapshot ships: one block per declared plugin with its source and description. Below them is one count per component kind the plugin has (skills, commands, agents, hooks, MCP servers and LSP servers), for example "1 skill · 4 agents · 3 hooks". Each count is collapsed and expands its own list in place. A hook row shows its trigger (the event, and the tool matcher when there is one), what it runs, and where it is declared. Read from GET /api/v1/snapshots/{id}/content
    Provenance Upstream URL and SHA, the served SHA, who decided it and when, and the closure of external plugin sources
  3. The decision — Approve (Re-approve for a revoked snapshot) and Reject, only for a snapshot awaiting one.

The decision is last because it rests on everything above it: an approval control is never shown above the evidence. When the gateway would refuse the approval — vetting blocked it, the minimum release age has not passed, or the four-eyes rule refuses you — Approve is disabled and the reason is on the card, rather than the press failing — inside the minimum release age the reason names when it opens, read from GET /api/v1/snapshots/{id}/release-age rather than the browser's clock, and Reject is never age-gated. Approve opens the review dialog; Reject fires immediately and toasts Snapshot {id} rejected.

When other snapshots await a decision, the card names them. Approving this one does not retire them: they stay held, and approving an older one afterwards would serve content older than this.

This is the review surface, and it works on held snapshots — inspecting a snapshot must not require serving it.

Approve dialog

Titled Approve snapshot {id}, it renders the vetting report for the snapshot. The confirm control (Confirm approval of snapshot {id}) is disabled for as long as the effective outcome is BLOCKED; there is no field to type past it. The way to enable it is to waive each blocking finding from the report itself. The server enforces the same rule independently, with 409, so the disabled button mirrors policy rather than replacing it.

Below the report, a Plugin names already in use section lists each plugin name the snapshot introduces that looks like one another marketplace already serves, with the manifest line declaring it and the incumbents it resembles. While any is uncovered the confirm control stays disabled. Each carries a Waive name collision button (accessible name Waive name collision for {name}) opening the same waiver form, offering Every plugin-name-collision finding in this snapshot as the only scope. The snapshot card does not shut Approve for a collision, since this dialog is where it is waived.

The dialog is also shut by the minimum release age, and says so in its own note: that one has no way past it in the portal at all, and needs none — it opens by itself at the stated time.

Confirming calls POST /api/v1/snapshots/{id}/approve with no body and toasts Snapshot {id} approved.

Waiving a finding

The report lists each verdict's findings as finding groups. A group is the findings of one rule on one line of identical content (the same git blob). It shows its first location and, under N more copies of the same content, the rest. Above the verdicts, Approval is blocked until each of these is waived lists every uncovered group, as {rule} at {path:line}, …, with the first three locations spelled out and the rest counted.

Each blocking group carries a button that opens an inline form beside it: Waive finding (accessible name Waive finding {rule} at {path:line}) for a single location, or Waive all N locations (Waive all N locations of {rule}) for a group.

Control Notes
Scope These N identical copies, in this snapshot (or This finding, in this snapshot) — the default, covering the group and nothing else; Every {rule} finding in this snapshot; and, for a single location, Every {rule} finding under {path}, in later snapshots too
Expires on date input, defaulting to 30 days out; must be in the future — the waiver lapses at the end of the chosen day
Justification required; whitespace does not count

Record waiver for {rule} stays disabled until the justification is non-blank and the expiry is still in the future, which is exactly what the server requires.

Record waiver for {rule} calls POST /api/v1/snapshots/{id}/waivers and toasts Waiver recorded for {rule}. The group is then struck through and badged waived by {approver} until {date} (or k of N waived when a wider waiver covers only some of its locations), and the outcome badge becomes vetting clear with waivers once nothing is left uncovered.

Below the verdicts, Accepted risks lists every waiver whose rule appears in this run — active, expired and revoked alike — with its rule, scope, justification, approver and expiry. An active one carries Revoke waiver {id}, which calls DELETE /api/v1/waivers/{id}.

Connect a client

Connect a client, in the header of every section, opens a wizard that composes for this marketplace everything a consumer needs — every URL derived from the address the browser is already on. It is a header action rather than the page's first panel because a consumer takes this step once, while the reviewer's work is on Review every visit.

When nothing is served, the header already says a clone is answered with 404; the wizard repeats it, adding that this is not a credential problem — a wrong or revoked token is answered with 401 — for anyone who opens it without reading the header.

Inside the wizard:

  1. Personal access token — the same show-once creation flow as the Access tokens page. The name defaults to {marketplace}-client and the control disables again if it is emptied or left whitespace. Expires is a button group — 7, 30 or 90 days, or no expiry — defaulting to 30 days rather than to never; the gateway's own cap (skills-gateway.tokens.max-ttl) is not exposed to the browser, so a longer choice than the deployment allows is refused by the server and the refusal is shown beneath the field. A token minted here is filled into the snippets only while the wizard stays open; closing it drops the value, and no previously issued token's value is ever shown.
  2. Store the credential — a git credential approve line for this host. This is the primary copy target, marked by a highlighted snippet box, because a consumer who copies one thing should copy this. Its copy control is the same corner icon button the other snippets use, so the three read as one family.
  3. Add the marketplace to Claude Code — claude plugin marketplace add {origin}/git/{name}.
  4. Clone directly — a plain git clone of the facade URL, with no token in it: the credential stored by step 2 authenticates it, and nothing lands in .git/config or shell history.

The remaining snippets have icon copy buttons. Until a token is minted the credential snippet carries the <YOUR_TOKEN> placeholder.

Vetting

The snapshot card's Vetting tab — the tab a card opens on — is fed by GET /api/v1/snapshots/{id}/vetting.

The chain flow

Above the per-vetter list, the chain is drawn as an ordered flow — one node per step, left to right:

Ingest → secret-scan → prompt-injection → Outcome → Approval

The order is the order the vetters ran, taken from the run's own recorded positions; for a snapshot the chain has not run against yet, it is the configured chain in the order it will run.

The flow never wraps: nodes narrow their names before the row breaks, and on a screen too narrow for the whole chain it scrolls sideways — faded at the edge it continues past — so the gate is never stranded on a line of its own, away from the result it follows.

Above the nodes, one sentence states the answer and where the chain stopped, so it can be read without opening anything:

Blocked at step 1 · secret-scan found 1 critical finding
Clear · 5 vetters, 0 findings
Clear with waivers · 2 findings accepted
Stopped at step 1 · secret-scan found 1 critical finding, so 2 later vetters
                    did not run — re-vet to see what they say

The last of those is the one a reader must not skim past: a chain that stopped early has a smaller verdict set, not a cleaner one, so the sentence says how many vetters never looked and what to do about it.

How to read a node:

Part Meaning
Stage label Where the node sits: Source, Step N for each vetter in run order, Result, Gate.
Name Ingest, a vetter's name, Outcome, Approval.
State word pass, warn, fail, error, pending, skipped (switched off), not reached (the chain stopped before it), not run (no verdict at all) for a vetter; clear, clear with waivers, blocked for the outcome; open or closed for the approval gate. The word is always present — colour never carries a state on its own.
Top edge The same state, as colour: the accent for a pass or warn, red for a fail or error, a plain hairline for anything that reached no conclusion. A second, scannable carrier of the word above it, never a replacement for it.
Finding count How many findings that vetter raised, when it raised any.
external chip The verdict comes from an operator-configured external service, not from a built-in vetter.
N waived chip How many of that vetter's findings an active waiver is suppressing. The vetter keeps the verdict it reached: an accepted risk is never redrawn as a pass.

The stage the chain arrives at — Result here, Gate on the marketplace chain — is drawn as a filled surface rather than an outline, so the end of the chain reads as the end.

Every node is a button. Activating one opens its detail:

  • A vetter — its verdict, its version, whether it is built in or external, its self-description, and its findings, with a waived one struck through and badged. A skipped node states that an administrator switched the vetter off for this marketplace and that the scope and reason are on the marketplace's vetting chain, which is administrator-only. A not reached node states that the chain stopped before it, that this is an absence rather than a result, and that a waiver on the finding which stopped the chain lets the next run get this far without standing in for the verdict this vetter never gave.
  • The outcome — how the aggregation reached its answer, what the vetters themselves recorded, which vetters are objecting, the vetters the run never reached, and the findings that still need a waiver.
  • Ingest and Approval — what the step is, and why the gate is where it is.

The flow is an overview; the per-vetter list below it is where findings are read side by side and a waiver is written next to the one being accepted. Both are always shown.

It shows the effective chain outcome badge and one block per vetter: an icon and badge for the verdict state, the vetter name, a one-line summary, and every finding as severity badge, rule id, path:line, and message. A vetter with nothing to report shows "Nothing found."

A finding an active waiver is suppressing is struck through and badged waived by {approver} until {date}; one that still blocks carries a Waive finding {rule} button. When the effective outcome only clears because of a waiver, the header adds the chain objected; active waivers are suppressing what it found. Blocking findings are also summarised as a single line naming what must be waived before approval unblocks. See Waiving a finding for the form and the Accepted risks list.

A snapshot the chain never ran against says so explicitly, and states that there is nothing to waive — a snapshot with no evidence cannot be approved at all.

A collapsed What these vetters can and cannot see disclosure lists each configured vetter and its self-description, so the limits of the heuristics are readable at the point of decision. The same section is embedded in the approve dialog.

Chain staleness

Enabling or disabling a vetter, or changing the chain's mode or order, re-runs nothing. For approved content the re-vetting sweep converges on it; for a snapshot still at the approval gate nothing does. So a snapshot ingested before a chain change can be approved on evidence a vetter in the chain today never produced.

The vetting section says so, against the evidence it qualifies:

State Shown
The run came from the chain in force Nothing. A marking on every snapshot would be noise.
The run came from a different chain A notice naming both chains — the one that produced the evidence and the one in force — and a Re-run the chain now button.
The run records no chain identity This run records no chain identity, so whether it matches the chain in force is unknown. No alarm and no button: the gateway does not know that anything is wrong.

A chain is described as vetter@version,…;mode=…, and — when an administrator has switched vetters off — ;disabled=[…]. A switched-off vetter stays named in the chain and is recorded on the run as a disabled verdict, so a toggle shows up in the disabled= part rather than by a name disappearing.

Approval is not blocked by this, and no configuration makes it block. The gateway states the fact and the reviewer decides, the same as for an override or a waiver. If they approve anyway, the ledger records snapshot-approved-on-superseded-chain naming both chains, beside the approval rather than instead of it.

Re-run the chain now calls POST /api/v1/snapshots/{id}/revet. For a held snapshot that is a refresh, not a re-vetting: the chain runs, the run is recorded, the snapshot stays held, and nothing is announced or retracted — there is nothing published to retract.

Re-vetting panel

Above the tabs, the snapshot card carries the re-vetting surface.

Snapshot Shown
State approved A Re-vet now button.
State revoked revoked by {revokedBy} on {revokedAt}, and an Already fetched by panel.
Anything else Nothing here — a held snapshot's evidence is refreshed from the chain staleness notice instead, which is where the reason to refresh it appears.

Re-vet now calls POST /api/v1/snapshots/{id}/revet and toasts what the run concluded: re-vetted clear, could not conclude, has a re-vetting violation; it is still published (warn mode), or revoked by a re-vetting violation (enforce mode). Why the snapshot was revoked is the card's own violation line.

Already fetched by lists every identity that received the snapshot's content through the facade, each with a fetch count and a last-fetch time, from GET /api/v1/snapshots/{id}/fetchers. It is only requested for a revoked snapshot. When nobody fetched it, the panel says so rather than showing an empty list. This read is privileged — admin or an approver of this marketplace — so the panel shows an error to a session holding neither.

The way back is on the marketplaces page: a revoked snapshot's approve control reads Re-approve and goes through the ordinary gate. See Re-vetting approved content.

Retention controls

Each snapshot card carries its retention state and the control that changes it:

Snapshot Shown
Not deleted, state held, rejected or revoked A Delete button.
Not deleted, state approved Nothing — an approved snapshot is served by the facade and the gateway refuses to delete it.
Deleted A destructive deleted badge, "restorable until {purgeAfter}", and a Restore button.

Delete calls DELETE /api/v1/snapshots/{id} and toasts Snapshot {id} deleted; it can be restored. Restore calls POST /api/v1/snapshots/{id}/restore and toasts Snapshot {id} restored. Both fire immediately, like every other mutation in the portal — deletion here is a reversible mark, not a purge.

The restore deadline is the real one

Once purgeAfter has passed, a compaction run removes the snapshot and its quarantine ref permanently and the card disappears. See Snapshot retention.

Empty state: "No snapshots yet."

Audit log

Below the snapshots, the marketplace's slice of the ledger: the entries GET /api/v1/audit?marketplace={name} returns, newest first, with the same verdict colouring as the Audit log page — a blocked verdict reads red here too. The server does the narrowing, so a quiet marketplace's entries show however many newer entries belong to others. Load older entries fetches the next page while there is one. A See the full ledger link goes to /audit. Empty state: "Nothing recorded against this marketplace yet."


Snapshot contents

Route: /marketplaces/:name/snapshots/:id/files · Heading: Snapshot contents

The reviewer's file explorer for one snapshot: exactly the commit it pins. The same explorer is the Contents tab of the snapshot card; this full-width route renders it on its own, and keeps working for links already sent.

The address is the feature. Approval is a two-person decision (see Approving snapshots), and this page is addressed so the first reviewer can send the second a link to a file rather than directions for finding it. The selected path is in the query string:

/marketplaces/acme/snapshots/41/files?path=plugins/hello/skills/hello/SKILL.md

Opening that address restores the same file, with the directories above it already open. Back and forward walk the files visited. Which directories are open is not in the address — it is derived from the selection and from what you have since toggled.

The page takes the whole window, and each pane scrolls on its own; the page itself does not scroll.

Left pane — the tree. The pinned commit, read one folder at a time (GET /api/v1/snapshots/{id}/tree). A folder's contents load when you open it. A folder with more than 500 entries shows the first 500 and a Show N more control, so every path of a snapshot of any size can be reached. Directories come before files. A file shows its size, or how it stands against what is served (added, modified, removed). A folder shows how many changed paths are beneath it, or how many files when none changed, so you can find the change without opening every folder. Paths this snapshot removes relative to what is served are listed and marked removed: a surface for reading a change that hid the deletions would be the wrong surface.

Above the tree, a count line gives the snapshot's true totals, for example "3261 files, 12 changed". Below that is a search over every path in the snapshot (GET /api/v1/snapshots/{id}/files?q=). A search matches any part of the full path, regardless of case. The matches replace the tree while the search is up, each shown by its full path, and the count reads "12 matching of 3261 files". Clearing the search gives back the folders you had open.

Right pane — the file. The selected blob (GET /api/v1/snapshots/{id}/file?path=), with a vs served toggle for that one file, read from GET /api/v1/snapshots/{id}/diff?path=. Files and the diff no longer compete for one box: switching to the diff leaves the tree where it is.

Findings in the file. The latest vetting run's findings (GET /api/v1/snapshots/{id}/vetting) are read onto the files they locate:

  • In the tree, a file that carries findings shows its highest severity and the count, for example "high · 3". Directories carry no marker.
  • Above the file, "N findings in this file" lists each finding with its severity, rule, line, vetter and message. An entry with a line moves to that line. A finding whose location has no line, or whose line is beyond the part of a truncated file that is shown, is listed there too.
  • In the file, a file is shown as numbered lines. Each line a finding locates is tinted by its highest severity, and each finding on it is written out beneath the line: severity, vetter, rule and message, and "waived by … until …" when an active waiver covers it. Several findings on one line are all shown. The line is described by them, so moving focus to it reads what was found there. Colour is never the only signal.
  • Markdown and JSON open as they read (rendered, formatted), with the findings listed above them. Following a finding, from that list or from a link with a line, opens the numbered Source. A Source / Rendered (or Formatted) control switches between the two. Other text always opens numbered.

A finding is marked on the one line its location names; a finding whose message names further lines marks only that one. Copy link includes the line once one is chosen, and the address of this route takes ?line= beside ?path=.

A reviewer who may read contents but not vetting still gets the file, without markers.

Every bound of these reads is a state this page renders on purpose:

Condition What the page says
Folder wider than 500 entries "Show N more (500 of M shown)" beneath it
Search with more than 2000 matches "Show N more (2000 of M shown)" beneath the matches
Blob over 128 KiB "Truncated: showing the first part of N bytes.", with the first part; the full blob is not shown in the portal
Binary blob "Binary file (N bytes) — content is not rendered."
Path removed by this snapshot Marked removed in the tree; the pane shows the removal
File unchanged vs served "Unchanged against the served commit."
Nothing served yet No baseline; approving serves all of it
No approver role "You cannot read this snapshot's contents.", naming the role needed
Snapshot is not this marketplace's "Snapshot N is not a snapshot of X."
A .json file that does not tokenise as JSON "Not valid JSON — shown as stored.", with the stored bytes
A finding's line beyond a truncated blob Listed above the file, and written out below it as "Line N is beyond the part shown"

The tree keeps one width whatever file is open; a long line scrolls inside the file pane rather than taking width from the tree.

JSON is shown re-indented, with a Formatted / Raw control switching to the stored bytes. It is re-indented by its tokens, not parsed: parsing would keep only the last of a key declared twice and rewrite number spellings (1.0 to 1) — the very differences a hostile manifest can use to show a reviewer one document and a client another — so every token is copied as written, in order, and only the whitespace between them changes. A truncated blob is shown as stored, since half a document cannot be formatted honestly.

Markdown renders inertly — there is no HTML pipeline at all, so HTML embedded in a hostile file appears as visible text and links are shown but never navigable. Other text renders as numbered lines. This is inspection, not execution: nothing fetched here is ever run, followed or injected as markup, and the page changes nothing about what the facade serves.

The reads are privileged — admin or an approver of this marketplace — and the page is gated by the server refusing them, not by the link being hidden.


Vetting chain (governance)

Route: /vetting · Heading: Vetting chain

Administrator-only. The sidebar offers the entry only to a session holding the admin role, and the page itself renders a stated refusal — "This page needs the administrative role." — to any other session rather than an empty shell. The server refuses each of the page's reads independently, which is what actually protects them.

Where Marketplace detail governs one marketplace's chain, this page governs the estate: the chain every marketplace runs unless it says otherwise, and the ones that say otherwise.

The default chain

The chain as it applies to a marketplace with no override of its own — the same drawing, the same per-vetter switch, and the same two chain-level controls as the marketplace card, scoped globally. Fed by GET /api/v1/vetting/global-chain and GET /api/v1/vetting/global-chain-settings, which resolve in the gateway rather than being recomposed in the browser, so this page cannot disagree with what actually runs. The source a control reports here is global or default, never this marketplace.

Writing here calls the same endpoints the marketplace card calls with the marketplace field omitted, which is the global setting. A marketplace that overrides the setting keeps its own; clearing that override is how it comes back.

Overrides

One row per marketplace whose chain departs from the default, showing what it departs in — mode: stop-after-fail, order: …, secret-scan: off — and a Clear every chain override on name control. Assembled from GET /api/v1/vetting/chain-settings, GET /api/v1/vetting/vetter-toggles and GET /api/v1/marketplaces; a stored override whose marketplace is no longer registered still gets a row, labelled by its id and with its control disabled.

Clearing is not setting the default value

An override that happens to equal the default still pins the marketplace: its source stays this marketplace, so the next change to the default passes it by. Clear override removes the setting, which is the only thing that puts the source back to global or default. Clearing a marketplace that overrides nothing writes nothing and records nothing.

Empty state: "No marketplace overrides the default chain. Everything in the estate runs exactly what is above."

Bulk edit

Select marketplaces — individually or All marketplaces — then choose one change: Set the chain mode, Set the vetter order, Switch a vetter, or Clear overrides. Selection and every control are ordinary keyboard-operable controls; the order to apply uses the same named Move vetter up / down buttons the marketplace card uses.

Review the change is disabled until a selection exists, with a hint saying what is missing. It opens a confirm step that states the act in a sentence and lists every affected marketplace with its Now and After — stated as the marketplace's own setting (no mode override → mode: stop-after-fail), because that is what will be stored.

Apply to these marketplaces calls POST /api/v1/vetting/chain-settings/bulk. The result is rendered per marketplace — applied, unchanged, failed with the server's reason — together with the correlation id every ledger entry the act wrote carries. A response in which anything was refused renders as a failure, with an error toast and an alert; it is never reported as a success.


Audit log

Route: /audit · Heading: Audit log

The ledger. Subtitle: "Append-only ledger of every facade fetch and administrative action, exportable to an external compliance system."

Export

Download ledger (NDJSON), the page's header action, points at /api/v1/audit/export — a plain same-origin, session-authenticated download, not a fetch through the API client. The line beneath it states how many audit sinks push the same feed onwards and links to them.

Ledger

The table, from GET /api/v1/audit, newest first. The API pages the ledger newest first, and the table sorts the entries it has loaded by timestamp descending, so it opens on what just happened. Any column header re-sorts it.

The table starts with the newest page of the ledger (1,000 entries by default). Below it, Load older entries fetches the next older page and adds it to the table. Beside the control, a line says how many entries are loaded. Once the oldest entry the ledger holds is loaded, the control goes and the line reads "{n} entries loaded — nothing older is recorded."

Column Contents
Status A verdict badge derived from the entry: a vetting-completed row reads from the outcome= in its detail, and refusal/violation events (rejected, revoked, refused approval, a re-vetting violation, a blocked private key) read blocked. A blocked row is drawn in the destructive colour — the same red the marketplace surfaces use — with a faint tint and a left accent so it is findable; a clear act reads in the accent, a warn one muted. Everything else (a fetch, an ingest, a token event) is neutral and uncoloured.
When The entry timestamp. Sortable.
Event The event name, monospace. Sortable and filterable.
Principal The acting identity. Sortable and filterable.
Marketplace A link to that marketplace's detail page; "—" for an entry not tied to a marketplace. Sortable and filterable.
Commit First 12 characters of the SHA, or "—". Filterable.
Detail The entry's free-text detail.

The per-column filter boxes sit above the table; each is free-text but offers completion from the values actually present, so you pick rather than type blind. The event, principal and commit lists are the distinct values in the loaded rows; the marketplace list draws on the registered marketplaces, so it completes beyond the rows on screen. The table sorts, filters and paginates (25 rows a page) client-side over the entries loaded so far. The footer reads "{shown} of {loaded} loaded entries", and the filters and the event/principal/commit suggestions cover only the loaded entries. The status is a read-only legibility aid derived from what the ledger already records — it does not correct the ledger; the lossy verdict detail and the actor-type on automated vetting principals are tracked in #221. For a full, resumable pull, use the NDJSON export and its cursor.

Empty state: "No fetches recorded yet."


Adoption

Route: /adoption · Heading: Adoption

The adoption and staleness reports, read-only. Subtitle: "Who fetches what through the facade, aggregated from the append-only ledger, and which identities are not on the served tip." Both underlying reads require the auditor role (see Authorization), so a session without it sees the page's error state.

Window and totals

A Report window button group — 7, 30 or 90 days, default 30, the active choice pressed — drives GET /api/v1/adoption?days=. Beside it, stat chips: total fetches in the window, marketplaces fetched, and stale identities.

Adoption by marketplace

One row card per marketplace fetched in the window: name, a serving / not serving badge, and chips for fetches, identities and the last fetch. Inside, the per-SHA breakdown:

Column Contents
Snapshot SHA First 12 characters, monospace; full SHA on the tooltip.
Fetches Content-transferring fetches of this SHA in the window.
Identities Distinct identities that fetched it.
Last fetch Most recent, in the reader's locale; the ISO-8601 instant on the tooltip.
Tip current (primary badge) or superseded (secondary).

Empty state: "No fetches in the last {n} days. Adoption appears once content is fetched through the facade."

Stale identities

The staleness report, window-free: identity, marketplace, last received SHA, the served tip it diverges from — or a destructive not serving badge when the marketplace stopped serving entirely — and the last fetch time. The page says what the report is: facts, not verdicts.

Empty state: "Every identity is on the served tip."


Webhooks

Route: /integrations/webhooks (/webhooks redirects here) · Heading: Webhooks

"Snapshot lifecycle events are POSTed to each subscriber that filters for them, signed with HMAC-SHA256 and retried with backoff until delivered."

Reading requires the auditor role; adding and deleting require an administrator, and the page offers those controls to an administrator only.

New subscriber

New subscriber opens a dialog with Subscriber name, Target URL and Events. Events is a checkbox list built from GET /api/v1/webhooks/events, so the portal offers exactly the names this gateway emits rather than asking for them: All events ticks or clears every one, and the box above the list narrows what is shown without changing what is selected. Every event is ticked by default, and that state submits * rather than an enumeration — so a subscriber registered today also receives events added to the gateway later.

Add subscriber stays disabled until the name matches ^[a-z0-9][a-z0-9_-]*$, the target URL parses with a scheme, and at least one event is ticked; only then is POST /api/v1/webhooks called. The scheme allowlist remains server-side and surfaces as an error toast.

On success the dialog closes and a show-once dialog opens — "This signing secret is shown exactly once — copy it now." — with the whsec_… value in a code block and a clipboard button that flips to a checkmark for two seconds, the same pattern as Access tokens.

Subscribers

The lifecycle subscribers. An audit sink's delivery channel — a subscriber the API marks with auditSink — is listed with its sink on Audit sinks instead, and cannot be deleted from here.

Column Contents
Name The subscriber name.
Target URL The registered endpoint.
Events The filter, rendered as a chip; * displays as "all events". A name absent from the served registry is flagged with an unknown event badge, whose tooltip names it.
Status enabled (primary badge) or disabled (secondary).
Actions Delete, which fires immediately — administrators only.

Delete calls DELETE /api/v1/webhooks/{id} and toasts Subscriber '{name}' deleted. It removes the delivery history with the subscriber.

Empty state: "No subscribers yet."

Delivery attempts

From GET /api/v1/webhooks/deliveries — the operator's view of a failing integration.

Column Contents
Event The lifecycle event name.
Subscriber Resolved from the subscriber list held in the browser; falls back to the raw id. A sink's delivery reads "{sink} · audit sink" and links to Audit sinks.
State Badge — delivered (primary), failed (destructive), pending (secondary).
Attempts Attempts made so far.
Last response The last HTTP status, else the last error, else "—".
Queued Enqueue timestamp.

Read-only: there is no manual redelivery and no editing. Empty state: "No deliveries yet."

The secret is never re-displayed anywhere on this page. See Receiving lifecycle webhooks for the payload, headers and signature verification.


Audit sinks

Route: /integrations/sinks · Heading: Audit sinks

The audit ledger pushed to an external compliance system, each sink with its position in it. Reading the list requires the auditor role; adding, replaying and deleting require an administrator, and the page offers those controls to an administrator only.

New sink

New sink opens a dialog with Sink name and Target URL, which posts to POST /api/v1/audit/sinks. Add sink stays disabled until the name matches ^[a-z0-9][a-z0-9_-]*$ and the target URL parses with a scheme; the scheme allowlist itself stays server-side. On success the dialog closes and the same show-once secret dialog opens as Access tokens and Webhooks.

Sinks

Column Contents
Name The sink name.
Target URL Where batches are POSTed.
Position The sink's cursor — the last ledger sequence handed to it, monospace.
Behind Entries not yet handed over, as "{n} entries".
Status enabled (primary badge) or disabled (secondary).
Actions Replay and Delete, both firing immediately — administrators only.

Replay sets the cursor to 0 — the whole ledger, from the beginning — and toasts Sink '{name}' will replay the ledger. Replaying to an arbitrary position is API-only (PUT /api/v1/audit/sinks/{id}/cursor).

Delete calls DELETE /api/v1/audit/sinks/{id}, taking the sink's delivery channel with it, and toasts Sink '{name}' deleted.

Sink deliveries are ordinary webhook deliveries, so their attempts appear on the Webhooks page rather than here, marked as the sink's.

Empty state: "No export sinks yet."


Access tokens

Route: /tokens · Heading: Access tokens

"Personal access tokens authenticate git clients against the facade. Values are hashed at rest and shown exactly once."

An inline form with a Token name field and a Create token button posts to POST /api/v1/tokens. Create token stays disabled until the name field holds a non-blank value — the name is trimmed before it is sent. The response opens a show-once dialog — "This value is shown exactly once — copy it now. Only a hash is stored." — with the token in a code block and a clipboard button that flips to a checkmark for two seconds.

Column Contents
Name The name you gave it.
Created Creation timestamp.
Last used How long ago the token last authenticated successfully, or never. Hover for the exact instant.
Status active (primary badge) or revoked (destructive).
Actions Revoke, shown only while active.

Last used is the column to read before revoking: it separates a token something still authenticates with from one nobody has used. It shows the recency rather than the instant, because recency is the question; the exact time stays on the hover title, as on every other timestamp in the portal. The gateway records it at most once a minute, so it can be up to a minute behind, and a refused authentication never moves it — see lastUsedAt for what a never does and does not prove.

Revoke calls DELETE /api/v1/tokens/{id} and toasts Token '{name}' revoked. It fires immediately. Revocation is recorded rather than deleted: the row stays with a revoked badge.

Empty state: "No tokens yet."

See Consuming approved skills for using a token with a git client.