The map registry. It owns the answer to two questions:
- which content hash is which version of which map, and
- which version players currently get.
Bytes never travel through this service. They live in R2 keyed by their own sha256;
clients upload with a presigned PUT and read from cdn.grounds.gg. Game servers do not
call this service at all — they read pins/<env>.json and the bundles it points at
straight off the CDN, so a pod can boot with this service down.
Architecture: Maps & Asset Distribution · Build Server & Builder Experience
| The gameplay services | Here | |
|---|---|---|
| Transport | gRPC on :9000 |
HTTP + JSON on :9000 — the callers are the portal's BFF proxy, the build server's Paper plugin and staff tooling |
| Auth | projected ServiceAccount token, verified against the k8s JWKS | Keycloak bearer — the build server runs on the grounds-dev spoke while this runs on core, and a k8s token from one cluster does not verify against the other's JWKS |
Everything else follows the house shape: Quarkus/Kotlin, Flyway into its own schema,
plain JDBC over the injected DataSource, distroless Java 25 runtime, the shared CI
workflow.
| Action | Granted by |
|---|---|
| Create a map, upload, commit a version, fork | a group in author-groups, or owning the u/<sub> namespace |
| Publish a version (makes it pinnable) | a group in publish-groups, or owning the namespace |
| Move a pin — change what players load | a group in golive-groups. Never by ownership |
| See another creator's maps | a group in review-groups |
Publishing and going live are separate because they are different risks: publishing changes nothing anyone sees, moving a pin changes what every player loads. A creator can do the first in their own corner and never the second.
service-permissions is deliberately not in this path — it answers what a player may do
in-game, scoped per environment. Using it to gate a staff HTTP API would be the same word for
two different questions.
A map is <namespace>/<name>, e.g. bedwars/4x4-baumhaus. The namespace is normally a
gamemode, grounds for platform-owned maps, or u/<creator> for creator content — so
a namespace may itself contain a slash, and an address is not reliably two segments.
Parsing splits at the last slash, and the API takes the whole address as one catch-all
path parameter. Splitting from the left would file every creator map under namespace u.
The namespace is not part of any R2 key. Keys are content-addressed, so renaming a map moves no bytes.
./gradlew build -Pgithub.user="$GITHUB_ACTOR" -Pgithub.token="$GITHUB_TOKEN"Tests start a real Postgres with Testcontainers and run the Flyway migration into it, so
the schema and its constraints are under test rather than stubbed. @TestSecurity
supplies the identity; there is no Keycloak in a test run.
MAPS_DERIVE_ENABLED=false is the compatibility default. With it enabled, every commit that
names a private uploaded source is accepted as a DRAFT, then moves to DERIVING; clients poll
GET /v1/maps/{address}/versions/{version} until it reaches a terminal state. The returned
version includes derive attempt/failure fields plus a scene projection (PENDING, INVALID,
NONE, or VALID) with catalog references, ordered required actions, and ordered diagnostics.
POST /v1/maps/{address}/versions/{version}/derive/retry is for retryable SYSTEM failures only
and requires the normal publish authority. It returns 202 and a new DERIVING attempt; a failed
Job submission remains a reconciliation candidate rather than becoming a falsely published map.
During rollout, derive: true is accepted on a commit. Setting MAPS_DERIVE_REQUIRED=true
requires MAPS_DERIVE_ENABLED=true and rejects source-backed commits that omit derive: true.
Once derivation is enabled, the legacy /publish endpoint rejects source-backed versions: forks
and source-less compatibility versions may still use it, but uploaded source bytes are never
promoted by that path.
- A Keycloak client
service-mapshas to exist, and its tokens must carryaud: service-maps. Without the audience check this service would accept any token the realm signed, including one minted for another client entirely. Adding the client touches the realm import, which is known to rotate other clients' secrets on re-import — so it is a deliberate step, not a side effect of a deploy. - Authorization is by Keycloak group, and the groups have to exist. The token's
groupsclaim decides staff actions and ownership decides creator ones; which group grants which action is configuration (MAPS_AUTHOR_GROUPS,MAPS_PUBLISH_GROUPS,MAPS_GOLIVE_GROUPS,MAPS_REVIEW_GROUPS). The defaults namegrounds-staff,map-authorsandmap-reviewers, which do not exist in Keycloak yet — until they do, only creators acting inside their ownu/<sub>namespace can do anything at all. - Single replica. The pin file is rebuilt in full and published under a per-environment lock, which orders concurrent moves within one process. A second replica can still have two moves land out of order and leave the CDN behind the database. Two replicas need a conditional write or a reconciler first.
The chart in chart/ is published to oci://ghcr.io/groundsgg/charts/service-maps from the
release tag, so a chart version always names an image that exists. What it does not carry,
because a chart that mints secrets puts them in the release history:
existingSecret— the R2 credentials, provisioned as anInfisicalSecret.database.existingSecret— the application user, generated by CNPG.config.r2.endpoint— required, and rendering fails without it. An EU-jurisdiction bucket answers on its own endpoint host, and getting it wrong is a 404 on a bucket that plainly exists.
replicas: 1 and strategy: Recreate are correctness settings, not capacity ones — see the
note on ordering below.
Phase 1. Implemented: the schema; create, list and read for maps; presigned uploads;
version commit and publish; forks; pin moves and the published pins/<env>.json. Not yet
implemented: the derive Job and its watch, moderation and review, leases and stateful
snapshots, quotas.
The derive-Job watch must ship with a poll reconciler in the same commit.
grounds-forge's build informer drops terminal Job events today, and a developer notices and retries by hand. A builder cannot notice and cannot retry — they see an unexplained hang. A watch is an optimisation; the reconciler is the correctness mechanism.