Skip to content

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