Skip to content

Configuration

Every setting the gateway reads, with its default and what consumes it.

Summary

Block Purpose Required in production
skills-gateway.* Storage location, the URL-scheme allowlist, and the development auth escape hatch. No — all defaulted.
skills-gateway.ingestion.* Whether a manifest may declare plugin sources outside the marketplace repository, and within what bounds. Admits nothing by default. The credentials private upstreams are read with. None by default. No — all defaulted.
skills-gateway.webhooks.* Outbound lifecycle-webhook dispatch: poll interval, retry budget and backoff. No — all defaulted.
skills-gateway.audit-export.* Ledger export: the commit-settling lag, batch and page sizes. No — all defaulted.
skills-gateway.retention.* Snapshot retention policies, the schedules that apply them, and the sweep of abandoned publication staging refs. Off by default. No — all defaulted.
skills-gateway.approval.* Separation of duties on approval: whether a reviewer may publish content they themselves supplied (records by default; enforcement is opt-in), and the plugin-name collision rule (on by default). No — all defaulted.
skills-gateway.sync.* Upstream sync: the polling sweep's schedule and batch, and the inbound webhook body bound. No — all defaulted.
skills-gateway.catalog.* The global virtual catalog and its reserved name. No — all defaulted.
skills-gateway.tokens.* Access-token policy: the maximum lifetime creation accepts. No — defaulted (unlimited).
skills-gateway.roles.* The administrators and claim mappings the gateway derives roles from. Enforcement is always on. Yes — a deployment that names no administrator refuses to start.
skills-gateway.estate.* The declared estate: marketplaces, role grants, webhook subscribers, audit sinks, policy rules — reconciled at startup and on demand. Empty by default. No — empty by default.
spring.datasource.* PostgreSQL connection. Supplied entirely by environment. Yes
spring.security.oauth2.client.* OIDC login for the web surface. Yes
server.servlet.session.cookie.same-site Whether the browser sends the session cookie on a cross-site request. No — set to lax in application.yaml.
server.forward-headers-strategy Whether the scheme and host a TLS-terminating proxy reports are believed. Off by default. Yes, behind a proxy — which is every real deployment.
management.endpoints.* Which actuator endpoints are exposed. No
scalar.* The bundled API reference UI. No

There are no validation annotations anywhere in the properties record. Every field is nullable and its default is applied in the constructor, which means omitting a whole block and omitting individual keys behave identically.


skills-gateway

The application's own namespace. Only data-dir appears in application.yaml; the rest are Java-side defaults.

skills-gateway:
  # Root of local storage. On the filesystem backend three subdirectories are
  # created beneath it:
  #   {data-dir}/quarantine/{marketplace}.git   — never served
  #   {data-dir}/published/{marketplace}.git    — what the facade reads
  #   {data-dir}/hosted/{marketplace}.git       — a hosted marketplace's origin
  # On the object-store backend the repositories are in the bucket and this
  # holds only the local pack cache, which is never authoritative.
  # The container image sets SKILLSGATEWAY_DATADIR=/data instead.
  data-dir: data

  # URL-scheme allowlist for every operator-supplied outbound URL.
  # Compared lower-cased. A URL that fails to parse, or carries no scheme,
  # is rejected — the check fails closed. Rejection is HTTP 400.
  # An empty list is not configurable: it falls back to the default.
  allowed-url-schemes:
    - http
    - https

  # DEVELOPMENT ONLY. Makes the entire web surface unauthenticated and
  # injects a synthetic principal "dev". Logs a warning at startup.
  # Does not affect the git facade, which always requires a PAT.
  dev-insecure-auth: false
Property Type Default Notes
skills-gateway.data-dir path data Relative to the working directory. /data in the container image.
skills-gateway.allowed-url-schemes list of string [http, https] Governs registration and an admitted external plugin source's clone URL. See Compatibility and allowlists.
skills-gateway.dev-insecure-auth boolean false Must stay false outside a development loop.

dev-insecure-auth in a deployed environment

