Skip to content

Public API internals

This page covers how /api/v1 is built. For endpoints, request shapes, the upsert rules as callers see them, and curl examples, see Public API.

Where the code is: apps/app/src/server/api/v1/ (operations, the HTTP adapter and the OpenAPI generator), routes/api/v1/ (route files), server/lib/map-scope.ts, server/lib/map-io/upsert*.ts, server/lib/map-io/changes.ts, server/lib/map-io/import-capture.ts, server/lib/map-recipe.ts.

app.api_key stores one row per key: key_hash (SHA-256 hex, unique, the lookup), key_prefix (the first 12 characters in clear, to identify it), last_used_at, and the (org_id, user_id) of the member it acts for. That pair is a foreign key to organisation_member, so removing a member removes their keys. The table is service-role only: RLS is on with no policies. See Data model.

A key is mmk_ plus 32 random bytes in base64url (API_KEY_PATTERN). server/lib/api-key-crypto.ts generates and hashes keys. It imports only node:crypto, so the minting script can import it under plain Node and the two never disagree.

Mint a key with:

Terminal window
./scripts/supa pnpm api-key:create --org <org uuid> --user <member email>

apps/app/scripts/create-api-key.ts prints the key once and stores only its hash. There is no UI. Revoke a key by deleting its row.

API requests have no Supabase session, so RLS can’t check the org. Every handler uses the service-role client and makes the checks in code (server/api/v1/auth.ts).

  1. authenticateApiKey reads the Bearer token. A missing, malformed or unknown key is 401 with one message. On every request it then checks that the key’s member is still org_admin or member of an org that isn’t soft-deleted (403 otherwise), and bumps last_used_at. Demoting a member or deleting the org stops their keys at once.
  2. authorizeMapAccess loads the map: 404 if missing, 403 if it belongs to another org.

Two entry points wrap these:

Function Returns Used for
requireOrgScope OrgScope: client, org, member, key listing and creating maps
requireMapScope MapScope: client, org, map, member, key everything under /maps/:mapId

Both scopes are frozen. API code takes a scope, never a loose client and id. With an OrgScope, every query filters by orgId itself (server/lib/maps-service.ts).

With a MapScope, network tables are reached through networkTable(scope, table) (server/lib/map-scope.ts). It adds map_id = scope.mapId to every select, update and delete, and stamps map_id on every insert and upsert, after the row’s own fields so a row can’t override it. Nothing in network carries an org id, so authorizeMapAccess is the one org check. scope.orgId is used for Storage paths and map rename.

A field id that isn’t in the scoped map is 404, even if it exists in another map.

auth.integration.test.ts, v1.integration.test.ts, v1-1.integration.test.ts, v1-recipe.integration.test.ts and v1-views.integration.test.ts check that one org’s key can’t read or write another org’s map.

Every endpoint is an operation declared once with defineOperation (server/api/v1/registry.ts), and listed in OPERATIONS (operations.ts):

export const ensureField = defineOperation({
id: 'ensureField', // the OpenAPI operationId, and an MCP tool's name
method: 'POST',
path: '/api/v1/maps/{mapId}/fields',
tag: 'Fields',
summary: '…',
description: '…',
auth: 'map', // 'org' gets an OrgScope, 'map' a MapScope
params: { schema: mapParamsSchema, notFound: MAP_NOT_FOUND },
body: { kind: 'json', schema: ensureBodySchema },
response: { description: '…', statuses: [200, 201], errors: [409] },
handler: async ({ scope, params, query, body, raw }) => ({ created, field }),
})

The handler gets a scope and parsed inputs and returns data, never a Response, so the same declaration can serve any transport. Its types come from the schemas.

HTTP. httpHandler(operation) (http.ts) takes a request apart in a fixed order:

  1. Path params. A value that fails params.schema can’t name anything, so it’s 404 with params.notFound.
  2. Query. A failure is 400 invalid_request.
  3. Body headers (precheckImportHeaders).
  4. Auth: requireOrgScope or requireMapScope with the path’s mapId.
  5. The body, read under its cap, then checked against body.schema. A json body that fails is 400 invalid_request, or invalid_document with every issue in details when the operation sets invalid. A text body is the file itself.
  6. The handler. A result with created: true is 201 when the operation lists 201; anything else is 200. A FileResult is sent as a download.

