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:
| What | URL | Notes |
|---|---|---|
| Router (the API) | http://localhost:4000/graphql | The only endpoint a client ever calls |
| Router health | http://localhost:4000/health | And /health/ready |
| Identity provider | http://localhost:8180 | Realm ai4msme |
| People subgraph | http://localhost:4011/graphql | Internal — do not build against it |
| Ads subgraph | http://localhost:4012/graphql | Internal — do not build against it |
| Sales subgraph | http://localhost:4014/graphql | Internal — 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
}
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: 5is not optional. Connections never guess a page size. Omitting it is an error, not a default. See Pagination.idis 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.
| Try | What comes back | What it proves |
|---|---|---|
Drop the Authorization header | HTTP 401 and {"errors":[{"message":"unauthorized"}]} — no data key at all | The edge authenticates before any module is asked |
Use a token for maya, who has no people access | HTTP 200, data: null, {"errors":[{"message":"Unauthorized"}]}, and a top-level extensions.authorization naming the scope she is missing | Module scopes are enforced at the router, before a module is called |
Use a token for budi, who has people access at a different merchant | totalCount: 2 — Batik Nusantara's own two employees, and none of Rina's eight | The 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
- Authentication and scopes — what your token can open.
- The generated reference — every type, field and argument, with the description its owner wrote.