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:

  1. 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, so https://api.example.com/config-connector is called at .../config-connector/describe and so on.
  2. 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.
  3. 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.
  4. 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
}
  • revision changes 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 its id.
  • held is 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.
  • next is where the next page starts, or null after 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, updatedAt and the table's envLocalFields left out of the top level;
  • strings, numbers, booleans and null written as JavaScript's JSON.stringify writes them (14, not 14.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 is longsword everywhere). Promotion keeps ids, so a row promoted from test arrives in production under 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 into idField. 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.