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:
./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.
Authentication and scope
Section titled “Authentication and scope”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).
authenticateApiKeyreads theBearertoken. A missing, malformed or unknown key is 401 with one message. On every request it then checks that the key’s member is stillorg_adminormemberof an org that isn’t soft-deleted (403 otherwise), and bumpslast_used_at. Demoting a member or deleting the org stops their keys at once.authorizeMapAccessloads 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.
Operations, adapters and routes
Section titled “Operations, adapters and routes”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:
- Path params. A value that fails
params.schemacan’t name anything, so it’s 404 withparams.notFound. - Query. A failure is 400
invalid_request. - Body headers (
precheckImportHeaders). - Auth:
requireOrgScopeorrequireMapScopewith the path’smapId. - The body, read under its cap, then checked against
body.schema. Ajsonbody that fails is 400invalid_request, orinvalid_documentwith every issue indetailswhen the operation setsinvalid. Atextbody is the file itself. - The handler. A result with
created: trueis 201 when the operation lists 201; anything else is 200. AFileResultis sent as a download.
It runs inside withApiErrors (errors.ts):
- An
ApiErrorbecomes{ error: { code, message, details? } }with its status. - A
codedErrorfrom shared editing code is mapped byapiErrorFromCoded(for exampleFIELD_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:
- Declare the operation next to its neighbours.
- Add it to
OPERATIONS. - Add a route file whose
v1Routenames 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.
Request validation
Section titled “Request validation”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. precheckImportHeadersruns before authentication: a declaredContent-Lengthover the cap is 413, and a multipart or compressed body is 400.- The body is read only after authentication.
readBodyWithLimitcounts bytes as they stream, so the cap holds without aContent-Length. It decodes strict UTF-8. - Imports and
/changesare capped at 10 MB (MAX_IMPORT_BYTES); map, field, recipe and view bodies at 1 MB (DEFAULT_JSON_BODY_BYTES). validateUpsertDocumentchecks 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 asinvalid_documentwithdetails.
Import: plan, then apply
Section titled “Import: plan, then apply”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:
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 isdirected. Each existing row is claimed at most once. - It never plans a delete, except replacing a node’s tag in an exclusive group.
- Nodes match by
applyUpsertPlan(upsert.ts) writes the plan throughnetworkTable. Node and connection updates are per-row updates, not upserts, so a row deleted meanwhile isn’t resurrected; it’s reported asvanished. A plan with no writes writes nothing and returnschanged: false.withMapImportLock(server/api/v1/import.ts) runs imports and/changesinto 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.
/changes
Section titled “/changes”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.
Maps and fields
Section titled “Maps and 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.
POSTmeans “ensure”. A map with the same name, or a field of the same kind with the same case-insensitive name, is returned withcreated: falseinstead of a duplicate. A connection type whose value type or direction differs from the request is 409conflict.PATCH …/fields/:fieldIdrefuses lossyvalue_typechanges 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 aconnection_type.value_type_changedchange throughrecordStandaloneChange.- There is no delete on any v1 route.
Recipe
Section titled “Recipe”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.
PUTvalidates with a strict zod schema, so unknown keys are 400. It passesvalidate: body.validated === trueand nobaseUpdatedAt, so there’s no conflict check: the last write wins.changedisstatus === 'saved'.saveRecipeandconfirmRecipethrowRECIPE_TOO_LONGandRECIPE_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.referenceResolverturns a name or id into an id. The adapter checks the body’s shape with no resolver. The handler then parsesraw.jsonagain withbuildViewParameterPatchSchema({ 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, anddescribeVisualisationreads 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.
Recording an import
Section titled “Recording an import”recordApiImport (import-capture.ts) runs after every import or /changes call that
was applied. When the call wrote something, it:
- uploads the request body to
map-importsat{orgId}/{mapId}/{uuid}-api.{ext}; - inserts an
import_sessionwithstatus = 'ingested',created_by= the key’s member andscan.source = 'api'; - records one
map.importedchange for that member withrecordStandaloneChange, 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.