It runs inside withApiErrors (errors.ts):

  • An ApiError becomes { error: { code, message, details? } } with its status.
  • A codedError from shared editing code is mapped by apiErrorFromCoded (for example FIELD_NOT_FOUND → 404, TAG_GROUP_CONFLICT → 409).
  • Anything else is logged, without request headers, and returned as a generic 500 internal_error.

The view operations parse their body a second time in the handler, against the map’s catalogue, using raw.json (see Views).

Routes. A route file under routes/api/v1/ is one line, server: { handlers: v1Route('/api/v1/maps/{mapId}/fields') }. v1Route (routes.ts) serves every operation registered at that path. It fills each other method with a JSON 405 method_not_allowed and an Allow header (v1Handlers). Without that, the router would answer with the app’s HTML and a 200. operations.test.ts checks that the route files serve exactly the registered paths.

OpenAPI. buildOpenApiDocument (openapi.ts) generates an OpenAPI 3.1 document from the registry, served at GET /api/v1/openapi.json without a key. Parameters and request bodies come from the zod schemas (z.toJSONSchema, input side). Responses are described in words, with the shared Error schema on every error status.

MCP. There’s no MCP adapter yet. Each operation already has what a tool needs: a name (id), a description, input schemas, and a handler that returns data.

Every response has Cache-Control: no-store. There are no CORS headers, so a browser page can’t call the API with someone’s key.

The exported handleX functions (handleEnsureField = httpHandler(ensureField)) take (request, params?, deps?), so integration tests call them with a real Request and, optionally, an injected client.

To add an endpoint:

  1. Declare the operation next to its neighbours.
  2. Add it to OPERATIONS.
  3. Add a route file whose v1Route names its path, or reuse the route if the path has one already.

API error codes: invalid_request, invalid_document, unauthorized, forbidden, not_found, conflict, method_not_allowed, payload_too_large, internal_error. See Error codes.

server/api/v1/request.ts validates with zod.

  • Path ids use z.guid(). z.uuid() enforces RFC 4122 variant bits and would reject ids Postgres accepts. A malformed map id is 404, since it can’t name a map.
  • precheckImportHeaders runs before authentication: a declared Content-Length over the cap is 413, and a multipart or compressed body is 400.
  • The body is read only after authentication. readBodyWithLimit counts bytes as they stream, so the cap holds without a Content-Length. It decodes strict UTF-8.
  • Imports and /changes are capped at 10 MB (MAX_IMPORT_BYTES); map, field, recipe and view bodies at 1 MB (DEFAULT_JSON_BODY_BYTES).
  • validateUpsertDocument checks a parsed document before anything is compared or written: label limits (server/lib/labels.ts), key length, unique node ids and keys, and edges only between nodes in the file. Problems come back as invalid_document with details.

POST /maps/:mapId/import parses the file and normalises it with the editor’s pipeline (normalizeImport) and programmaticAttributeMapping, which binds only to existing fields. Then:

  1. planUpsert (upsert-plan.ts) is a pure function from the normalised file, the map’s current state and its catalogue to a write plan. It does no I/O, so every rule is unit-tested (upsert-plan.test.ts).
    • Nodes match by external_key, then by the uuid of a node in this map, else they are created with a fresh id.
    • Connections match by resolved endpoints plus connectionIdentityKey (graph/connection-key.ts, the editor’s grouping key). Orientation is ignored unless the type is directed. Each existing row is claimed at most once.
    • It never plans a delete, except replacing a node’s tag in an exclusive group.
  2. applyUpsertPlan (upsert.ts) writes the plan through networkTable. Node and connection updates are per-row updates, not upserts, so a row deleted meanwhile isn’t resurrected; it’s reported as vanished. A plan with no writes writes nothing and returns changed: false.
  3. withMapImportLock (server/api/v1/import.ts) runs imports and /changes into one map one at a time, in process. The unique (map_id, external_key) constraint is the backstop.

Like every import, this is not one transaction. Re-running converges for keyed and uuid-matched nodes.

