Compatibility and allowlists¶
What the gateway accepts, what it rejects, what it does not support yet, and what its own API promises in return. Everything on this page is enforced in code or in CI, not merely recommended — where a rule is not mechanically enforced, it says so.
URL schemes¶
Applied to every operator-supplied outbound URL. The scheme is lower-cased and
matched against skills-gateway.allowed-url-schemes.
| Scheme | Default | Notes |
|---|---|---|
https |
Allowed | The intended production scheme. |
http |
Allowed | Present for local development and internal forges. Consider narrowing to https alone in production. |
ssh, git, git+ssh |
Rejected | Not in the default allowlist. The gateway clones over HTTP(S) with JGit. |
file |
Rejected | Would let a registration read the gateway's own filesystem. |
ext, and any other scheme |
Rejected | Not in the allowlist. |
| (no scheme, or unparseable) | Rejected | The check fails closed — an unparseable URL is a rejection, not a pass-through. |
Rejection is HTTP 400 with a ProblemDetail naming the allowed schemes.
Widening the allowlist widens a trust boundary
allowed-url-schemes governs what a registration can make the gateway
connect to. Adding file or ext would let an operator-supplied string
reach local resources. Treat changes to it as a security decision.
An empty list is not configurable — it falls back to the default.
Refs¶
| Input | Behaviour |
|---|---|
ref absent |
Accepted. The default branch is ingested. |
ref: main |
Accepted — identical to omitting it. |
| Any other ref | 400. |
Which ref is ingested is the gateway's decision, not the registrant's. This is a
deliberate trust-boundary constraint rather than a missing feature: multi-ref
support will arrive as promotion per (upstream, ref), with each ref advancing
independently through the same approval gate, not by relaxing this check.
Consequently marketplace add <url>#release-1.x against the facade will not
find a ref — only main exists on the published repository.
Names¶
Marketplace names must match ^[a-z0-9][a-z0-9_-]{0,62}$: 1 to 63 lowercase
letters, digits, hyphens and underscores, not starting with a hyphen or
underscore. A violation is 422.
The name is also a path segment on the facade (/git/{name}), which is why the
character set is constrained.
Plugin sources inside a marketplace¶
An unconfigured gateway accepts local sources only, and that is the default.
| Source type | Status |
|---|---|
| Relative path inside the marketplace repository | Accepted |
github |
Rejected fail-closed unless skills-gateway.ingestion.external-sources.enabled is set; when it is, resolved and rewritten — see below |
git/url, git-subdir |
Rejected fail-closed. Not in the shipped allowed-types, and nothing resolves them yet |
npm, archive |
Rejected fail-closed, permanently: no configuration admits them |
A source declaring a ref or a sha |
Rejected fail-closed, naming the field. This gateway resolves at the remote's default branch head, and resolving a pinned source somewhere else would serve a commit the manifest did not name |
| Anything else — an unrecognised type, an object with no type, a value that is neither a path nor an object | Rejected fail-closed, with the form named in the snapshot's violation |
A relative source resolves inside the served snapshot by itself, so for a local-only marketplace every URL a client dereferences already resolves inside the gateway.
External source support arrives in increments
(ADR 0011).
An enabled gateway now resolves an admitted github source: it fetches the
repository into quarantine, grafts it under _plugins/<plugin name>/, and
rewrites the served manifest so that plugin's source is
./_plugins/<plugin name>. The snapshot is that composite commit, parented on
the upstream commit. So the property above holds for an enabled gateway too —
every URL a client dereferences resolves inside the gateway.
The invariant that governs both cases: a snapshot is held, and therefore approvable, only when every source it declares resolves inside the snapshot the gateway serves. Anything that stops a source from resolving — an unreachable repository, a refused address or redirect, a breached budget, an exhausted deadline, a graft that cannot be made — is a rejected snapshot recorded against the upstream commit, never a held one.
Three further refusals belong to the graft rather than to the source type, and
each is fail-closed with no partial result: a marketplace repository that already
has a top-level _plugins; an external plugin whose name is not
^[a-z0-9][a-z0-9_-]*$; and two external plugins sharing a name.
Clients¶
The facade is plain read-only git smart-HTTP, so anything that clones works.
| Client | Support |
|---|---|
Claude Code (claude plugin marketplace add) |
The primary target. |
Copilot / Cursor (open Agent Skills SKILL.md repositories) |
Works — the facade serves a git repository. |
CI pipelines, bare git clone |
Works with a PAT. |
Anything attempting git push to /git/** |
Rejected by construction. (A hosted marketplace is published to on /publish/**, a separate endpoint.) |
Enforcement mechanisms¶
Making the gateway the only path is outside the gateway itself:
| Mechanism | Availability |
|---|---|
Claude Code strictKnownMarketplaces (managed settings) |
Available today — a hard client-side allowlist. |
Claude Code extraKnownMarketplaces, enabledPlugins |
Available today — pre-register and force-install. |
| Copilot / Cursor equivalent | None. Egress policy carries the load. |
| Network egress blocking of upstream marketplace hosts | Your network, not the gateway. |
The API contract¶
Everything above is about what the gateway accepts. This is about what its own HTTP surface promises.
What this promise covers, and how each surface is checked
It covers /api/**, /status/** — the
revocation check, which
is described in the same OpenAPI document and diffed by the same gate — the
lifecycle webhook deliveries, and the
skills-gateway.estate.* declarative estate schema
(guide,
reference). A renamed or removed
estate key breaks a checked-in estate file exactly as a removed API field
breaks a client, so the same additive-within-major rule applies: a key may
be added, but not renamed, removed, or narrowed, without a major release.
It does not extend to the rest of skills-gateway.*, or to the Helm
chart's values — those are operator-facing too, but nothing here declares
them a contract yet.
Enforcement differs by surface, not the obligation. /api/** and the
webhook deliveries are diffed by oasdiff because both are described in the
OpenAPI document. The estate schema is Spring @ConfigurationProperties,
not OpenAPI, so no diffable document exists for it today — enforcement is
by review, against this page and the configuration reference, of what the
PR title declares. A future gate could diff the
spring-configuration-metadata.json the build already generates, if a
reviewed miss shows the need; none exists yet, and this change does not add
one.
Two removed properties — skills-gateway.roles.enabled and
…object-store.cache.ref-freshness — used to refuse startup when a
deployment still set them. Those refusals were migration aids for versions
that had read the properties, promised only as far as the next major, and
1.0.0 is it: they are gone, and setting either name is now an unknown
property, ignored like any other. The policy that produced them stands —
removing a property whose absence reverses an operator's stated intention
needs a refusal, not silence — and the next such removal gets its own.
Within a major, /api/** only grows. Endpoints, fields and enum values may
be added; nothing a deployed client could depend on is removed, narrowed or
renamed. A breaking change is allowed — it is not free. It moves the path prefix
and ships as a major release, so a client that pinned neither is never
surprised.
The same promise covers what the gateway sends. The
lifecycle webhook deliveries — the set of
events, each delivery's body fields and its X-Skills-Gateway-* transport
headers — are described in the same document, under its top-level webhooks
object, generated from the registry and the types the dispatcher uses. They only
grow too.
The event names were renamed once, before 1.0
Every snapshot.<x> event is now marketplace.snapshot.<x>, so that
marketplace.<x> — what happened to the marketplace itself — has a namespace
to live in. A subscriber's stored filter was rewritten by a migration, so no
operator action was needed; a receiver matching on the name it was given has
to be updated. This was a declared break under the rules below, and it is the
kind of change that costs a major release once the contract is frozen.
| Change | Additive? |
|---|---|
| A new endpoint, a new optional field, a new enum value | Yes. Ships as a minor. |
/api/v2/... added while /api/v1/... remains |
Yes — nothing was taken away. Deprecate first, remove in a later major. |
| Removing or renaming an endpoint or field, making an optional field required, narrowing a type | No. New prefix, and a major. |
| A new lifecycle event | Yes. A subscriber's filter is exact-match, so nothing starts receiving it uninvited. |
| A new field in a delivery body — added optional | Yes. Receivers must ignore keys they do not recognise. |
| Removing or renaming an event, a delivery body field, or a transport header | No. |
| Gating a route that was reachable without the role it should always have required | Additive to the document, narrowing in fact. See below. |
A new enum value obliges the consumer, not the gateway
The table says a new enum value is additive, and it is — but only because the obligation sits on the other side: a client must tolerate a value it does not recognise, the same rule a webhook receiver is held to for keys it does not know. A verdict state, a snapshot state or a credential kind can join its set in a minor, and a consumer that compiled the set closed will see something it has no branch for. Render it, log it, ignore it — do not fail on it.
The diff tool disagrees by default, and is configured to the contract's answer
rather than the other way round: response-property-enum-value-added is
lowered to a warning in .oasdiff-severity-levels.txt, so the addition still
appears in the diff without declaring a major. Removing or renaming a value
remains a break, because nothing on the consumer's side can absorb one.
Closing a security gap narrows a route without moving the prefix
A route that was readable by callers who should never have reached it is
corrected in place: the guard is added, a 403 joins its documented
responses, and the prefix does not move. The remedy the table prescribes for
a narrowing — a new prefix and a major — cannot apply, because it would leave
the old prefix serving the gap it exists to close.
The gate does not object: adding a response is additive, and oasdiff cannot
see that a caller who used to get 200 now gets 403. That makes this a
case review has to catch, like the prefix rule above it. It has happened once
so far, to GET /api/v1/snapshots/{id}/fetchers.
A payload field added later is added optional
Not a concession to the diff tool — an honest statement. A receiver cannot rely on a field that only exists from release N onward, and every field the schema marks required is one the gateway populates on every delivery, without exception.
The prefix, and what it is for
Every endpoint carries one: /api/v1/marketplaces, /status/v1/snapshots.
The segment exists so that the remedy above is available — a breaking change
moves the prefix — without leaving a bare, unversioned path that has to be
supported forever beside its own successor. It arrived in 1.0.0, before the
promise began to bind, which is the only release where adding it was free.
How it is enforced¶
Several mechanisms, and it is worth being clear about which is which.
| Rule | Enforced by |
|---|---|
| The published contract is the contract the gateway serves | A build test. src/main/frontend/openapi.json must equal the document the running application serves, or ./mvnw verify fails naming the command that regenerates it. |
| A breaking change is detected | The API contract workflow diffs every pull request's contract against the one it forked from with oasdiff, and fails on a breaking classification. |
| A breaking change is declared | The same workflow requires the PR title — which becomes the squash commit subject, and so the release version — to carry ! or BREAKING CHANGE. |
| The prefix was moved | Nobody. No check can tell that a break should have been versioned instead; that is review's job. |
| The estate schema is additive | Nobody, mechanically. skills-gateway.estate.* is not OpenAPI, so oasdiff never sees it. Review checks the PR title against this page and Configuration. |
Two of oasdiff's default severities are raised to errors in .oasdiff.yaml:
removing a response field, and removing an optional response header. Both are
warnings by default unless the schema marks them required, and almost no response
schema here does — so the most ordinary breaking change there is used to pass a
gate that fails on errors only. The webhook delivery bodies are among the few that
do mark their fields required, which is a statement about the wire (those fields
are always populated) before it is anything about the gate.
The webhook deliveries reach the same gate through the same document, and are caught from two directions:
| Read from | What only it catches |
|---|---|
The webhooks entries |
An event disappearing, and the transport headers. Each entry is also the human-readable per-event contract a receiver author reads. |
The events endpoint's 200 response |
A body field removed, renamed or retyped. The bodies hang off a response there, which is what makes such a change an error: a webhooks entry describes a request the gateway sends, and on the request side a removed field is only a warning — the single most likely break, rated as advice. |
What it still does not catch: that the meaning of a field changed, that an
enum-like state string gained a value a receiver will not understand, or that
the signature scheme changed — the document constrains the signature header's
shape, not how it is computed.
Deliberately breaking the contract takes two visible acts: the
⚠️ BREAKING CONTRACT label on the pull request, and the break declared in its
title. Both stay in the record. Keeping the old surface alongside the new one
needs neither — that path is additive, and the gate passes on its own.
The document served at /v3/api-docs declares the release it describes, derived
from the build. The copy published in the repository carries a placeholder there
instead: it is regenerated by hand, and a version that changes on every commit
would make it differ from the build on every commit.
Platform¶
| Component | Requirement |
|---|---|
| Runtime | Java 25 (Temurin) |
| Database | PostgreSQL |
| Identity | An OIDC provider supporting authorization-code flow |
| Container | Distroless image: the application jar on a jlink Java 25 runtime |
| Kubernetes | helm/skills-gateway; bring your own PostgreSQL and OIDC provider |