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.
Navigation¶
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-authdevelopment 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
detailon 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 |
Header¶
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:
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:
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:
- Identity — the short SHA, the state badge, when it was ingested and by whom, who decided it, and the retention control (Delete / Restore).
-
The delta line — what is arriving, how big it is, and against what:
File and line counts come from
GET /api/v1/snapshots/{id}/diff(against what is served); skill counts fromGET /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:linelocation 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 fromdiff, 500 at a time, with Show N more, the totals over the whole diff, and each file's diff opened in placeInventory What the snapshot ships: one block per declared plugin with its sourceand 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 fromGET /api/v1/snapshots/{id}/contentProvenance Upstream URL and SHA, the served SHA, who decided it and when, and the closure of external plugin sources -
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:
- Personal access token — the same show-once creation flow as the
Access tokens page. The name defaults to
{marketplace}-clientand 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. - Store the credential — a
git credential approveline 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. - Add the marketplace to Claude Code —
claude plugin marketplace add {origin}/git/{name}. - Clone directly — a plain
git cloneof the facade URL, with no token in it: the credential stored by step 2 authenticates it, and nothing lands in.git/configor 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:
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
skippednode 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. Anot reachednode 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:
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.