create_fields=true uses fieldCreatingAttributeMapping: the wizard’s suggestion, new resolutions included, with label downgraded to property. It first normalises and validates with the no-create mapping, so a rejected file creates no fields. Created fields are listed in fieldsCreated.

server/lib/map-io/changes.ts validates the JSON body with changesBodySchema and turns it into the same NormalizedGraph an import produces (changesToNormalized). Nodes are addressed by key or id, fields by name or id. A connection endpoint that names a node already in the map, and not in the request, is added as a stub that changes nothing. The result goes through upsertNormalized, the same validate, plan, apply and record path as /import. /changes never creates fields.

server/api/v1/maps.ts and fields.ts call the same functions the editor uses (maps-service.ts; readMapFields, createFieldForMap, updateFieldForMap in server/functions/fields.ts), so each rule has one implementation.

  • POST means “ensure”. A map with the same name, or a field of the same kind with the same case-insensitive name, is returned with created: false instead of a duplicate. A connection type whose value type or direction differs from the request is 409 conflict.
  • PATCH …/fields/:fieldId refuses lossy value_type changes with 400: a measure becoming a tag, or a connection type that stops recording a value. A connection type starting to record a value is lossless and allowed. It rewrites the type’s connections, so it records a connection_type.value_type_changed change through recordStandaloneChange.
  • There is no delete on any v1 route.

server/api/v1/recipe.ts calls the editor’s own recipe code (readRecipe, saveRecipe, confirmRecipe and toMapRecipe in server/lib/map-recipe.ts) with the service-role client and the key’s member as the user.

  • PUT validates with a strict zod schema, so unknown keys are 400. It passes validate: body.validated === true and no baseUpdatedAt, so there’s no conflict check: the last write wins. changed is status === 'saved'.
  • saveRecipe and confirmRecipe throw RECIPE_TOO_LONG and RECIPE_NOT_FOUND, which map to 400 and 404 (see Error codes).
  • Names come from loadUserIdentities (server/lib/user-names.ts).

How the save rules and staleness work is on Editing internals.

server/api/v1/views.ts uses the editor’s saved-view code (server/lib/saved-views.ts) with the service-role client. Each request loads the map with loadGraphForExport, for two things:

  • Names. vocabularyOf (lib/view-references.ts) lists the catalogue by kind. referenceResolver turns a name or id into an id. The adapter checks the body’s shape with no resolver. The handler then parses raw.json again with buildViewParameterPatchSchema({ resolve }) (lib/view-parameters.ts), so an unknown or ambiguous name is a zod issue at its path, and a 400.
  • description. visualisationCounts (components/map-visualiser/lib/view-counts.ts) runs the visualiser’s pipeline on the map, and describeVisualisation reads the view back, as the About panel does.

applyViewParameterPatch merges a patch over the stored config, read as a view loads (over the defaults, legacy keys migrated). A write happens only when the name, step or config differ (configs compared as sorted-key JSON), so a repeat is changed: false and leaves updated_at alone. POST matches the name case-insensitively and never renames. A new view’s viewport has zoom 0, which VizCanvas reads as “no camera” and fits.

The open map re-applies its active view when a refetch of ['savedViews', mapId] brings a newer updated_at than it loaded or saved, and its config is still the one that load left (no unsaved changes). It carries the layout on, so the change glides in.

recordApiImport (import-capture.ts) runs after every import or /changes call that was applied. When the call wrote something, it:

  1. uploads the request body to map-imports at {orgId}/{mapId}/{uuid}-api.{ext};
  2. inserts an import_session with status = 'ingested', created_by = the key’s member and scan.source = 'api';
  3. records one map.imported change for that member with recordStandaloneChange, in a closed session of its own (see Editing internals).

The change’s forward payload points at the stored file and session, plus counts. The reverse payload lists created node and connection ids, the prior values of updated rows, and tags an exclusive group replaced.

When the call wrote nothing, only step 3 runs, with no_changes: true in the forward payload and no file or import_session. The response then has a changeId and a null importSessionId. It still counts as a data change, so the map’s recipe goes stale.

If recording fails, the partial record and the stored file are removed and the response carries a warning; the map changes stay. A rejected import records nothing.