Skip to main content

Getting started

Three things stand between you and a first result: a running graph, an access token, and one POST.

1. Bring the graph up

The whole stack runs locally from one compose file — Postgres, the identity provider, the subgraphs, and the router that composes them.

cd services
docker compose up -d

The ports, once it settles:

WhatURLNotes
Router (the API)http://localhost:4000/graphqlThe only endpoint a client ever calls
Router healthhttp://localhost:4000/healthAnd /health/ready
Identity providerhttp://localhost:8180Realm ai4msme
People subgraphhttp://localhost:4011/graphqlInternal — do not build against it
Ads subgraphhttp://localhost:4012/graphqlInternal — do not build against it
Sales subgraphhttp://localhost:4014/graphqlInternal — do not build against it

The subgraph ports are published for debugging only. In every deployed environment they are unreachable from outside the private network, and they do not honour the same guarantees the router does. Build against :4000.

2. Get a token

Development personas live in the realm with a fixed password. ana is the one to start with: she can read and change people data.

curl -s -X POST \
http://localhost:8180/realms/ai4msme/protocol/openid-connect/token \
-d grant_type=password \
-d client_id=portal-web \
-d username=ana \
-d password=dev-Passw0rd1

Keep it to hand — bash:

token() {
curl -s -X POST http://localhost:8180/realms/ai4msme/protocol/openid-connect/token \
-d grant_type=password -d client_id=portal-web \
-d "username=$1" -d password=dev-Passw0rd1 \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}

PowerShell:

function Get-Ai4Token($user) {
(Invoke-RestMethod -Method Post `
-Uri http://localhost:8180/realms/ai4msme/protocol/openid-connect/token `
-Body @{ grant_type='password'; client_id='portal-web'; username=$user; password='dev-Passw0rd1' }
).access_token
}
The password grant is a local convenience, not the integration path

Direct access grants are enabled on the portal-web client for development only. Real clients use the authorization-code flow with PKCE; machine clients use the client-credentials flow with their own service account. Nothing else about the token differs — same issuer, same claims, same validation.

3. Send a query

curl -s http://localhost:4000/graphql \
-H "Authorization: Bearer $(token ana)" \
-H 'Content-Type: application/json' \
-d '{"query":"{ employees(first: 5) { totalCount edges { cursor node { id fullName title } } } }"}'

Three details in that one line are worth naming:

  • first: 5 is not optional. Connections never guess a page size. Omitting it is an error, not a default. See Pagination.
  • id is opaque. It encodes the type alongside the record's key and is unique across the whole graph. Pass it back verbatim; never parse it, and never expect a database key.
  • No merchant argument. The organisation came from the token.

4. Watch the boundaries hold

Run the same query three more times, with three different tokens. Each one lands in a different layer, and between them they are the whole shape of the API. The first two are refusals; the third is the one people expect to be a refusal and is not.

TryWhat comes backWhat it proves
Drop the Authorization headerHTTP 401 and {"errors":[{"message":"unauthorized"}]} — no data key at allThe edge authenticates before any module is asked
Use a token for maya, who has no people accessHTTP 200, data: null, {"errors":[{"message":"Unauthorized"}]}, and a top-level extensions.authorization naming the scope she is missingModule scopes are enforced at the router, before a module is called
Use a token for budi, who has people access at a different merchanttotalCount: 2 — Batik Nusantara's own two employees, and none of Rina's eightThe tenant came from the token, not from the query

The third is the one worth dwelling on, because it is easy to misread. budi is not refused, and he does not get an empty page: he gets his merchant's complete answer. employees never meant "Rina's employees" — it means "the employees of whatever merchant this token is for", so the same query text returns a different, complete result for every tenant, and nothing in the request chose which.

So read that result by name, not by count. If any of Rina's eight — Ana Wijaya, Budi Santoso, Dewi Lestari — appeared under budi's token, isolation would be broken. None does; the two rows that come back are Batik Nusantara's own. And because the tenant is a claim rather than an argument, there is no query that addresses another merchant's data to begin with: isolation is enforced by row-level security in the database, so even a resolver bug returns nothing rather than someone else's rows.

The second row is worth one more look too. The refusal body carries no extensions.code on the error — the machine-readable part is the top-level extensions.authorization object, listing the coordinate, the scopes it required and the scopes the token actually had. Errors has the shape.

5. Explore the schema

The schema explorer is a full GraphiQL with completion, validation, documentation and a click-to-build explorer panel. It reads the schema from a file this site ships, so it works before you start anything; point its endpoint field at your router and press play to run.

Production deployments have introspection turned off, which is exactly why the explorer ships the schema instead of asking for it.

Where to go next