It permits all of /api/**, /actuator/** and /docs without authentication, and attributes every audit entry to dev. There is no partial mode.

The gateway refuses to start where the flag cannot belong

The escape hatch exists for a development loop that has no identity provider to log in to. So a gateway with dev-insecure-auth: true and an identity provider configured refuses to start, naming what it decided on and both ways out. It counts a provider as configured when any of these is true:

  • an OIDC client registration carries a client id other than the shipped change-me placeholder;
  • a provider endpoint (authorization-uri, token-uri, jwk-set-uri, issuer-uri) names a host other than the shipped idp.invalid placeholder;
  • skills-gateway.oidc.issuer is pinned.

There is no property that switches the guard off — the way out is to stop setting dev-insecure-auth. Note what the guard cannot see: a deployment with no identity provider at all is indistinguishable from a laptop, so it is not a substitute for keeping the flag out of your deployed configuration.


Git storage

Which storage holds the repositories, and how to reach it. The backend is named, never inferred: an absent block is the filesystem, an unrecognised name fails startup, and an object-store selection missing what it needs fails startup naming the missing setting. There is no fallback in either direction — a gateway serving from local disk while the operator believes it is serving from a bucket reports healthy and is wrong from the outside.

For choosing between the two, and for moving an estate from one to the other, see Choosing and migrating the storage backend.

skills-gateway:
  storage:
    # filesystem (default) | object-store
    backend: filesystem

    object-store:
      bucket: skills-gateway
      region: eu-north-1
      # Empty for the regional AWS endpoint; set it for an S3-compatible store
      # or for an S3 VPC endpoint.
      endpoint: ""
      # Key prefix, so one bucket can hold more than one gateway.
      prefix: ""

      credentials:
        # default | web-identity | static
        mode: web-identity
        # web-identity: both fall back to AWS_ROLE_ARN and
        # AWS_WEB_IDENTITY_TOKEN_FILE, which is what an annotated service
        # account projects into the pod.
        role-arn: ""
        token-file: ""
        # static only, for stores with no role mechanism.
        access-key-id: ""
        secret-access-key: ""

      cache:
        # Local pack cache; nothing in it is authoritative and deleting it at
        # any moment is safe. Defaults to {data-dir}/object-store-cache.
        dir: ""
        max-bytes: 2147483648
        block-size-bytes: 65536
        block-cache-bytes: 268435456
        # How long a pack nothing references is kept before its objects are
        # deleted, so a fetch already streaming from it is not cut off.
        pack-grace: 1h

      # Below the store's own idle timeout: a connection the store has already
      # closed reads, on the first request after a quiet period, as a storage
      # fault rather than as the pooling artefact it is.
      connection-max-idle-time: 20s
      connection-time-to-live: 1m

    # The offline one-shot copy between backends. Off unless asked for.
    migration:
      enabled: false
      # The destination; the source is whatever `backend` above names.
      to: object-store
Property Type Default Notes
skills-gateway.storage.backend filesystem | object-store filesystem An unrecognised value fails startup listing the accepted ones.
skills-gateway.storage.object-store.bucket string — Required on object-store.
skills-gateway.storage.object-store.region string — Required on object-store; an unsigned-for region fails mid-approval rather than at startup.
skills-gateway.storage.object-store.endpoint URL SDK regional An S3-compatible store, or an S3 VPC endpoint.
skills-gateway.storage.object-store.prefix string "" Key prefix inside the bucket.
skills-gateway.storage.object-store.credentials.mode default | web-identity | static default See below.
skills-gateway.storage.object-store.credentials.role-arn string AWS_ROLE_ARN web-identity only.
skills-gateway.storage.object-store.credentials.token-file path AWS_WEB_IDENTITY_TOKEN_FILE web-identity only.
skills-gateway.storage.object-store.credentials.access-key-id string — static only.
skills-gateway.storage.object-store.credentials.secret-access-key string — static only. Never logged, audited or echoed by any API.
skills-gateway.storage.object-store.cache.dir path {data-dir}/object-store-cache Safe to delete at any time.
skills-gateway.storage.object-store.cache.max-bytes bytes 2 GiB Bound on the on-disk pack cache.
skills-gateway.storage.object-store.cache.block-size-bytes bytes 64 KiB JGit DfsBlockCache block size.
skills-gateway.storage.object-store.cache.block-cache-bytes bytes 256 MiB JGit DfsBlockCache size.
skills-gateway.storage.object-store.cache.pack-grace duration 1h Grace before an unreferenced pack's objects are deleted.
skills-gateway.storage.object-store.connection-max-idle-time duration 20s Keep below the store's idle timeout.
skills-gateway.storage.object-store.connection-time-to-live duration 1m Upper bound on a pooled connection's life.
skills-gateway.storage.migration.enabled boolean false Makes this start a migration instead of a service.
skills-gateway.storage.migration.to filesystem | object-store — Required when enabled; must differ from backend.

…cache.ref-freshness was removed, and setting it refuses startup

It set how long a replica could keep serving a reference map it had already read — in effect, how long a revoked snapshot could still be advertised by a replica that did not perform the revocation. That is a property of the revocation path rather than a tuning dial, so it is no longer settable: every reference advertisement is preceded by a conditional GET of the repository manifest, and the bound is the next advertisement.

It is refused rather than ignored for the same reason skills-gateway.roles.enabled is. A deployment that set it was asking for a longer bound; ignoring it would shorten the bound silently, which is the right behaviour arrived at by the wrong route — the operator would still believe the value they wrote was in force.

Remove the property. What you get without it is what the default already gave you: it defaulted to zero, and the documented 10s default recorded here until this change never existed in the code. The refusal is a migration aid and is scheduled for removal at the next major version.

Credential modes

Mode Where credentials come from Use it for
default The AWS SDK's own provider chain A host where the chain already resolves, and where an instance metadata service exists
web-identity A projected service-account token exchanged for a role Workload identity (IRSA and its equivalents). The gateway holds no secret at all, and it is the only mode that works where there is no instance metadata service
static An access key pair Stores with no role mechanism

Write access to the bucket is publication

On the object-store backend the served reference map is an object in the bucket. Anyone who can write it can put content on the wire without going through approval. Give the gateway a narrow policy — object read, write and delete under its own prefix, and no bucket administration — and treat the bucket as part of the trust boundary the volume already was. See Trust boundaries.

Conditional writes are the portability boundary

The backend serializes every reference transition with a conditional write (If-Match / If-None-Match) on one small object. A store that does not implement those cannot be supported by weakening the model, so the gateway probes the configured bucket at startup and refuses to run where the probe fails. The stores this has actually been exercised against are listed in the storage guide.


Webhooks

Tuning for the outbound dispatcher that delivers marketplace lifecycle events. None of it appears in application.yaml; every value below is a Java-side default, and omitting the whole block is identical to omitting each key.

skills-gateway:
  webhooks:
    # Stops the polling dispatcher only. Events are still enqueued as delivery
    # rows and the admin API keeps working, so turning this back on drains
    # whatever accumulated instead of losing it.
    enabled: true

    # How often the dispatcher looks for due deliveries. Also the floor on
    # end-to-end latency for the first attempt.
    poll-interval: 5s

    # Attempt n schedules the next attempt at base-backoff * 2^(n-1),
    # never later than max-backoff.
    base-backoff: 10s
    max-backoff: 1h

    # Total attempts per delivery, first one included. Reaching it marks the
    # delivery 'failed'; it is never retried again.
    max-attempts: 5

    # Connect and read timeout for the outbound POST. The claim lease is
    # derived from it, so a slow receiver cannot strand a delivery.
    timeout: 10s

    # Deliveries claimed per poll pass.
    batch-size: 50
Property Type Default Notes
skills-gateway.webhooks.enabled boolean true false pauses delivery; it does not stop emission.
skills-gateway.webhooks.poll-interval duration 5s Fixed delay between dispatch passes.
skills-gateway.webhooks.base-backoff duration 10s First retry delay; doubles per attempt.
skills-gateway.webhooks.max-backoff duration 1h Ceiling on the doubling.
skills-gateway.webhooks.max-attempts integer 5 Attempt budget per delivery. Zero or negative falls back to the default.
skills-gateway.webhooks.timeout duration 10s Connect and read timeout per attempt.
skills-gateway.webhooks.batch-size integer 50 Deliveries claimed per pass. Zero or negative falls back to the default.

Raising the retry budget raises the retry window

The delays compound: with the defaults a delivery is abandoned about two minutes after the event. max-attempts: 12 with the same base reaches the one-hour cap and keeps a dead receiver in the queue for hours. Receivers must de-duplicate on the delivery id regardless — see Receiving lifecycle webhooks.


Read-only forge mirror

An optional copy of approved content on an external code host, so people can browse and search it with that host's own tools. Off by default: a deployment that sets none of this pushes nothing anywhere.

The mirror is visibility only. It is never a serving surface, never an enforcement path, and nothing about approval, revocation or what the facade serves depends on it. See The read-only forge mirror.

skills-gateway:
  mirror:
    # Nothing below is read until this is true.
    enabled: false

    # The single marketplace this mirrors. Required when enabled.
    marketplace: corp-marketplace

    # The mirror's clone URL. Its scheme must be on
    # skills-gateway.allowed-url-schemes, the same allowlist that governs
    # marketplace registration, and it may not embed a credential.
    url: https://forge.example.com/mirrors/corp-marketplace.git

    # The push credential. Configuration or environment; never committed.
    username: ${SGW_MIRROR_USERNAME}
    token: ${SGW_MIRROR_TOKEN}

    # Per-operation transport timeout.
    timeout: 30s

    # Attempts per mirror update, first one included. Exhausting them leaves the
    # mirror drifted until the next reconciliation; it never affects the decision
    # that triggered the update.
    max-attempts: 3
    retry-delay: 5s

    # The recurring reconciliation: on with the mirror, and the bound on how long
    # a reference the facade no longer serves can stay on the mirror.
    sweep-enabled: true
    sweep-interval: 15m

    # Short on purpose. A restart is when a queued update is lost, so the first
    # reconciliation after startup is the one that closes that gap.
    sweep-initial-delay: 1m
Property Type Default Notes
skills-gateway.mirror.enabled boolean false Nothing else here is read while this is false.
skills-gateway.mirror.marketplace string — Required when enabled. Exactly one marketplace is mirrored.
skills-gateway.mirror.url URL — Required when enabled. Scheme must be on skills-gateway.allowed-url-schemes.
skills-gateway.mirror.username string — Omit for a target that needs no credential.
skills-gateway.mirror.token string — Never logged, audited or echoed by any API.
skills-gateway.mirror.timeout duration 30s Per transport operation.
skills-gateway.mirror.max-attempts integer 3 Attempt budget per mirror update.
skills-gateway.mirror.retry-delay duration 5s Delay between those attempts.
skills-gateway.mirror.sweep-enabled boolean true The recurring reconciliation. Irrelevant while the mirror is disabled; turning it off with the mirror on means drift is corrected only by the next approval or revocation.
skills-gateway.mirror.sweep-interval duration 15m How often it runs, and therefore the bound on how long the mirror may hold a reference the facade no longer serves.
skills-gateway.mirror.sweep-initial-delay duration 1m How long after startup the first one runs.

An enabled mirror with unusable configuration refuses to start

A missing marketplace or URL, a scheme outside the allowlist, or a URL embedding a credential fails startup. Nothing is contacted to decide that — only the configuration is read — so a mirror that is merely down never affects startup or anything else.

Never install from the mirror

Agent installs and CI must keep pointing at the facade. A fetch from the mirror is not on the audit ledger, is not covered by the gateway's access tokens, and a revoked snapshot stops being served by the facade whether or not the mirror has caught up.

sweep-interval is a security setting

It is how long a mirror may keep showing a snapshot the gateway has revoked when the revocation's own push failed. Lengthening it lengthens that window; turning the sweep off removes the bound entirely and leaves the mirror correct only as often as something is approved or revoked. Watch skills_gateway.mirror.stale_refs either way — see Observability.


Audit export

Tuning for the ledger export — the NDJSON pull endpoint and the scheduled exporter that feeds push sinks. Java-side defaults again; nothing appears in application.yaml.

skills-gateway:
  audit-export:
    # Stops the exporter poller only. The pull endpoint keeps serving and sinks
    # keep their cursors, so turning this back on resumes where it left off.
    enabled: true

    # How often each enabled sink is offered the next batch.
    poll-interval: 30s

    # Commit-settling window. The ledger id is a BIGSERIAL assigned before
    # commit, so a lower id can become visible after a higher one; both export
    # paths withhold entries younger than this so a cursor cannot step over an
    # append that was still in flight. The cost is staleness, not correctness.
    lag: 5s

    # Ledger entries per pushed batch, unless the sink overrides it.
    batch-size: 500

    # GET /api/v1/audit/export page size when ?limit= is omitted, and the ceiling
    # it is clamped to when it is given.
    default-page-size: 1000
    max-page-size: 10000
Property Type Default Notes
skills-gateway.audit-export.enabled boolean true false pauses push export; the pull endpoint is unaffected.
skills-gateway.audit-export.poll-interval duration 30s Fixed delay between export passes.
skills-gateway.audit-export.lag duration 5s Entries younger than this are withheld from both paths.
skills-gateway.audit-export.batch-size integer 500 Per-sink default; a sink may set its own, clamped to max-page-size. Zero or negative falls back to the default.
skills-gateway.audit-export.default-page-size integer 1000 Applied when ?limit= is absent. Zero or negative falls back to the default.
skills-gateway.audit-export.max-page-size integer 10000 Hard ceiling on ?limit= and on a sink's batch size. Zero or negative falls back to the default.

lag: 0 reintroduces the skip window

Setting the lag to zero lets a cursor advance past an entry that had an id but was not yet committed — that entry is then never exported, and the gap is silent. Shorten it only with evidence about your commit latency; a compliance feed with a hole in it is worse than one that is seconds behind.

Sinks, cursors and replay are described in Exporting the audit ledger.


Ingestion — upstream credentials

The credentials that private marketplace upstreams are read with. The list is empty by default, and an empty list reads every upstream anonymously. Each entry is one of two kinds: a static username and token, or a github-app that mints a short-lived token per fetch. The task-shaped guide, with examples for local development, Helm and ECS, is Reading a private upstream.

skills-gateway:
  ingestion:
    upstream-credentials:
      # The longest matching prefix wins. Prefixes match whole path segments.
      - url-prefix: https://github.com/acme/
        username: skills-gateway
        # Always an environment reference, never the token itself.
        token: ${SGW_UPSTREAM_ACME_TOKEN}
      # A GitHub App instead of a token: no username or token on this entry.
      - url-prefix: https://github.com/widgets/
        github-app:
          app-id: "123456"
          private-key: ${SGW_UPSTREAM_APP_KEY}
          installation-id: "7890"            # optional
          api-url: https://api.github.com    # optional; GitHub Enterprise Server sets its own
Key Type Default Notes
skills-gateway.ingestion.upstream-credentials list [] One entry per URL prefix. Used by the registration check and by every ingestion and sync fetch.
….upstream-credentials[n].url-prefix URL — https, or http to a loopback host only. No userinfo, query, fragment, dot segments or encoded separators. Scheme and host are compared without case, the port after defaults, and the path by whole segments; a trailing / makes no difference.
….upstream-credentials[n].username string — Sent as the HTTP Basic user.
….upstream-credentials[n].token string — Sent as the HTTP Basic password. Never logged, stored, audited or echoed by any API.
….upstream-credentials[n].github-app.app-id string — The App's numeric id. Required in a github-app block.
….upstream-credentials[n].github-app.private-key string — The App's RSA private key as PEM: PKCS#1 (BEGIN RSA PRIVATE KEY, what GitHub issues) or PKCS#8, unencrypted. A \n written as two characters counts as a line break. Never logged, stored, audited or echoed.
….upstream-credentials[n].github-app.installation-id string none The installation to mint from. When absent, the gateway asks the API which installation covers each repository.
….upstream-credentials[n].github-app.api-url URL https://api.github.com The REST API tokens are minted at. https, or http to a loopback host only; no userinfo, query or fragment. Never taken from a marketplace URL.
  • Validated at startup. The gateway refuses to start on an entry with a blank field, an unresolved ${…} reference, a prefix that breaks the rules above, or a prefix that another entry also declares. It also refuses an entry with both a token and a github-app or with neither, a non-numeric app-id or installation-id, a key it cannot read as RSA, and an api-url that breaks the rules above. The message names the entry's position and prefix, never its token or key.
  • GitHub App tokens. Per fetch the gateway signs an assertion with the key (valid for under ten minutes) and exchanges it for an installation token scoped to the one repository, with contents: read. It reuses the token until five minutes before it expires, and after the upstream refuses a reused token it mints once more. The marketplace URL must be https://<host>/<owner>/<repository>[.git].
  • Sent per request. The credential that the marketplace URL selects rides only on requests under its own prefix, so a redirect elsewhere carries nothing. The forge metadata lookup and external plugin sources are always anonymous.
  • Rotation is a restart. The list is read once, at startup. A GitHub App's installation tokens renew by themselves; its key is changed by a restart.

Ingestion — external plugin sources

Whether a marketplace manifest may declare plugins that live outside the marketplace repository, and — when it may — what resolving them is allowed to reach and to cost (GW_INGEST_0020, GW_INGEST_0023 – GW_INGEST_0026, GW_INGEST_0027). Java-side defaults; nothing appears in application.yaml, and an absent block is the behaviour every existing deployment already has.

skills-gateway:
  ingestion:
    external-sources:
      # false is GW_INGEST_0003's local-only rejection: a manifest declaring a github,
      # git, git-subdir, npm or archive source is refused, snapshot rejected.
      # It is also the only setting under which the gateway makes no outbound
      # request driven by manifest content.
      enabled: false

      # The types an enabled gateway will consider. Only types something can
      # resolve belong here — the allowlist never advertises a form nothing
      # implements, and today only github resolves.
      allowed-types: [github]

      # Exact hosts the derived clone URL may name; empty means any host. Never
      # a suffix or pattern match: github.com does not admit evil-github.com.
      allowed-hosts: []

      # How many external sources one manifest may declare.
      max-sources: 20

      # Where an owner/repo shorthand is resolved, for GitHub Enterprise Server.
      github-base-url: https://github.com

      # Whether a source may resolve to a loopback, RFC1918, carrier-grade-NAT
      # or unique-local address. It never permits a link-local address.
      allow-private-networks: false

      budgets:
        max-received-bytes: 50MB      # per source, on the wire
        max-inflated-bytes: 200MB     # per source, as objects
        max-closure-bytes: 500MB      # every source of one manifest, as objects
        max-inflation-ratio: 100      # uncompressed bytes per received byte
        max-objects: 20000            # per source
        max-blob-bytes: 10MB          # largest single file
        max-tree-depth: 32
        max-redirects: 3
        deadline: 5m                  # whole resolution, wall clock
Key Type Default Notes
skills-gateway.ingestion.external-sources.enabled boolean false false is unchanged GW_INGEST_0003 behaviour, and no manifest-driven outbound request is made.
skills-gateway.ingestion.external-sources.allowed-types list [github] npm and archive are refused whatever this says; git and git-subdir are not resolved yet.
skills-gateway.ingestion.external-sources.allowed-hosts list [] (any) Exact-host matching on the derived clone URL.
skills-gateway.ingestion.external-sources.max-sources integer 20 Counts external sources, not plugins.
skills-gateway.ingestion.external-sources.github-base-url string https://github.com Trailing slashes are trimmed. The derived URL still faces the scheme and host allowlists.
skills-gateway.ingestion.external-sources.allow-private-networks boolean false Loopback, RFC1918, 100.64.0.0/10, fc00::/7. Never link-local.

The nine resolution budgets carry a second number: the furthest this gateway will honour a raise.

Key Type Default Ceiling Notes
…external-sources.budgets.max-received-bytes size 50MB 500MB Enforced on the response stream, so an endless transfer is cut off rather than measured.
…external-sources.budgets.max-inflated-bytes size 200MB 2GB One source's content as objects.
…external-sources.budgets.max-closure-bytes size 500MB 5GB Accumulated across every source one manifest declares.
…external-sources.budgets.max-inflation-ratio integer 100 1000 Judged only once a source's content passes a quarter of max-inflated-bytes; below that the absolute bound already caps it.
…external-sources.budgets.max-objects integer 20000 200000 Blobs and directories one source may contribute.
…external-sources.budgets.max-blob-bytes size 10MB 100MB Largest single file.
…external-sources.budgets.max-tree-depth integer 32 256 Bounds every later walk of the content, not only the fetch.
…external-sources.budgets.max-redirects integer 3 10 Hops one request may take.
…external-sources.budgets.deadline duration 5m 30m Wall clock for resolving a whole manifest.

A budget can be lowered without limit and raised only so far

The Ceiling column is the furthest this gateway will honour. Set a budget above it and the gateway uses the ceiling instead, logs a warning naming the key, the value you configured and the value in force, and starts normally (GW_INGEST_0031 — A configured resolution bound is honoured only as far as the gateway can defend it).

Lowering is never clamped. These bounds exist so that no manifest the gateway will look at can exhaust the gateway, and a bound settable to any value is not that — it is a control an operator can switch off by typing a large number, after which the gateway behaves as an unbounded resolver while its configuration still claims a limit. Every ceiling is far above its default, so a marketplace that legitimately outgrows one still has somewhere to go.

Every budgets.* key except max-redirects is its own sub-requirement of GW_INGEST_0026 — Resource-bounded source resolution, in the order the table lists them: GW_INGEST_0026.1 through GW_INGEST_0026.8. Raising one of them is therefore a decision about one stated bound, and a traceability report names the bound that regressed rather than the whole budget. max-redirects belongs to GW_INGEST_0025 — Address, redirect and transport policy for source resolution, which is where redirect policy lives.

A source's derived clone URL must also satisfy skills-gateway.allowed-url-schemes — the same allowlist that governs registration, so there is one scheme policy for every URL the gateway will ever dereference. A github shorthand is expanded against github-base-url before the scheme and host checks, and a shorthand that is not exactly owner/repo — or that contains a . or .. segment — is refused rather than expanded.

Every breach of a budget, of the address policy or of the redirect policy is a rejected snapshot whose violation names what was exceeded and which plugin caused it, so an operator is led to the number to change rather than to a log.

What an enabled gateway does with a source

An admitted github source is fetched into the marketplace's quarantine repository, grafted into the snapshot under _plugins/<plugin name>/, and the served .claude-plugin/marketplace.json is rewritten so that plugin's source is ./_plugins/<plugin name>. The snapshot is that synthesised commit, and its parent is the commit ingested from upstream — see Snapshots and the ledger.

Refused, each with its own violation and no partial result: a marketplace whose repository already has a top-level _plugins; an external plugin whose name is not a single lowercase path segment; two external plugins sharing a name; and a source declaring a ref or a sha, which this gateway does not resolve at — refused rather than silently resolved somewhere else.

Egress isolation is the control that matters, and it is not in this file

With enabled: true, manifest content — attacker-influenced upstream data — drives gateway-originated fetches. The allowlists and budgets above are defence in depth, not the primary control. Run ingestion egress through a proxy or DMZ with no route to cloud metadata endpoints, internal APIs, or anything holding corporate credentials. The gateway documents the topology; the network enforces it. See Trust boundaries for what the in-application layer does and does not claim, and ADR 0011 for why that ordering was chosen.


Vetting

The vetter chain that runs at ingestion. Java-side defaults; nothing appears in application.yaml.

skills-gateway:
  vetting:
    # How long a single vetter may take before its verdict is recorded as an
    # error — which blocks the snapshot. A wedged vetter must never wedge
    # ingestion, and a vetter that never answers must never look like a pass.
    timeout: 30s

    # Files larger than this are handed to vetters unread. They are reported
    # in one informational 'file-not-scanned' finding per vetter and reason,
    # and in the verdict's coverage summary; never skipped in silence.
    max-file-bytes: 1048576

    # How much of a snapshot's content one chain run may hold so that the
    # vetters after the first read it instead of inflating it again. Past
    # this, content is re-read rather than kept: a snapshot larger than the
    # bound costs speed, never coverage.
    content-cache-bytes: 33554432

    # How often lapsed waivers are noted in the audit ledger. This cannot open a
    # hole: a waiver stops suppressing its finding the moment the effective
    # outcome is next computed, whether or not this sweep has run.
    waiver-sweep-interval: 1h
    waiver-sweep-batch-size: 200

    # The cooling-off window: how long a commit must have been in quarantine
    # before it can be approved. 0 — the default — is no window at all.
    minimum-release-age: 0s

    # The organisation-level license policy, evaluated by the built-in
    # license-scan vetter. Both lists default to empty, under which
    # identified licenses are informational and an unknown or missing license
    # only warns — an upgrade blocks nothing.
    license:
      # SPDX ids. Once non-empty, any license not on the list — and any
      # unknown or missing license — is a blocking finding.
      allowed: [MIT, Apache-2.0, BSD-3-Clause]
      # SPDX ids whose detection is a blocking finding. Checked before the
      # allow list: a license on both is reported as banned.
      banned: [AGPL-3.0]

    # The posture of the built-in skill-conformance vetter, which validates
    # every SKILL.md against the Agent Skills specification vendored in the
    # gateway. Advisory by default: defects are recorded and shown to the
    # reviewer, and block nothing.
    conformance:
      # true makes every conformance defect a blocking finding, waivable like
      # any other. A verdict covers a whole snapshot, so one malformed skill
      # then holds up every other skill in the marketplace.
      enforce: false
Property Type Default Notes
skills-gateway.vetting.timeout duration 30s Per vetter, per run. Exceeding it is an ERROR verdict, which blocks.
skills-gateway.vetting.max-file-bytes integer 1048576 Zero or negative falls back to the default.
skills-gateway.vetting.content-cache-bytes integer 33554432 Content one chain run may retain for reuse across its vetters. Zero or negative falls back to the default.
skills-gateway.vetting.waiver-sweep-interval duration 1h How often waiver-expired ledger entries are written. Has no effect on the gate.
skills-gateway.vetting.waiver-sweep-batch-size integer 200 Lapsed waivers recorded per pass.
skills-gateway.vetting.minimum-release-age duration 0s How long the gateway must have held a commit before it may be approved. 0 disables the gate.
skills-gateway.vetting.license.allowed string list empty SPDX ids, case-insensitive. Empty means no allow list is enforced.
skills-gateway.vetting.license.banned string list empty SPDX ids, case-insensitive. Evaluated before the allow list.
skills-gateway.vetting.conformance.enforce boolean false Whether SKILL.md conformance defects block approval instead of warning.

Minimum release age

The gate refuses POST /api/v1/snapshots/{id}/approve with 409 while the snapshot is younger than this, and the problem document names the setting, the snapshot's current age and the time remaining. Nothing has to run for the wait to end: the age is compared at the instant of each approval request, exactly as waiver expiry is, so a snapshot becomes approvable on its own.

The clock is the gateway's first sighting, not the commit's date

The name mirrors Renovate's, but what is measured is ingestion age: the instant this gateway first ingested that commit, recorded when the snapshot row was created. The commit's own author and committer dates are never read — they are written by whoever made the commit, so a control that trusted them could be defeated by backdating one.

Re-ingesting the same commit does not restart the clock: ingestion recognises the SHA and keeps the existing snapshot, so a re-push cannot reset the window either.

The rejection path is not gated — suspicious content can always be refused at once — and neither is anything about serving already-approved content.

There is deliberately no exemption and no per-approval override, including for the first snapshot of a newly registered marketplace: an exemption is a special case an attacker can arrange to land in. Getting an urgent fix out before the window elapses means changing this setting, which is a deployment someone reviews.

The license policy is configuration on purpose

Vetting policy must be attributable per chain run: the license-scan vetter stamps a digest of these lists into its recorded version, so a changed answer about unchanged content can be traced to the policy change that caused it. That is why the lists change by deploy, not by API — see License compliance for skills. After changing them, trigger a re-vet to turn the new policy into fresh evidence.

There is no switch that turns vetting off

Deliberately. A snapshot with no chain run is blocked either way, so a kill switch would buy an estate of blocked snapshots with no findings to explain them. To get past a vetter that is wrong about a snapshot, waive the findings it raised — scoped, justified and expiring, and on the record.

Conformance enforcement is a decision, not a default

conformance.enforce is false out of the box, and an upgrade therefore blocks nothing: the skill-conformance vetter records every departure from the pinned Agent Skills specification as a warning the reviewer sees. Turning it on makes those same departures blocking — a snapshot with one malformed SKILL.md then needs a waiver per finding before any of its skills can be published, and a SKILL.md too large to read blocks too, because under enforcement "we could not check" must not read as "it conformed". Watch the advisory findings for a cycle first; they are exactly the set that would block.

Like the license lists, the posture is stamped into the vetter's recorded version, so every run names the posture it ran under. Changing it is a deploy, and a re-vet turns the new posture into fresh evidence.

A shortened timeout silently converts slow vetters into blockers

Lowering timeout does not make vetting faster; it makes slow vetters fail. A vetter that times out is recorded as ERROR and blocks the snapshot, so every affected approval then needs a vetter-error waiver — which is a reviewer writing down that the vetter never looked.

The chain, the verdict states, the aggregation rule, waivers, and the honest limits of the built-in vetters are described in Vetting — the vetter chain.

External connectors

An operator can extend the chain with their own vetters — an LLM reviewer, a sandbox, a corporate scanner — under skills-gateway.vetting.external. Each entry becomes a vetter at its configured order, running against the quarantined snapshot alongside the built-ins and recorded by the same fail-closed rules. An empty or absent list changes nothing.

skills-gateway:
  vetting:
    external:
      - name: llm-review              # stable identity, recorded on every verdict;
                                      # must be unique and not a built-in name
        url: https://vet.internal/api/v1/vet   # http/https endpoint the gateway POSTs to
        order: 150                    # chain position; the built-ins are at 100+
        version: "2024-06"            # rule-set identity, stamped into the chain identity
        description: IT Security LLM review of skill instructions
        # Credential sent to the endpoint. Reference an environment variable —
        # never inline a literal. Write-only: never logged, audited or echoed.
        token: ${VETTING_LLM_TOKEN}
        token-header: Authorization   # default; any header name is accepted
        token-scheme: Bearer          # applied only for the Authorization header
        connect-timeout: 5s
        read-timeout: 30s
        # Caps that keep a hostile or broken endpoint from exhausting memory, and
        # a too-large snapshot from earning a verdict about partial content.
        max-request-bytes: 5242880
        max-response-bytes: 1048576
        max-file-bytes: 1048576

The gateway POSTs {snapshotId, marketplace, sha, files[]} — the snapshot's scannable file content, since quarantined content is never served — and expects back a normalized {state, reportUrl, findings[]} where state is one of pass, warn, fail or pending, and each finding carries id, severity (info|low|medium|high|critical), optional location and a message.

Property Type Default Notes
…external[n].name string — Required, unique; must not be a built-in (secret-scan, prompt-injection, executable-surface, license-scan, skill-conformance).
…external[n].url url — Required; http or https only.
…external[n].order integer 1000 Chain position; ties broken by name. Built-ins start at 100.
…external[n].version string 1 Stamped into the chain identity (GW_VETTING_0012). Bump when the external rules change.
…external[n].description string derived Reviewer-facing one-liner.
…external[n].token string none Credential; use ${ENV}. Write-only. Null sends no credential.
…external[n].token-header string Authorization Header the credential is sent in.
…external[n].token-scheme string Bearer Prefix, applied only for Authorization. Blank sends the raw value.
…external[n].connect-timeout duration 5s Exceeding it is an ERROR verdict, which blocks.
…external[n].read-timeout duration 30s Exceeding it is an ERROR verdict, which blocks.
…external[n].max-request-bytes integer 5242880 A snapshot whose scannable content exceeds this fails closed rather than shipping partial evidence.
…external[n].max-response-bytes integer 1048576 A larger response fails closed.
…external[n].max-file-bytes integer 1048576 Per-file cap on content in the bundle; a larger file is sent unscanned, not dropped.

An external connector fails closed, hard

Every way the endpoint can fail to return a verdict the gateway can stand behind — unreachable, connection refused, timeout, non-2xx, empty, oversized, unparseable, an unrecognised state, or a malformed finding — is recorded as an ERROR verdict, which blocks the snapshot. A flaky external endpoint blocks approvals; it never lets content through. The recorded state is also the worse of the state the endpoint declared and the state its own findings imply, so an endpoint cannot return pass alongside a critical finding and have it pass.

External connectors are configuration for the same reason the license policy is

Their identity and version are stamped into every run's chain identity (GW_VETTING_0012), so a changed answer about unchanged content is attributable. That is why they are declared by deploy, not managed through the API. A pending answer is the asynchronous seam: it is recorded as PENDING and blocks until resolved — the inbound resolution callback is a separate, later capability.

See Adding an external vetter for the wire contract in full and a minimal working example.


Continuous re-vetting

Re-running the chain over content that is already approved and served, and what a fresh violation on it does. Java-side defaults; nothing appears in application.yaml.

skills-gateway:
  vetting:
    revet:
      # Whether the scheduled sweep runs. ON by default: it only ever produces
      # evidence — a new chain run over pinned content — and never retracts
      # anything on its own. The on-demand endpoints work regardless.
      enabled: true

      # WARN (default): a violation is recorded and announced in full, and the
      # snapshot stays approved and published.
      # ENFORCE: a violation revokes the snapshot and removes its published refs.
      mode: warn

      # How often the sweep runs.
      interval: 6h

      # A snapshot is re-vetted only when its newest run is older than this. With
      # batch-size, this is what stops a tick re-vetting the whole estate: the
      # sweep takes the oldest-vetted snapshots first and covers the rest in
      # rotation over later ticks.
      cadence: 24h
      batch-size: 25
Property Type Default Notes
skills-gateway.vetting.revet.enabled boolean true Runs the scheduled sweep. POST /api/v1/snapshots/{id}/revet and POST /api/v1/marketplaces/{name}/revet are unaffected.
skills-gateway.vetting.revet.mode warn | enforce warn What a violation does. warn never unpublishes anything.
skills-gateway.vetting.revet.interval duration 6h Sweep schedule.
skills-gateway.vetting.revet.cadence duration 24h Minimum age of a snapshot's newest run before the sweep picks it again.
skills-gateway.vetting.revet.batch-size integer 25 Approved snapshots re-vetted per pass.

enforce unpublishes content teams are already using

Under enforce, a re-vetting violation revokes the snapshot and removes the refs the facade serves it through — with no person in the loop. The next git fetch by every consuming team fails. Run warn for at least one full sweep cycle first and read the revet-violation ledger entries: each one names the identities that had already fetched the snapshot, which is the blast radius enforce would have caused.

A broken vetter never revokes anything

A run that blocks only because a vetter errored, timed out, or has not answered is recorded as inconclusive, and leaves the snapshot approved and served in either mode. Retraction needs a vetter that objects to the content. This does not loosen the approval gate: an inconclusive run still blocks approving, re-approving or publishing that snapshot.

The sweep, the revoked state, and the re-approval path are described in Re-vetting approved content.


Separation of duties (four-eyes)

Whether the identity that supplied a snapshot may also be the one that approves it. Java-side defaults; nothing appears in application.yaml.

skills-gateway:
  approval:
    four-eyes:
      # WARN (default): a conflict is recorded on the audit ledger and the
      # approval proceeds.
      # ENFORCE: the approval is refused, and the snapshot stays held.
      mode: warn
Property Type Default Notes
skills-gateway.approval.four-eyes.mode warn | enforce warn What a detected conflict does. warn never refuses an approval; enforce refuses it and publishes nothing.

A reviewer conflicts with a snapshot when they are any of:

Conflict Meaning
registered-by They registered the marketplace the snapshot came from.
ingested-by They triggered the ingestion that pinned it.
waiver-author They wrote a waiver this approval relies on — the response names which one.

The two automated sync triggers, scheduler and webhook, are recorded as the ingestion actor but are never conflicts: a scheduled poll is nobody's judgement about the content. Neither is an unrecorded actor, which is what marketplaces and snapshots that predate this release carry.

There is no way to switch detection off

warn is the floor, not a disabled state. Whatever the mode, every detected conflict is appended to the audit ledger as a four-eyes-conflict entry naming the acting identity, the snapshot, the mode, whether the approval proceeded, and each conflicting act. That is what makes warn measurable rather than merely permissive — and what lets an operator size up enforce from evidence before turning it on.

enforce needs at least two principals who can approve

Under enforce a marketplace whose only approver also registered it, or ingests its content, has no one left who may publish it. Before switching, check that every marketplace that needs deciding has a second identity with approval rights — a second admin, or an approver scoped to that marketplace under delegated administration. This is why the default is warn: a single-administrator deployment must keep working across an upgrade.

Identities are compared as exact strings, as the identity provider reports them through the configured principal claim. The comparison assumes what is true of a single provider — that one person is one principal string — and makes no attempt to reconcile two spellings of the same human.

Refusals are visible to reviewers before they act: the portal's approve dialog says which acts conflict, and GET /api/v1/snapshots/{id}/four-eyes answers the same question for the calling identity. Rejecting a snapshot is never gated — refusing content quickly must not need a second pair of eyes.

The rule as part of the approval boundary is described in Approving snapshots.

Plugin-name collisions

Whether an approval is refused when the snapshot introduces a plugin name that looks like one another marketplace already serves. Java-side default; nothing appears in application.yaml.

skills-gateway:
  approval:
    name-collision:
      # true (default): a lookalike of an approved plugin in another marketplace
      # is refused until a waiver on rule plugin-name-collision covers it.
      enabled: true
Property Type Default Notes
skills-gateway.approval.name-collision.enabled boolean true false refuses nothing and reports nothing.

On by default, because enabling it withdraws nothing: only names new to a marketplace are checked, only the snapshot seeking approval is judged, and an estate that already contains lookalikes keeps serving them. What it matches is described in Vetting.

Switching it off leaves typosquatting uncovered

The switch exists for an estate built on deliberate forks, where every lookalike is legitimate and a refusal would only be waived unread. Anywhere else, accept a fork with a waiver rather than turning the rule off.


Retention

Snapshot retention: which snapshots the gateway may delete, and when the two passes run. Java-side defaults; nothing appears in application.yaml.

skills-gateway:
  retention:
    # OFF by default. Retention only ever deletes because an operator asked for
    # it; an upgrade never starts deleting on its own. The on-demand endpoints
    # work regardless, so a policy can be inspected before this is flipped.
    enabled: false

    # Evaluation marks (soft delete); compaction removes (hard delete). They are
    # separate schedules because the restore window only means anything if the
    # two are decoupled in time.
    poll-interval: 1h
    compaction-interval: 6h

    # Snapshots considered per marketplace per pass.
    batch-size: 200

    # How long an abandoned publication staging ref must have been under
    # observation before compaction may remove it. Not a tuning knob: it is what
    # keeps the sweep from overtaking a publication that is still transferring
    # objects. Set it above your slowest publication. Zero or negative switches
    # the sweep off rather than sweeping everything.
    staging-ref-max-age: 24h

    # The policy every marketplace inherits.
    defaults:
      # A snapshot still 'held' this long after ingestion is eligible.
      # Zero or negative disables the criterion rather than selecting everything.
      held-max-age: 90d

      # Whether a held/rejected snapshot overtaken by a later approved snapshot
      # of the same marketplace is eligible.
      superseded: true
      superseded-min-age: 30d

      # Veto, not a selector: a candidate whose SHA was fetched through the
      # facade within this window is dropped from the pass.
      min-idle: 30d

      # How long a soft-deleted snapshot stays restorable before compaction may
      # remove it. Resolved at deletion time, not at compaction time.
      restore-window: 14d

    # Per-marketplace overrides; unset fields fall back to defaults.
    marketplaces:
      acme:
        held-max-age: 30d
Property Type Default Notes
skills-gateway.retention.enabled boolean false Runs the scheduled passes. The on-demand endpoints are unaffected.
skills-gateway.retention.poll-interval duration 1h Evaluation (soft delete) interval.
skills-gateway.retention.compaction-interval duration 6h Compaction (hard delete) interval.
skills-gateway.retention.batch-size integer 200 Snapshots per marketplace per pass. Zero or negative falls back to the default.
skills-gateway.retention.staging-ref-max-age duration 24h How long an abandoned publication staging ref must be observed before compaction removes it. Zero or negative disables the sweep.
skills-gateway.retention.ledger-max-age (unset) How old an audit-ledger read entry must be before the compaction pass may remove it. Unset, zero or negative switches the trim off, and it removes nothing regardless unless an enabled export sink has already taken the entries — see Bound the audit ledger.
skills-gateway.retention.defaults.held-max-age duration 90d Zero or negative disables the criterion.
skills-gateway.retention.defaults.superseded boolean true Enables the supersession criterion.
skills-gateway.retention.defaults.superseded-min-age duration 30d Minimum age of a superseded snapshot.
skills-gateway.retention.defaults.min-idle duration 30d Fetch-recency veto window.
skills-gateway.retention.defaults.restore-window duration 14d Restore window applied at deletion time.
skills-gateway.retention.marketplaces.<name>.* policy (empty) Same keys as defaults; unset fields inherit.

Enabling retention starts deleting on the next pass

Preview first: GET /api/v1/retention/candidates shows exactly what the policies in force would select, and writes nothing. Approved snapshots are never eligible, but a generous held-max-age can still clear a large review backlog on the first pass.

Compaction cannot be undone

A soft-deleted snapshot is restorable only until its purge_after. After compaction the row and the unreachable git objects are gone, and only the ledger entry remains. Size restore-window so a mistake is noticed inside it.

Criteria, guards and the two passes are described in Snapshot retention.


Upstream sync

How automated ingestion behaves for marketplaces whose sync mode is scheduled or webhook. Java-side defaults; nothing appears in application.yaml.

skills-gateway:
  sync:
    # Whether the scheduled polling sweep runs. ON by default, and still safe
    # on upgrade: the sweep only touches marketplaces an operator has
    # explicitly moved to the `scheduled` sync mode, so an estate of defaults
    # (all on-demand) sees no change. The inbound webhook endpoint and the
    # sync-mode endpoint work regardless.
    enabled: true

    # How often the sweep runs, and how many scheduled marketplaces one pass
    # ingests — least recently attempted first, so a large estate is covered
    # in rotation rather than all at once.
    poll-interval: 10m
    batch-size: 10

    # Inbound webhook bodies larger than this are rejected with 413 before the
    # HMAC is computed, bounding the work an unauthenticated caller can cause.
    max-webhook-body-bytes: 1048576
Property Type Default Notes
skills-gateway.sync.enabled boolean true Runs the scheduled sweep. Touches only scheduled-mode marketplaces.
skills-gateway.sync.poll-interval duration 10m Sweep schedule.
skills-gateway.sync.batch-size integer 10 Scheduled marketplaces ingested per pass, oldest attempt first.
skills-gateway.sync.max-webhook-body-bytes long 1048576 Inbound webhook body bound; larger requests get 413 unverified.

Modes, the webhook secret lifecycle, and the outage guarantee are described in Syncing from upstream automatically.


Virtual catalog

The synthesized one-URL catalog of the whole served estate. Java-side defaults; nothing appears in application.yaml.

skills-gateway:
  catalog:
    # Whether approvals and revocations rebuild the catalog and the /api/v1/catalog
    # endpoints answer. Turning this off never deletes an existing catalog repo.
    enabled: true

    # The catalog's facade path (/git/{name}) and its RESERVED name: a
    # marketplace cannot be registered under it.
    name: catalog
Property Type Default Notes
skills-gateway.catalog.enabled boolean true Gates rebuild triggers and the API endpoints.
skills-gateway.catalog.name string catalog Facade path segment; reserved at registration.

Composition, freshness, and provenance are described in The virtual catalog.


Access tokens

Token policy (GW_AUTH_0007). Java-side default; nothing appears in application.yaml.

skills-gateway:
  tokens:
    # The longest lifetime creation accepts. When set, a request beyond it —
    # including one with no expiry at all — is refused with 422, never
    # silently shortened. Unset (the default) accepts tokens that never
    # expire, which is what every pre-cap deployment had.
    max-ttl: 90d
    # What a session-derived credential is GRANTED (GW_AUTH_0018), as opposed to
    # what a holder may ask for. Not derived from max-ttl on purpose: a
    # deployment may allow year-long CI tokens and still want a credential
    # minted from a browser session to die at the end of the working day.
    session-ttl: 8h
Property Type Default Notes
skills-gateway.tokens.max-ttl duration unset (unlimited for personal access tokens; 90 days for machine API credentials) Cap on accepted token lifetime; refusal, not clamping.
skills-gateway.tokens.session-ttl duration 8h Lifetime granted to a session-derived credential. The caller cannot influence it.

A machine API credential is always capped, even with max-ttl unset

A machine API credential must state an expiry, but "mandatory expiry" alone admits now + 100 years whenever no cap is configured — which is the never-expiring credential in a CI variable, spelled differently. Machine credentials are therefore held to a built-in cap of 90 days when max-ttl is unset.

Ninety days is a quarter: short enough that a forgotten credential expires within one planning cycle rather than outliving the service it was minted for, and long enough that rotating it is a scheduled chore rather than an interruption. A configured max-ttl, longer or shorter, always wins — and that is the point. A long-lived control-plane credential should be a stated choice, not the consequence of leaving a property blank.

The cap does not change personal access tokens, whose behaviour under an unset max-ttl is exactly what it was.

Scopes, expiry, and rotation are described in Access tokens; session-derived credentials in Consuming skills.


Delegated administration

Role enforcement for the web surface (GW_AUTH_0010, GW_AUTH_0013). Java-side defaults; nothing appears in application.yaml.

Enforcement is unconditional (GW_AUTH_0025): every mutation and the ledger surface need a role, in every deployment. There is nothing to switch on.

skills-gateway.roles.enabled was removed, and setting it refuses startup

It used to switch enforcement off, and defaulted to off. A gateway that was installed and left alone therefore granted full administrative access to anyone who could complete a login. Enforcement is now unconditional, so the property has nothing to turn on or off — and it is refused rather than ignored, under any spelling the framework would resolve (SKILLSGATEWAY_ROLES_ENABLED included). Quietly doing the opposite of what a manifest says is not something an operator can see from inside the deployment.

Remove the property. The refusal is a migration aid and is scheduled for removal at the next major version — see Compatibility and allowlists.

skills-gateway:
  roles:
    # Admins by configuration: effective without a grant row and unrevocable
    # through the API — the escape hatch that survives a bad grant edit.
    admins:
      - admin@example.com
    # Roles from the identity provider's own claims (GW_AUTH_0015.1). The claim name
    # and every value are yours: on a shared app registration the values are
    # the organisation's group ids or app-role values, not gateway role names.
    claim: groups
    mappings:
      - claim-value: 8f1c0a2e-0000-0000-0000-000000000000
        role: admin
      - claim-value: gateway-approvers-acme
        role: approver
        marketplace: acme
      - claim-value: security-auditors
        role: auditor
Property Type Default Notes
skills-gateway.roles.admins list of strings [] Principals that hold the admin role by configuration, matched exactly against the authenticated principal name. Effective without a grant row, and no API call can revoke them. Stored grants and claim-derived roles add to this list; they never replace it.
skills-gateway.roles.claim string groups Claim carrying membership. A dotted path walks nested claims (realm_access.roles).
skills-gateway.roles.mappings list [] Claim value → role. approver names a marketplace; admin and auditor must not.

Naming an administrator in roles.admins, in roles.mappings, or in estate.grants is required: enforcement is unconditional, so a gateway that names none could not be administered once it started, and it refuses to start instead. A grant made through the API does not satisfy the check — it is revocable through the API, so it says nothing about the next start.

admins or mappings?

Both name administrators, and neither replaces the other.

roles.admins roles.mappings
Matches on the authenticated principal name, exactly a claim value the provider emits, exactly
Granularity individuals only — a group name is not a principal name and would match nobody whatever the claim carries, so typically a whole group or app role at once
Roles it can confer admin only admin, auditor, and approver scoped to one marketplace
Changing who holds it edit configuration, restart change directory membership; no gateway change
/api/v1/me source config claim
Survives a directory outage, an unassigned role or a wrong claim name yes no

Prefer mappings for everyone whose access the directory already governs — the joiner/mover/leaver process then governs gateway access too, and there is no second list to keep in step. Keep at least one entry in admins regardless: it needs no group, no token and no directory, and it is what gets you back in when a group is renamed, a claim stops being emitted or a mapping is wrong.

The third source, a row in the grants API, is described in Delegated administration; a session's effective roles are the union of all three.

Claim values are matched exactly, after trimming surrounding whitespace: no prefix, glob or case-insensitive matching, because a looser match can only ever widen who is privileged. The claim may be a list of strings or a single string; a delimited string is one value and is never split. A malformed mapping — an unknown role, a blank value, an unscoped approver, a scoped global role — refuses startup rather than silently granting nothing. A mapping may name a marketplace that is not registered yet; until it is, the mapping matches nothing.

Claims are read only from a browser session established through the identity provider. A personal access token on the git facade, the dev-insecure-auth principal and the anonymous webhook request carry no claims and derive no role, whatever authorities they hold.

The roles, the enforcement matrix, and the staging workflow are described in Delegated administration; the mapping walkthrough in Identity providers; the grants API in Roles.


Declarative estate

The estate defined as configuration (GW_ESTATE_0001–GW_ESTATE_0005, GW_APPROVAL_0006): marketplaces, role grants, webhook subscribers, audit export sinks and policy deny rules, reconciled at startup — after schema migration, before the web surface serves — and on demand via POST /api/v1/estate/reconcile. Empty by default; an empty declaration reconciles nothing.

Reconciliation is additive and idempotent. A declared object is created if missing and converged if drifted; an object absent from the declaration is never deleted, deregistered or revoked — removing a line from this block retracts nothing. A converged estate reconciles with zero writes and zero ledger entries; every applied change lands on the append-only ledger under the same event name as its API equivalent, attributed to the actor config-reconciler. An entry that fails validation is skipped and reported — in the log at ERROR, on the ledger as estate-reconciliation-failed, and in the reconciliation report — and never prevents startup or the other entries.

skills-gateway:
  estate:
    # Registered through the exact same gate as POST /api/v1/marketplaces:
    # name rules, the reserved catalog name, the URL scheme allowlist.
    # There is no ref key — the ingested ref is always the gateway's
    # decision (the upstream default branch).
    marketplaces:
      - name: corp-marketplace
        url: https://github.com/acme/skills-marketplace.git
        # on-demand or scheduled; applied through the same audited path as
        # PUT /api/v1/marketplaces/{name}/sync. Omit to leave the stored mode
        # alone. webhook mode is refused: its inbound HMAC secret is
        # generated and shown once, which has no declarative form.
        sync-mode: scheduled
      # A gateway-hosted marketplace declares no url and is published to by
      # pushing (GW_FACADE_0006). Its sync mode is fixed at on-demand: the push is
      # its ingestion trigger. push-policy defaults to append-only.
      - name: platform-skills
        origin: hosted
        push-policy: append-only

    # The exact shape of POST /api/v1/roles: approver grants name one
    # marketplace that must exist at reconcile time (declared above, or
    # registered through the API); admin and auditor grants must not.
    grants:
      - principal: alice@example.com
        role: approver
        marketplace: corp-marketplace
      - principal: audit@example.com
        role: auditor

    # Webhook subscribers with an OPERATOR-SUPPLIED signing secret — the
    # inversion of the API's generated show-once secret. Reference an
    # environment variable; never inline a literal in a committed file.
    webhooks:
      - name: ci-bot
        url: https://ci.example.com/hooks/skills-gateway
        events:                                 # omit for all events
          - marketplace.snapshot.approved
          - marketplace.snapshot.rejected
        secret: ${SGW_ESTATE_CI_BOT_SECRET}

    # Audit export sinks; same secret contract as webhooks.
    audit-sinks:
      - name: siem
        url: https://siem.example.com/ingest/skills-gateway
        secret: ${SGW_ESTATE_SIEM_SECRET}
        after: 0          # cursor seed — applied at creation ONLY
        batch-size: 500

    # CEL policy deny rules, through the same compiled, audited path as
    # POST /api/v1/policy/rules: an expression that does not compile to a
    # boolean is an isolated entry failure, never a stored rule.
    policy-rules:
      - name: no-shell-tools
        description: deny skills declaring shell tools
        expression: 'skills.exists(s, s.tools.exists(t, t.startsWith("Bash")))'
        # enabled defaults to true: a declared rule is declared to enforce
Property Type Default Notes
skills-gateway.estate.marketplaces list [] Each entry: name, url, optional sync-mode (on-demand/scheduled).
skills-gateway.estate.marketplaces[].name string — Same rules as the API: ^[a-z0-9][a-z0-9_-]{0,62}$ (at most 63 characters), catalog name reserved.
skills-gateway.estate.marketplaces[].url string — Scheme must be allowlisted. Immutable once registered: a differing declared URL is a reconciliation failure, never an update.
skills-gateway.estate.marketplaces[].sync-mode string unset (not managed) on-demand or scheduled; webhook is refused. Unset never touches the stored mode.
skills-gateway.estate.grants list [] Each entry: principal, role (admin/approver/auditor), marketplace (required for approver, forbidden otherwise).
skills-gateway.estate.webhooks list [] Each entry: name, url, optional events (a list; omitted means every event), secret.
skills-gateway.estate.webhooks[].secret string — Operator-supplied, minimum 16 characters, write-only. A changed value rotates the stored secret idempotently.
skills-gateway.estate.audit-sinks list [] Each entry: name, url, secret, optional after, optional batch-size.
skills-gateway.estate.audit-sinks[].after long 0 Seeds the ledger cursor at creation only; never re-applied to an existing sink.
skills-gateway.estate.audit-sinks[].batch-size int audit-export default Converged when set; unset never touches the stored value.
skills-gateway.estate.policy-rules list [] Each entry: name, optional description, expression, optional enabled. See Policy deny rules.
skills-gateway.estate.policy-rules[].name string — Same rules as the API: ^[a-z0-9][a-z0-9_-]*$, at most 100 characters.
skills-gateway.estate.policy-rules[].expression string — CEL over the policy variables; compiled to a boolean at reconcile time — a non-compiling expression is an isolated entry failure.
skills-gateway.estate.policy-rules[].enabled boolean true Enabled rules gate every approval fail-closed; description, expression and this flag are converged on drift.

Declared secrets are sensitive property values

The gateway treats a declared secret as write-only — it never appears in the log, the ledger, the reconciliation report, or any API response — but it cannot control where the configuration lives. Supply secrets by environment-variable reference (${VAR}) from a secret store; a literal in a committed values file is a leaked credential. A blank or shorter-than-16 secret is refused as a reconciliation failure.

Personal access tokens are deliberately not declarable: they are user-owned credentials, API-only by design. The GitOps workflow is described in Declarative estate configuration; the report and trigger endpoints in Estate.


Datasource

Not present in application.yaml at all — supplied purely by environment. PostgreSQL is assumed.

$ export SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/skillsgateway
$ export SPRING_DATASOURCE_USERNAME=skillsgateway
$ export SPRING_DATASOURCE_PASSWORD=skillsgateway

The Helm chart exposes these as postgresql.host (required, no default), postgresql.port (5432), postgresql.database and postgresql.username (both skillsgateway), and postgresql.existingSecret (required — a Secret with a password key).

Flyway is deliberately unconfigured

There are no spring.flyway.* settings. The schema is built from a single consolidated migration on startup, because every environment — including Testcontainers and the e2e compose stack — builds it from scratch.

That the schema is one migration rather than a chain is a consequence of being pre-1.0: with no compatibility promised between versions, there is no deployed database to upgrade, so a schema change edits V1__init.sql instead of adding a version behind it. At 1.0.0 that reverses — V1 freezes and changes become incremental migrations.


OIDC login

The registration id is idp. The defaults below are placeholders rather than absent values, so the registration always exists and a gateway with none of these set still starts — it simply cannot complete a login.

Property Environment variable Placeholder default
…client.registration.idp.client-id SGW_OIDC_CLIENT_ID change-me
…client.registration.idp.client-secret SGW_OIDC_CLIENT_SECRET change-me
…client.provider.idp.authorization-uri SGW_OIDC_AUTHORIZATION_URI https://idp.invalid/authorize
…client.provider.idp.token-uri SGW_OIDC_TOKEN_URI https://idp.invalid/token
…client.provider.idp.jwk-set-uri SGW_OIDC_JWK_SET_URI https://idp.invalid/jwks
…client.provider.idp.user-name-attribute SGW_OIDC_USER_NAME_ATTRIBUTE sub
…client.registration.idp.scope SGW_OIDC_SCOPE openid
…client.registration.idp.redirect-uri SGW_OIDC_REDIRECT_URI {baseUrl}/login/oauth2/code/idp

user-name-attribute decides what the principal is called everywhere else — grants, roles.admins, and every ledger row. On an app registration shared between services, sub is an opaque per-application identifier, so set this to a readable claim such as preferred_username there. Widen SGW_OIDC_SCOPE when your provider needs a scope before it will emit group or role claims.

The grant type is fixed at authorization_code. The redirect URI to register with the provider is https://<your-host>/login/oauth2/code/idp; by default the gateway derives it from the request ({baseUrl}), which behind a TLS-terminating proxy is right only with a forwarded-header strategy in force. SGW_OIDC_REDIRECT_URI states it absolutely instead, trusting no header — the escape hatch when the derivation cannot be made to work, not a substitute for it, since every other URL the gateway builds from the request stays as the container sees it.

Moving any of these off its placeholder is what tells the gateway an identity provider exists — and a gateway with one configured refuses to start with skills-gateway.dev-insecure-auth on. See skills-gateway above.

Expected issuer

Property Type Default Notes
skills-gateway.oidc.issuer string (unset) ID-token issuer to require. Required once an identity provider is configured: the gateway refuses to start without it.

The gateway configures its provider endpoints explicitly rather than by issuer discovery, and Spring Security compares an ID token's iss only when the registration carries an issuer — so with this unset, nothing checks it. That matters most where one authorization endpoint serves many tenants: every tenant's tokens verify against the same signing keys, so the issuer is the only thing that says which organisation the person logging in belongs to.

A configured provider needs an issuer

The gateway refuses to start when an identity provider is configured — a client id other than change-me, or a provider endpoint off the idp.invalid host — and this is unset or blank. The refusal names the property and the settings that showed a provider to be configured. Set it to the issuer value of your provider's /.well-known/openid-configuration. Unset is accepted only while the shipped placeholders are in force, and under skills-gateway.dev-insecure-auth, whose own guard refuses a configured provider.


Identity-provider bearer tokens on the facade

Off by default. When enabled, /git/** accepts an OIDC access token or ID token from the identity provider configured above, in addition to a personal access token — see the facade reference and ADR 0019.

skills-gateway:
  facade:
    idp-bearer:
      enabled: false
      audience: # unset — defaults to the OAuth2 client id
Property Type Default Notes
skills-gateway.facade.idp-bearer.enabled boolean false Whether /git/** accepts a bearer token. While false, no decoder, provider or filter is registered at all.
skills-gateway.facade.idp-bearer.audience string (unset) The value aud must contain. Unset means spring.security.oauth2.client.registration.idp.client-id.

There is deliberately no issuer or key-set property here. The key set comes from the idp client registration's jwk-set-uri and the issuer from skills-gateway.oidc.issuer, so the facade cannot be configured to accept a token the browser login would refuse.

Enabling this requires a pinned issuer

With enabled: true and skills-gateway.oidc.issuer unset or blank, the gateway refuses to start, naming both properties. A signature check alone cannot separate one tenant of a shared authorization endpoint from another, and the login path's provenance — the gateway itself exchanged a code over TLS with a configured endpoint — does not exist for a bearer token arriving at the facade.

A token is accepted only if its signature verifies against the published key set, its iss equals the pinned issuer, its aud contains the expected audience, and the current time is within nbf/exp (60 seconds of clock leeway). Anything else is refused with the same bare 401 an unknown PAT gets.

Revocation belongs to the identity provider

The gateway cannot revoke a bearer token. Its lifetime is the provider's — usually minutes to an hour — and that short life is the control. A deployment that needs gateway-side revocation keeps using access tokens, which is why both exist.


server:
  servlet:
    session:
      cookie:
        same-site: lax

Named rather than left to the browser, because Firefox and Safari apply no default at all: unset, the session cookie rides a cross-site request in those browsers.

Strict is deliberately not used. It withholds the cookie on the identity provider's redirect back to /login/oauth2/code/idp — a cross-site top-level navigation that has to carry the session holding the saved authorization request — so login fails outright. Lax still withholds it on every cross-site POST, which is the vector. Naming the attribute is one half of GW_AUTH_0030 — A cross-site request cannot act on an ambient session; the control itself is the CSRF token on the web chain, described under The web surface.


Actuator

management:
  endpoints:
    web:
      exposure:
        include: health,sbom

Only two endpoints are exposed. /actuator/health is the only unauthenticated path in the application; /actuator/sbom serves the CycloneDX SBOM and requires a session.

Probe the bare health path

The security chain permits exactly /actuator/health. Subpaths such as /actuator/health/liveness redirect to the identity provider, which is why both Kubernetes probes point at the bare path.


API documentation

scalar:
  enabled: true
  path: /docs        # the Scalar UI
  url: /v3/api-docs  # the OpenAPI document it renders
  theme: purple
  with-default-fonts: false

Both paths sit behind the OIDC login like the rest of the web surface.

with-default-fonts is off because Scalar otherwise loads its fonts from fonts.scalar.com, which the web surface's content security policy refuses. With it off, the reference renders in the browser's own fonts. The page at path is the one page whose policy allows inline script, because Scalar starts itself with one. Moving path moves that exception with it. See Trust boundaries.


Forwarded headers

Property Environment variable Default Values
server.forward-headers-strategy SERVER_FORWARDHEADERSSTRATEGY (unset) native, framework, none

The gateway serves plain HTTP and terminates no TLS, so the request it sees names its own host and port over http. Every URL it builds from the request — the OIDC redirect URI above all — is therefore wrong behind a TLS-terminating proxy until it believes the scheme and host the proxy reports in X-Forwarded-Proto and X-Forwarded-Host. This is Spring Boot's own setting, with Spring Boot's own meanings:

Value Effect
native Tomcat's RemoteIpValve: the headers are honoured only from a peer whose address is in server.tomcat.remoteip.internal-proxies — by default the private ranges 10/8, 172.16/12, 192.168/16, 100.64/10, loopback and their IPv6 counterparts. X-Forwarded-For also replaces the remote address the fetch ledger records. Recommended.
framework Spring's ForwardedHeaderFilter: the headers, X-Forwarded-Prefix and RFC 7239 Forwarded are honoured from any peer. The remote address is left as the proxy's.
none The headers are ignored.
(unset) native when Spring Boot detects a container platform from the environment (Kubernetes and ECS among them), otherwise none.

Trusting the headers is a security decision: the gateway cannot tell a proxy's header from a client's, so framework belongs only where nothing but the proxy can reach the listener and the proxy overwrites the headers, and native belongs where the proxy's address is inside the internal ranges. The Helm chart sets native through its forwardHeadersStrategy value; other runtimes set the variable — see Running behind a proxy.

The gateway registers the framework filter itself and reads the setting at runtime (GW_AUTH_0029 — Proxy-reported scheme and host are honoured only when configured, identically on every packaging). Spring Boot's own registration is behind a @ConditionalOnProperty, which an ahead-of-time image evaluates when it is built, with the property unset — so on the GraalVM native image the release used to publish it was never compiled in and no runtime value could switch it on. native never had that problem: Tomcat's valve is installed by a customizer that reads the property at runtime. The released image is a JVM container today (ADR 0012 — A JVM container is the release artifact), where neither shape can occur; the explicit registration stays because it is what makes the setting mean one thing on every packaging rather than on the one that happens to ship.


Server and storage notes

Port. Never set; the Spring default 8080 applies. Compose publishes 8080:8080 and the Helm chart hardcodes containerPort: 8080, so changing server.port means editing the chart, not just values.

Container storage. The image sets SKILLSGATEWAY_DATADIR=/data — the relaxed-binding environment form of skills-gateway.data-dir.

JGit's own scratch. The image sets XDG_CONFIG_HOME=/tmp/xdg-config, and the chart restates it in the Deployment (GW_FACADE_0024 — The embedded git library's own scratch stays inside the writable temporary directory). JGit resolves a filesystem-timestamp-resolution cache file there the first time it touches a repository; left unset, it falls back to a home directory this user does not have under a read-only root filesystem, and fails — caught, not fatal, but logged — on every fetch.

Volume durability is a decision the chart makes you state. There is no default: persistence.mode must be existingClaim (a PersistentVolumeClaim that already exists), ephemeral (an emptyDir, and everything on it is lost on restart), or none (no volume at all, accepted only on the object-store backend). Anything else — including leaving it empty — stops the render with a message that spells out the consequence. See Choosing and migrating the storage backend.

What the chart refuses. Alongside the durability choice it will not render an object-store selection with no bucket or no region, a static credential mode with no secret to take the keys from, a web-identity mode with no annotation on the service account, persistence.mode: none on the filesystem backend, or more than one replica on the filesystem backend. Each refusal names the value it was decided by.

Background passes coordinate themselves across replicas. Every scheduled pass — sync, re-vetting, retention evaluation and compaction, waiver expiry, webhook dispatch, mirror reconciliation and audit export — takes a lease in the gateway's own database before it runs, so the estate gets one pass per interval whatever the replica count (GW_FACADE_0030 — A scheduled background pass runs on one replica at a time). There is no property for it. A lease lasts its pass's own interval, which is the property already listed for that pass above, so there is nothing extra to set and nothing to keep consistent between replicas. See Running more than one replica.