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 Marketplaces /marketplaces
Governance Audit log /audit
Governance Adoption /adoption
Governance Webhooks /webhooks
Access Access tokens /tokens
Tools API reference /docs — the Scalar API reference, not a portal route

Marketplace detail is reached by clicking a marketplace, not from the sidebar.

The sidebar footer shows the signed-in username from GET /api/me. The header carries a breadcrumb and a dark-mode toggle.

There is no logout control

Ending a session means clearing the session cookie or logging out at the identity provider.

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

With role enforcement at its default (off), any authenticated session can register marketplaces, ingest, approve and reject. With skills-gateway.roles.enabled=true the server refuses mutations, the audit surface and the snapshot preview 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": the review-queue signal. Action: Manage marketplaces.

Fetch ledger — chip with the total recorded fetches. Action: Open audit log.

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

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


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_-]*$ — "lowercase letters, digits, - and _; must not start with - or _"
Clone URL Must be a valid URL. The scheme allowlist is enforced server-side and surfaces as an error toast.

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

Submits POST /api/marketplaces; toasts Marketplace '{name}' registered.

Marketplace cards

One card per marketplace, titled with its name and linking to the detail page. Beneath: the clone URL, the forge description if captured, and the last upstream update if known.

The card action is Ingest (POST /api/marketplaces/{name}/ingest), toasting Snapshot {sha12} is {state}.

Snapshot table

Column Contents
Commit First 12 characters of the SHA, monospace.
State Badge — approved (primary), held (secondary), rejected and revoked (destructive).
Vetting Badge from GET /api/snapshots/{id}/vetting: vetting clear (primary), vetting clear with waivers (secondary) or vetting blocked (destructive). A snapshot the chain never ran against reads blocked. The waived case is a separate badge on purpose — an accepted risk must not read as a clean chain.
Violation The ingestion violation, or "—".
Decided by The deciding principal, or "—".
Actions Right-aligned buttons.

Approve and Reject appear while the state is held or revoked; on a revoked snapshot the approve control reads Re-approve and goes through the same gate. Reject fires immediately and toasts Snapshot {id} rejected.

Approve opens the review dialog rather than acting immediately — approve is the moment content becomes reachable by clients, so the verdicts come first.

While a snapshot is inside the configured minimum release age, the approve control is disabled and reads Eligible in {remaining} — from GET /api/snapshots/{id}/release-age, so the portal never computes the deadline from the browser's clock. Reject stays enabled: rejection is never age-gated. With the gate off (the default) the control reads Approve as before, and a snapshot whose eligibility has not been fetched yet is never disabled on suspicion — the server is the gate.

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.

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/snapshots/{id}/approve with no body and toasts Snapshot {id} approved.

Waiving a finding

Each blocking finding in the report carries a Waive finding {rule} button that opens an inline form beside it:

Control Notes
Scope This snapshot only (default) or This path in the marketplace
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/snapshots/{id}/waivers and toasts Waiver recorded for {rule}. The finding is then struck through and badged waived by {approver} until {date}, 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/waivers/{id}.

Provenance is always available, opening a dialog fed by GET /api/snapshots/{id}/provenance: marketplace, upstream URL, upstream SHA, state, ingested time, decided by, decided at.

Empty states: "No marketplaces registered yet." and, per card, "No snapshots yet — ingest to fetch the upstream default branch."


Marketplace detail

Route: /marketplaces/{name} · Heading: the marketplace name

Reached by clicking a marketplace name. 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.

Upstream card

Forge metadata captured at registration, best effort: Forge, Project, Description, Last upstream update, Registered. Anything not captured shows "—".

Snapshots

One card per snapshot showing the short SHA (12 characters, monospace), the state badge, and the deciding principal once decided. A violation, when present, renders as destructive text beneath the header row.

The Show contents toggle loads GET /api/snapshots/{id}/content and renders one block per declared plugin: name, source, optional description, and one badge per skill found under it. Plugins with no skills show "no skills found".

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

Preview pane

The Preview files toggle on each snapshot card opens the reviewer preview pane: the pinned commit's actual content, not a summary of it.

  • Files: a scrollable file tree (path and size, from GET /api/snapshots/{id}/files) beside a viewer for the selected file (GET /api/snapshots/{id}/file?path=). A skill's SKILL.md is opened automatically. 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 preformatted; a binary file is described ("Binary file (N bytes) — content is not rendered."); a file over the size cap says it is truncated and shows the first part.
  • Diff vs served: the delta against the marketplace's currently served commit (GET /api/snapshots/{id}/diff) — one row per added, modified or removed path, each expandable to its unified text diff. When nothing is served the pane says so: there is no baseline, and approving the snapshot serves all of it.

This is inspection, not execution: nothing fetched here is ever run, followed or injected as markup, and the pane changes nothing about what the facade serves. While role enforcement is enabled these reads are privileged — admin or an approver of this marketplace — so the pane shows an error to a session holding neither.

Set up a client

The page header carries a Set up a client button opening a wizard that composes, for this marketplace, everything a consumer needs — every URL derived from the address the browser is already on:

  1. Personal access token — the same show-once creation flow as the Access tokens page (name required before the control enables). A token minted here is filled into the snippets below only while the wizard stays open; closing the wizard drops it, and no previously issued token's value is ever shown.
  2. Store the credential — a git credential approve line for this host.
  3. Add the marketplace to Claude Codeclaude plugin marketplace add {origin}/git/{name}.
  4. Clone directly — the CI-shaped git clone URL with the token inline.

Each snippet has a copy button. Until a token is minted the snippets carry the <YOUR_TOKEN> placeholder.

Vetting

Each snapshot card carries a Vetting section fed by GET /api/snapshots/{id}/vetting, above the contents toggle and always visible — the evidence is not behind a click.

It shows the effective chain outcome badge and one block per connector: an icon and badge for the verdict state, the connector name, a one-line summary, and every finding as severity badge, rule id, path:line, and message. A connector 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 connectors can and cannot see disclosure lists each configured connector 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 on the marketplaces page.

Re-vetting panel

Above the vetting section, each 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 — re-vetting is about content that is being served.

Re-vet now calls POST /api/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/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.

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/snapshots/{id} and toasts Snapshot {id} deleted; it can be restored. Restore calls POST /api/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

Route: /audit · Heading: Audit log

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

Export

A Download ledger (NDJSON) link pointing at /api/audit/export — a plain same-origin, session-authenticated download, not a fetch through the API client.

Export sinks

An inline form with Sink name and Target URL posts to POST /api/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. The response opens the same show-once secret dialog as Access tokens and Webhooks.

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.

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/audit/sinks/{id}/cursor).

Delete calls DELETE /api/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.

Empty state: "No export sinks yet."

Ledger

The table, from GET /api/audit.

It is schema-less: columns are the keys of the first returned row, in order, and every cell renders as monospace text with nulls shown as "—". In practice those columns are id, ts, source, principal, marketplace, event, ref and sha.

There is no filtering, search or paging — it is a recent-activity view rather than an investigation tool.

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." With role enforcement enabled both underlying reads require the auditor role, 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/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: /webhooks · Heading: Webhooks

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

Add subscriber

An inline form with Subscriber name, Target URL and Events. Events is a checkbox list built from GET /api/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/webhooks called. The scheme allowlist remains server-side and surfaces as an error toast.

The response opens a show-once dialog — "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

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.

Delete calls DELETE /api/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/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.
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.


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/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.
Status active (primary badge) or revoked (destructive).
Actions Revoke, shown only while active.

Revoke calls DELETE /api/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.