Config connectors
Some of your game's config may live in your own backend rather than on the platform: tables you keep beside your game's data, or that your servers read straight from their own database. A config connector lets the console manage them like the rest of your config. Compare shows how they differ between environments, promotion and releases change them, a release's review catches what moved in the meantime, and AI assistants read them with the same tokens.
Your backend serves three calls over HTTPS. The platform does all the deciding (what differs, what conflicts, what a row needs with it, what a person reviewed); your connector only reads its rows, and applies a change when every row is still what the change expects.
A game whose config all lives on the platform needs no connector.
Setting one up
On the project's Config pages, open Connections:
- New connection. A key (lower-case letters, digits and dashes, fixed once saved), a name, and the address your backend serves the connector at:
https, reachable from the internet. Each call is a path under it, sohttps://api.example.com/config-connectoris called at.../config-connector/describeand so on. - Connect an environment. Choose the environment and the connection, and the name your backend knows the environment by (the environment's app key to begin with, such as
skyforge_test). That name is sent, signed, with every call. If this environment's backend is a deployment of its own, give its own address too. - The environment is tested as soon as it is connected. Test connection tests it again at any time, and is how the console learns of a table your connector has added since.
- Tables managed here lists what your connector serves. With none chosen, the console manages every table it serves; choose some, and the others are left alone.
Setting up and removing connections needs the Project settings permission; anybody who can see the project can see them and test them. Removing a connection stops its environments showing its tables, and changes nothing your backend holds.
The calls
Each call is a POST with a JSON body to <address>/<call>, and an Authorization header to check before anything else (see Checking a request). Answer with JSON.
describe
What your connector serves for an environment.
{ "environment": "skyforge_test" }
{
"version": 1,
"name": "Skyforge backend",
"tables": [
{
"name": "Weapon",
"displayName": "Weapons",
"idField": "slug",
"references": [],
"naturalKeys": [],
"envLocalFields": [],
"order": 0,
"stableIds": true,
"columns": [{ "name": "damage", "type": "int", "description": "Base damage per hit" }]
},
{
"name": "WeaponPerk",
"idField": "id",
"references": [{ "field": "weaponSlug", "table": "Weapon" }],
"naturalKeys": [["weaponSlug", "perk"]],
"envLocalFields": ["enabledHere"],
"order": 1
}
]
}
| Field | Rules |
|---|---|
version |
1 |
name |
Optional. What your backend is called in the console |
tables |
Up to 200 |
name |
Of a table or column: letters, digits and underscores, starting with a letter, up to 100 characters. Unique among your tables |
displayName |
Optional. How a person reads it, until somebody labels it otherwise in the console |
description |
Optional, up to 2,000 characters |
idField |
The column a row's id is, or several joined by commas; a composite id is its parts joined with | |
references |
Columns holding the id of a row in another table: one of yours, or one of the platform's config tables |
naturalKeys |
Sets of columns, other than the id, no two rows may share |
envLocalFields |
Columns each environment keeps for itself, as a feature flag's switch is: never compared, hashed or written by the platform |
order |
The table's place in dependency order among yours, parents first: a table comes after every table it references |
stableIds |
Optional, false when absent. See Ids |
columns |
Optional. Each column's name, and optionally its type (string, int, float, bool, json or datetime) and description, for the console and for assistants |
snapshot
Every row of every table you serve for the environment, a page at a time.
{ "environment": "skyforge_test", "cursor": "page-2" }
cursor is absent for the first page.
{
"revision": "r-8812",
"rows": [
{
"table": "Weapon",
"id": "longsword",
"row": { "slug": "longsword", "damage": 12, "updatedAt": "2026-10-17T09:00:00.000Z" }
}
],
"held": [
{ "table": "Weapon", "id": "longsword", "by": [{ "table": "PlayerWeapon", "count": 412 }] }
],
"next": null
}
revisionchanges whenever any row changes, and every page of one snapshot carries the same one. A page with another means the rows moved while they were read, and the platform reads again from the first page, up to three times.rows: each names its table and its id, and the row as plain JSON. A row's own id columns must give itsid.heldis optional: rows your backend's own data points at, which a change may not delete or replace (a weapon players own, say), with what holds them. The console then refuses to remove such a row, and says to switch it off instead.nextis where the next page starts, ornullafter the last. At most 1,000 pages.
apply
A change: writes that land all together, or not at all.
{
"environment": "skyforge",
"changeId": "5b0c6c34-6e47-4b67-9c7a-0e4a1f6a2d10",
"dryRun": false,
"writes": [
{
"table": "Weapon",
"id": "longsword",
"expected": "sha256:3f7a...",
"data": { "slug": "longsword", "damage": 14 }
},
{ "table": "Weapon", "id": "rusty-dagger", "expected": "sha256:91c0...", "data": null }
]
}
| Field | What it is |
|---|---|
changeId |
Names the change. The same id twice is the same change: apply it once, and answer already after |
dryRun |
Optional. Check everything, write nothing |
writes |
Up to 10,000. Each names a row, the hash of the row as the platform last read it (null: no such row), and the row afterwards (null: delete it) |
Apply a change only if every row is still what its write expects: hash your current row and compare. Then write them all in one transaction, in an order your own constraints accept (deleting children before parents, writing parents before children), and remember the changeId.
data is the whole row as it should be, without createdAt, updatedAt and the table's envLocalFields: keep those columns as they are, or give a new row their defaults.
Answer:
| Status | Body | When |
|---|---|---|
200 |
{ "status": "applied" } |
Every write landed (or, for a dry run, would have) |
200 |
{ "status": "already" } |
This changeId was applied before; nothing was written now |
409 |
{ "status": "conflict", "conflicts": [{ "table", "id", "reason" }] } |
Nothing was written: these rows are not what the change expected. reason is optional |
When the platform does not hear your answer (a timeout, a dropped connection, a 5xx), it sends the same change once more under the same changeId, so answering already is what makes that safe. If it still cannot tell whether your backend took a change it then could not record, it sends the change's inverse under a new id, to put your rows back; refusing that as a conflict, because nothing had landed, is the right answer.
A row's hash
expected is the row's hash, and your connector has to compute the same one from the same row. It is sha256: followed by the hex SHA-256 of the row serialised as JSON with:
- the keys of every object sorted, at every depth;
- no whitespace;
createdAt,updatedAtand the table'senvLocalFieldsleft out of the top level;- strings, numbers, booleans and
nullwritten as JavaScript'sJSON.stringifywrites them (14, not14.0).
Hash the row exactly as your snapshot sends it: if a snapshot sends a date as an ISO string, hash that string. In TypeScript:
import { createHash } from 'node:crypto'
function stable(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`
if (value !== null && typeof value === 'object') {
const object = value as Record<string, unknown>
const keys = Object.keys(object)
.filter((key) => object[key] !== undefined)
.sort()
return `{${keys.map((key) => `${JSON.stringify(key)}:${stable(object[key])}`).join(',')}}`
}
return JSON.stringify(value)
}
function rowHash(row: Record<string, unknown>, envLocalFields: readonly string[]): string {
const kept = Object.fromEntries(
Object.entries(row).filter(
([column, value]) =>
value !== undefined &&
column !== 'createdAt' &&
column !== 'updatedAt' &&
!envLocalFields.includes(column),
),
)
return `sha256:${createHash('sha256').update(stable(kept), 'utf8').digest('hex')}`
}
Test connection checks this for you: it sends a dry run writing one of your rows back as it is, which your connector must accept.
Ids
A row's id is its idField column, or its columns joined with |.
stableIds: true: the same row has the same id in every environment (a weapon islongswordeverywhere). Promotion keeps ids, so a row promoted fromtestarrives inproductionunder the id it had.stableIds: false(the default): each environment names its own rows, as an auto-made id does. Promotion matches a row to the target's by a natural key, and makes an id for one that arrives new: a UUID derived from the source row's id, so promoting the same row again finds it, written intoidField. Your table has to accept it as the row's id.
A composite id is kept as it is.
References and held rows
The platform checks references itself before it sends anything: a row never arrives pointing at a row the environment does not have, and a row is never deleted while one that stays points at it. Your held rows add what the platform cannot see, such as players' data pointing at a row, and a change that would delete or replace one of them is refused before it reaches you.
Checking a request
Every request carries Authorization: Bearer <token>, a JWT the platform signs with Ed25519 (alg EdDSA). The public keys are at:
GET https://api.greenmindmedia.com/api/platform/v1/.well-known/config-connector-jwks.json
They are not the keys player tokens are signed with. Fetch them once and cache them; fetch them again when a token names a kid you do not have. Accept a request only when:
| Check | Must be |
|---|---|
| Signature | Valid, by the key in the set whose kid the token's header names; alg EdDSA |
iss |
gm-platform |
aud |
Your connector's own address, exactly as the console shows it on the Connections page |
type |
config-connector |
call |
The call this request is for: describe, snapshot or apply |
env |
The environment in the body |
bh |
The base64url SHA-256 of the request body, exactly as it arrived (check it before you parse the body) |
exp |
In the future. Tokens live 60 seconds; allow up to 30 seconds of clock skew |
jti |
One you have not taken before. Keep the ones you take until they expire, so a request cannot be sent twice |
game (and sub) is the platform's app key for the environment. A token for any other address, call, body or environment is no good for this request, so a request cannot be replayed elsewhere. If your connector answers at more than one address, accept each of them as aud.
In Node, with the jose library:
import { createHash } from 'node:crypto'
import { createRemoteJWKSet, jwtVerify } from 'jose'
const keys = createRemoteJWKSet(
new URL('https://api.greenmindmedia.com/api/platform/v1/.well-known/config-connector-jwks.json'),
)
const ADDRESS = 'https://api.example.com/config-connector'
const taken = new Map<string, number>()
/** Whether a request is the platform's, for this call, body and environment. */
async function isPlatformRequest(
authorization: string | undefined,
call: 'describe' | 'snapshot' | 'apply',
rawBody: string,
environment: string,
): Promise<boolean> {
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : null
if (!token) return false
try {
const { payload } = await jwtVerify(token, keys, {
algorithms: ['EdDSA'],
issuer: 'gm-platform',
audience: ADDRESS,
clockTolerance: 30,
maxTokenAge: 90,
})
const bodyHash = createHash('sha256').update(rawBody, 'utf8').digest('base64url')
if (payload.type !== 'config-connector' || payload.call !== call) return false
if (payload.bh !== bodyHash || payload.env !== environment) return false
const now = Date.now() / 1000
for (const [id, exp] of taken) if (exp + 30 < now) taken.delete(id)
if (!payload.jti || taken.has(payload.jti)) return false
taken.set(payload.jti, payload.exp ?? now)
return true
} catch {
return false
}
}
Answer a request that fails any check with 401, and say no more than that.
Errors
Anything other than a conflict is answered with a 4xx or 5xx and { "error": "<code>", "message": "..." }, message optional. The console shows the message to whoever was promoting or testing, so word it for them.
| Status | error |
When |
|---|---|---|
400 |
invalid-request |
The body is not JSON, or not a request you take |
401 |
unauthorized |
The request failed a check above |
404 |
unknown-environment |
You serve no environment by that name |
413 |
too-large |
The body is larger than you take |
500 |
failed |
Anything else went wrong |
Limits
The platform waits up to 20 seconds for each call (each page of a snapshot is a call), reads at most 32 MB of an answer, follows no redirects, and only connects to a public address: every address your connector's name resolves to is checked before each call, and the call goes to the address checked.
Testing it
Test connection on the Connections page runs these checks, and changes nothing:
| Check | Passes when |
|---|---|
describe |
describe answers with contract version 1 |
tables |
No two tables share a name |
snapshot |
Every page arrives, all from one revision |
rows |
Every row names a described table, once, and its own id columns give its id |
apply |
A dry run writing one of your rows back as it is is accepted |
conflicts |
A dry run expecting the wrong hash is refused as a conflict |
A failing apply almost always means your row hash differs from the platform's; a failing conflicts means your connector would overwrite rows edited since they were read. The environment's line shows Answering, Failing with the last error, or Not checked.
When your connector does not answer
The rest of your config carries on. While a connector does not answer, Compare says so and leaves its tables out (rather than showing every row as removed), and a release or any other change that touches those tables cannot go out until it answers again. Nothing is written to the platform's side of a change whose backend half was refused: a change lands in both, or in neither.