# GRANDstack: full documentation > GraphQL and Neo4j, for developers and agents. This file concatenates the documentation of every repository in the project (Apache-2.0). Index: https://www.grandstack.ai/llms.txt. --- # grandstack: grandstack: overview URL: https://www.grandstack.ai/docs/grandstack/ GraphQL on Neo4j for developers and agents. A Neo4j database, a GraphQL server generated from a schema file, and a typed React app, run together with Docker Compose. The **A** now stands for **Agent**: the same GraphQL API that serves the web app is meant to serve coding agents and agentic apps. The bundled example is a movie and people app: faceted search with live counts, compare, graph views, recommendations, frequent collaborators, and create/edit/delete, over 152 sample films and 464 people. You can keep it, change it, or replace the data and build your own ([build your own app](/docs/grandstack/build-your-own-app/)). > **The default stack has no authentication.** Anyone who can reach the app or the GraphQL endpoint can read, create, edit and delete data. Run it locally and do not expose it publicly. Reset the data any time with `docker compose down -v`. Login and access rules are available as an opt-in: see [Run it with authentication](#run-it-with-authentication). ## Quickstart You need Git, and Docker or Podman with compose. Clone the repositories **side by side into one folder**, because the compose files build the other repositories from their sibling folders (the first three are enough for the default stack): ```sh mkdir grand-stack && cd grand-stack # any folder name git clone https://github.com/grand-stack/grandstack git clone https://github.com/grand-stack/grandstack-server git clone https://github.com/grand-stack/grandstack-web ``` The folder then looks like this (the other repositories are cloned later, only if you use that setup): ``` grand-stack/ any folder name grandstack/ this repository (compose and docs) grandstack-server/ the GraphQL server grandstack-web/ the React app grandstack-auth/ optional: gateway, Keycloak realm, auth rules, demo client (only for the auth setup) grandstack-agents/ optional: MCP server and agent examples (only for the agents setup) grandstack-federation/ optional: Wikipedia subgraph, gateway, link builder (only for the federation setup) ``` Start the stack from inside the `grandstack` folder: ```sh cd grandstack cp .env.example .env docker compose up --build ``` | Open | To see | | --- | --- | | http://localhost:3000 | The web app | | http://localhost:4000/graphql | The GraphQL API and GraphiQL | | http://localhost:7474 | Neo4j Browser (user `neo4j`, password from `.env`, default `grandstack-dev`) | The first start builds the images and loads the sample data. Stop with `docker compose down`; add `-v` to also delete the data. ## Run it with authentication Opt-in, development only. Clone the extra repository beside the others first (`git clone https://github.com/grand-stack/grandstack-auth`). This adds Keycloak (login), a gateway that checks every token, row-level rules on the server, and a demo client with a permissions playground. `grandstack-web` is not changed; against the gateway it can read but not write. ```sh cd grandstack # the umbrella repository, as above cp .env.example .env # once, if you have not already docker compose -f compose.yaml -f compose.auth.yaml up --build ``` | Open | To see | | --- | --- | | http://localhost:3001 | The auth demo client: log in as `alice` / `bob` / `carol` (password is the name plus `-pass`) | | http://localhost:4000/graphql | The gateway: the public GraphQL endpoint (the server itself is no longer published) | | http://localhost:8081 | Keycloak admin console (`admin` / `admin`) | Reads stay public; writes need a login. See [`grandstack-auth`](/docs/grandstack/architecture/#authentication-opt-in) and its docs for roles and rules. Stop it with `docker compose -f compose.yaml -f compose.auth.yaml down` (add `-v` to delete the data). ## Run it with agents Add an MCP server that lets an agent run GraphQL against the (open) server. Clone the extra repository beside the others first (`git clone https://github.com/grand-stack/grandstack-agents`), then from the `grandstack` folder: ```sh docker compose -f compose.yaml -f compose.agents.yaml up --build claude mcp add --transport http grandstack http://localhost:4100/mcp ``` It is read-only by default; `AGENTS_ALLOW_MUTATIONS=true` enables writes. The endpoint has no auth: keep it on your machine. See [`grandstack-agents`](/docs/grandstack/architecture/#agents-opt-in). ## Run it with federation Add a Wikipedia subgraph and a gateway, so one query returns movie data from Neo4j together with a Wikipedia summary. Clone the extra repository beside the others first (`git clone https://github.com/grand-stack/grandstack-federation`), then from the `grandstack` folder: ```sh docker compose -f compose.yaml -f compose.federation.yaml up --build ``` Needs internet (the Wikipedia part is live). The gateway is then on port 4000 (the web app uses it unchanged) and the Neo4j subgraph on 4001. See [`grandstack-federation`](/docs/grandstack/architecture/#federation-opt-in). It replaces the default, auth and agents setups on port 4000; run one at a time. ## Repositories | Repository | What it is | Start with | | --- | --- | --- | | `grandstack` | Compose stack, overview, tutorial, agent guide | this README | | `grandstack-server` | Docker image serving GraphQL over Neo4j from a schema file and env vars | its `README.md` and `docs/how-it-works.md` | | `grandstack-web` | React app with typed operations (open, no login) | its `README.md` and `docs/architecture.md` | | `grandstack-auth` | Gateway, Keycloak dev realm, auth schema rules, demo client, and the exercise for adding login to `grandstack-web` | its `README.md` and `docs/how-it-works.md` | | `grandstack-agents` | MCP server with generic GraphQL tools, a direct agent-loop example, and example code for using auth | its `README.md` and `docs/how-it-works.md` | | `grand-stack.github.io` | The website (https://grandstack.ai): Hugo, a generated copy of every repo's docs, examples, llms.txt | its `README.md` | | `grandstack-federation` | Wikipedia subgraph, gateway and supergraph, link builder; the server becomes a subgraph | its `README.md` and `docs/how-it-works.md` | ## Documentation | Read | To learn | | --- | --- | | [Architecture](/docs/grandstack/architecture/) | Components, data model, request flow, why things are built this way | | [Build your own app](/docs/grandstack/build-your-own-app/) | A verified walkthrough: new schema and data, a typed front end | | [Agent guide](/docs/grandstack/agents/) | For coding agents: where to change what, checks, gotchas | | [Configuration](/docs/grandstack/configuration/) | Services, ports, settings (including the auth setup), Podman, resetting data | | [Versioning and releases](/docs/grandstack/versioning-and-releases/) | Tags, what is released, how to cut a release | | [Releasing, CI and the website](/docs/grandstack/releasing/) | The GitHub workflows: checks on every commit, the release button, images, how a release rebuilds grandstack.ai | | [Roadmap](https://github.com/grand-stack/grandstack/blob/main/docs/roadmap.md) | What is built and what is planned | | [llms.txt](https://github.com/grand-stack/grandstack/blob/main/llms.txt) | An index of all of this for agents (the public copy is https://grandstack.ai/llms.txt) | The sample data has real titles, casts and relationships; ratings and runtimes are approximate. Last verified: grandstack v0.10.4 with grandstack-server v0.9.3, grandstack-web v0.5.4, grandstack-auth v0.1.4, grandstack-agents v0.1.5, grandstack-federation v0.1.4 and the website grand-stack.github.io v0.1.3, Neo4j 2026.10 community, under Podman 6 on macOS. All four compose setups were run end to end with `scripts/integration.sh`. The GitHub workflows have been linted but not run on GitHub. ## Licence Apache License 2.0. See [LICENSE](https://github.com/grand-stack/grandstack/blob/main/LICENSE) and [NOTICE](https://github.com/grand-stack/grandstack/blob/main/NOTICE). The same licence applies to every GRANDstack repository and to [grandstack.ai](https://grandstack.ai). --- # grandstack: Architecture URL: https://www.grandstack.ai/docs/grandstack/architecture/ GRANDstack is a way to build apps on a Neo4j graph with GraphQL: a Neo4j database, a GraphQL server generated from a schema file, and a React client with typed operations. The **A** now stands for **Agent**: the same GraphQL API that serves the web app is meant to serve coding agents and agentic apps too. Apollo has been removed; the pieces are Neo4j's GraphQL library, GraphQL Yoga and open-source libraries that run locally in Docker. This guide describes what is built today (the versions that were tested together are in [versioning and releases](/docs/grandstack/versioning-and-releases/)). What is planned is on the [roadmap](https://github.com/grand-stack/grandstack/blob/main/docs/roadmap.md). ## Components [![System overview: the browser loads static files from the web container on port 3000, then sends GraphQL requests directly to the server on port 4000, which talks to Neo4j over Bolt.](/diagrams/grandstack/system-overview.svg)](/diagrams/grandstack/system-overview.svg) *Figure: the default stack. The browser gets static files from one container and talks to the GraphQL server directly. Click the image for full size; the numbered notes below carry the same information as text.* 1. **Static files only.** The `web` container serves the built app with nginx (falling back to `index.html` for client-side routes). It never calls the server. 2. **Direct GraphQL.** The browser POSTs to `http://localhost:4000/graphql`. That URL is fixed when the web image is built (`VITE_GRAPHQL_URL`), and the server's `CORS_ORIGIN` must allow the web origin. 3. **The schema file is the API.** `schema/schema.graphql` and the environment variables are read once at start. There are no resolvers to write. 4. **Cypher over Bolt.** `@neo4j/graphql` translates each request into Cypher and runs it on the one database (`neo4j`; community edition has a single database). 5. **Counts plugin.** After every mutation the server recalculates the stored film counts on people, before it replies, so a refetch is correct. 6. **Seed on start.** The Movies seed is loaded into an empty database, or reloaded when the seed file's version is newer; data the server did not load is left alone. - The **browser** loads the static app from `web` (port 3000) and sends GraphQL requests **directly** to `server` (port 4000). `web` does not proxy; the server allows the web origin with CORS. - The **server** has no resolvers. It reads `schema/schema.graphql`, and `@neo4j/graphql` generates the API and translates each request into Cypher. - **Neo4j** holds the data. Port 7474 serves Neo4j Browser (user `neo4j`, password from `.env`). | Repository | Role | Documentation | | --- | --- | --- | | `grandstack` (this one) | Compose stack, overview, tutorial, agent guide | here | | `grandstack-server` | The GraphQL server image | `docs/` in that repo | | `grandstack-web` | The React app (open, no login) | `docs/` in that repo | | `grandstack-auth` | Optional: gateway, Keycloak dev realm, auth rules, demo client | `docs/` in that repo | ## Data model (the sample) [![Data model: Person acted in and directed Movie, Movie is in Genre; Review and User come from the auth setup. Person counts are stored and derived, Movie.similar and Person.collaborators are computed.](/diagrams/grandstack/data-model.svg)](/diagrams/grandstack/data-model.svg) *Figure: the sample data model. Dashed boxes exist only in the auth setup. Click the image for full size; the numbered notes below carry the same information as text.* 1. **Roles live on the relationship.** `ACTED_IN` carries a `roles` list as a relationship property. Titles and names are unique keys (constraints), so there are no ids. 2. **Stored but derived.** `actedCount` and `directedCount` are real properties so people can be sorted and filtered in plain GraphQL; the counts plugin keeps them correct. 3. **Computed on request.** `Person.collaborators` runs a Cypher traversal ranked by shared films and returns a name and a count. It cannot be sorted or filtered. 4. **Computed on request.** `Movie.similar` scores films by shared director (3 points), cast (2) and genre (1). 5. **Auth setup only.** `Review` and `User` come from `grandstack-auth`'s schema extension. A review's author is taken from the token. - **Movie**: `title` (unique), `tagline`, `released`, `rating` (0 to 10), `runtime` (minutes). - **Person**: `name` (unique), `born`, and the stored counts `actedCount` and `directedCount`. - **Genre**: `name` (unique). - `ACTED_IN` carries the character names as `roles`. - Computed fields in the schema: `Movie.similar` (shared director, cast and genres) and `Person.collaborators` (shared films). - 152 films, 464 people and 18 genres are bundled. The titles, casts and relationships are real; ratings and runtimes are approximate sample data. The full field list is generated from the schema in `grandstack-server/docs/schema-reference.md`. ## A request, end to end Example: the user ticks the Drama genre on `/movies`. 1. The React app updates the URL (`/movies?g=Drama`). The URL is the single source of truth for filters. 2. `buildWhere` turns the filters into a GraphQL `MovieWhere`, and urql sends the typed `Results` operation to `POST /graphql`. 3. Yoga builds the request context (driver, database, options) and runs the operation. 4. `@neo4j/graphql` translates it to Cypher, runs it on Neo4j, and shapes the rows into the response, including genre and decade counts computed with the *other* filters applied. 5. The component renders typed data. If it was a mutation, a server plugin first recalculates the stored film counts, so the refetch is correct. ## Authentication (opt-in) The default stack above is open. Adding `compose.auth.yaml` puts a gateway in front of the server and adds Keycloak and a demo client: [![Before and after: the default stack versus the stack with compose.auth.yaml, which adds a gateway in front, Keycloak, and removes the server's host port; grandstack-web is unchanged and becomes anonymous.](/diagrams/grandstack/open-vs-auth.svg)](/diagrams/grandstack/open-vs-auth.svg) *Figure: what `compose.auth.yaml` changes. New services are outlined in bold; the red dashed path is the one that disappears. Click the image for full size; the numbered notes below carry the same information as text.* 1. **Gateway in front.** It becomes the public GraphQL endpoint on port 4000, validates every token and checks permissions per mutation, then forwards the `Authorization` header. 2. **Keycloak beside it.** It logs users in (OIDC code + PKCE) and signs tokens; the gateway and the server fetch its public keys (JWKS). 3. **Server no longer published.** The browser can no longer reach it directly. It also verifies the token again and applies row-level rules. 4. **`grandstack-web` unchanged.** It still calls port 4000, which is now the gateway, so it acts as an anonymous client: reads work, writes are refused. 5. **Demo client added.** `auth-client` on port 3001 logs in and shows the rules at work. - **Reads are public; writes need a login.** Roles `user`, `editor`, `moderator` map to permissions at the gateway and to row rules on the server (only a review's author or a moderator can change it). - **Two independent checks:** the gateway answers "may this token call this mutation?"; the server answers "may it change this row?" and refuses unauthenticated writes even if reached directly. - The server stays auth-agnostic: it only gets generic settings (`AUTH_*` to verify tokens, `SCHEMA_EXTRA_PATHS` to load extra schema). The `User` and `Review` types and every rule live in `grandstack-auth` and are mounted in. - **`grandstack-web` is deliberately unchanged.** Through the gateway it is an anonymous client: reading works, writing is refused. Adding login to it is a documented exercise in `grandstack-auth`. - Details, roles, responses and what to change for production: the `grandstack-auth` repository's `docs/how-it-works.md`. ## Agents (opt-in) `compose.agents.yaml` adds one service, `agents-mcp` on port 4100: an MCP server that accepts GraphQL statements and runs them against the server. An agent can also skip MCP and send GraphQL itself; both paths share one guarded executor. [![Two ways an agent can use the same GraphQL server: its own loop with a graphql tool, or any MCP client with the MCP server; both end in the same guarded executor and the open server.](/diagrams/grandstack/agents-direct-vs-mcp.svg)](/diagrams/grandstack/agents-direct-vs-mcp.svg) *Figure: the two agent paths. Click the image for full size; the numbered notes carry the same information as text.* 1. **An agent you write** holds the model, the loop and one `graphql` tool. 2. **Any MCP client** (Claude Code, Claude Desktop, your own app) lists and calls the tools. 3. **The guarded executor** parses the statement, allows one operation, keeps queries and mutations apart, refuses mutations unless `ALLOW_MUTATIONS=true`, and applies a timeout and a size cap. 4. **The server** is the open `grandstack-server`; nothing about it changes. 5. **The MCP server** offers `get_schema`, `describe_type`, `run_query` and `run_mutation` over stdio or HTTP. - Read-only by default. That flag guards the MCP path only; the server on port 4000 stays open. Real control is auth: `grandstack-agents/docs/adding-auth.md` shows the same tools using `grandstack-auth` with a token. - The tools use introspection and know nothing about movies, so they work with another GraphQL server. - Details: the `grandstack-agents` repository's `docs/how-it-works.md`. ## Federation (opt-in) `compose.federation.yaml` turns the server into a federation subgraph, adds a Wikipedia subgraph, and puts a gateway on port 4000 that serves one combined API. The Neo4j subgraph moves to port 4001. [![The federation setup: clients call a gateway on port 4000; it uses a composed supergraph and sends parts of each query to the Neo4j subgraph and to a Wikipedia subgraph, which calls the public Wikipedia API; a generated links file gives films and people their Wikipedia page.](/diagrams/grandstack/federation-overview.svg)](/diagrams/grandstack/federation-overview.svg) *Figure: the federation setup. Click the image for full size; the numbered notes carry the same information as text.* 1. **Clients** (`grandstack-web`, agents, curl) call one endpoint. 2. **The gateway** (Hive Gateway) reads the supergraph and plans each query. 3. **The supergraph** is composed from the two subgraphs by `npm run compose` and committed. 4. **The Neo4j subgraph** is `grandstack-server` with `FEDERATION=true`; Movie and Person are keyed on `wikipediaTitle`. 5. **The Wikipedia subgraph** adds `wikipedia` (description, extract, image, page link) to Movie and Person. 6. **The links file** gives films and people their Wikipedia page, set at server start. - The Wikipedia part calls the live public Wikipedia API: it needs internet, is cached for an hour, and its text is CC BY-SA 4.0. - A film or person with no link returns `wikipedia: null` with no error; Wikipedia trouble returns `null` with an error and the Neo4j fields still arrive. - Open, like the default stack: combining it with auth is not built. - Details: the `grandstack-federation` repository's `docs/how-it-works.md`. ## Design decisions and why | Decision | Reason | | --- | --- | | Generate the API from a schema file with `@neo4j/graphql` rather than writing resolvers | The schema is the single description of the API, which is easy for people and agents to read and change; filters, sorting and nested writes come for free. | | GraphQL Yoga, no Apollo | The brief removes Apollo; Yoga is the Guild's lightweight server with a plugin system we use. | | Schema and seed are mounted files, configuration is environment variables | The same image serves any data model. See [build your own app](/docs/grandstack/build-your-own-app/). | | Seed has a version and a marker node; data it did not load is never touched | A newer seed reloads cleanly, and pointing the server at your own Neo4j never overwrites your data. | | Film counts stored on `Person` and refreshed by a plugin after every mutation | Computed (`@cypher`) fields cannot be sorted or filtered in plain GraphQL, and a plugin keeps every client (UI, `curl`, agent) correct without knowing about it. | | Relationship edits are sent as diffs, never "disconnect all, then connect" | In `@neo4j/graphql` v7 the disconnect runs last and removes the new links. | | Names and titles are the keys (unique constraints), so there are no ids | It keeps the sample readable and URLs meaningful; the cost is that renames change URLs and duplicates are rejected. | | Filters live in the URL | Results are shareable and reloadable and the back button works. | | Posters and avatars are generated from the text | No image licensing, no assets to host. | | Needle-style look reimplemented, `@neo4j-ndl/*` not used | Those packages are GPL-3.0 and this project is Apache-2.0. The tokens are approximations, not verified against Needle. | | `react-force-graph-2d` for graph views | MIT-licensed; Neo4j's own NVL has a custom licence. | | Separate repos, versioned with semver tags | Each piece can be cloned and used alone; the umbrella records which versions work together. | | Community Neo4j, database `neo4j` | It runs locally with no licence; community supports a single database. | | Auth as a gateway plus server rules, in its own repo, opt-in | Authentication is orthogonal to the app: the gateway can front any GraphQL server, the server stays generic, the default quickstart stays zero-config, and `grandstack-web` shows what an app looks like before login. | | Both the gateway and the server verify the token | Defence in depth: the server is safe even if reached directly. | | Permissions at the gateway are derived from roles | Keycloak grants OAuth scopes per client, not per user, so roles are what differ between users; the gateway also accepts `permissions` and `scope` claims for other providers. | | Row-level `filter` rules, not `validate`; ISO strings, not `DateTime` | `validate` rules and `DateTime` reads need Neo4j's APOC plugin, which this stack does not include. | | Reviews are created by a custom mutation using the token's `sub` | The author cannot be spoofed because clients cannot pass one; the generated create is removed. | ## Limits today - **The default stack has no authentication.** Anyone who can reach the server can read, create, edit and delete everything. Do not expose it publicly. The opt-in auth setup is development-only too (Keycloak dev mode, demo users, plain HTTP); see `grandstack-auth` for what production would need. - The compose file builds the server and web images from sibling checkouts; it does not pull published images yet ([versioning](/docs/grandstack/versioning-and-releases/)). - Everything runs on one machine in Docker or Podman; there is no production deployment recipe. --- # grandstack: Build your own app URL: https://www.grandstack.ai/docs/grandstack/build-your-own-app/ This guide takes the GRANDstack repos and turns them into an app for **your own data**. The worked example is a small library: **Books**, **Authors** and **Genres**, with a relationship property on who wrote what. Every step here was run end to end: the Books server answered queries, `codegen` generated types from its schema, and a typed Books page built and rendered. > **Where to run this.** Docker Compose commands run inside a clone of this repository (`git clone https://github.com/grand-stack/grandstack`, then `cd grandstack`), with the repositories it builds cloned beside it. The [quickstart](/docs/grandstack/#quickstart) has the exact steps. You will: 1. Describe your data in a GraphQL schema and a seed file. 2. Run the server on it (no code changes, no rebuild). 3. Generate types and build a front end that reads it. Time: about 20 minutes. You need Docker or Podman with compose, Node 22 or later, and the three repositories (`grandstack`, `grandstack-server`, `grandstack-web`) side by side in one folder. ## Part 1: the server ### 1. Write the schema Create `grandstack/my-app/schema.graphql`. Rules (details in the server's `docs/schema-guide.md`): every type that is stored in Neo4j needs `@node`, relationships are declared from both sides, and every type and field should have a description. ```graphql """A book in the library.""" type Book @node { """Book title. Unique.""" title: String! """Year of first publication.""" published: Int """Number of pages.""" pages: Int """Average reader rating from 0 to 5.""" rating: Float """Genres the book belongs to.""" genres: [Genre!]! @relationship(type: "IN_GENRE", direction: OUT) """People who wrote or contributed to the book.""" authors: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") } """A person who wrote books.""" type Author @node { """Full name. Unique.""" name: String! """Year of birth.""" born: Int """Books the person wrote.""" books: [Book!]! @relationship(type: "WROTE", direction: OUT, properties: "Wrote") } """A book genre.""" type Genre @node { """Genre name.""" name: String! """Books in this genre.""" books: [Book!]! @relationship(type: "IN_GENRE", direction: IN) } """Properties on the WROTE relationship.""" type Wrote @relationshipProperties { """How the person contributed, for example author or translator.""" role: String! } ``` `direction` is relative to the type the field sits on: `Book.authors` is `IN` (the arrow goes from Author to Book), `Author.books` is `OUT` for the same `WROTE` relationship. ### 2. Write the seed Create `grandstack/my-app/books.cypher`. Statements end with a semicolon at the end of a line, use `MERGE` so reloading is safe, and the first line carries a version. ```cypher // seed-version: 1 CREATE CONSTRAINT book_title IF NOT EXISTS FOR (b:Book) REQUIRE b.title IS UNIQUE; CREATE CONSTRAINT author_name IF NOT EXISTS FOR (a:Author) REQUIRE a.name IS UNIQUE; CREATE CONSTRAINT genre_name IF NOT EXISTS FOR (g:Genre) REQUIRE g.name IS UNIQUE; UNWIND [ {title: "Dune", published: 1965, pages: 412, rating: 4.3, genres: ["Sci-Fi"], authors: [{name: "Frank Herbert", role: "author"}]}, {title: "Neuromancer", published: 1984, pages: 271, rating: 3.9, genres: ["Sci-Fi"], authors: [{name: "William Gibson", role: "author"}]}, {title: "Good Omens", published: 1990, pages: 288, rating: 4.3, genres: ["Fantasy", "Comedy"], authors: [{name: "Terry Pratchett", role: "author"}, {name: "Neil Gaiman", role: "author"}]}, {title: "The Hobbit", published: 1937, pages: 310, rating: 4.3, genres: ["Fantasy"], authors: [{name: "J. R. R. Tolkien", role: "author"}]}, {title: "Emma", published: 1815, pages: 474, rating: 4.0, genres: ["Romance"], authors: [{name: "Jane Austen", role: "author"}]} ] AS b MERGE (book:Book {title: b.title}) SET book.published = b.published, book.pages = b.pages, book.rating = b.rating FOREACH (g IN b.genres | MERGE (ge:Genre {name: g}) MERGE (book)-[:IN_GENRE]->(ge)) FOREACH (a IN b.authors | MERGE (au:Author {name: a.name}) MERGE (au)-[r:WROTE]->(book) SET r.role = a.role); ``` Map keys in Cypher are bare words (`title:`), not quoted strings. ### 3. Point the server at them Create `grandstack/compose.books.yaml`. It overrides the `server` service so the image uses your files and settings instead of the Movies ones, with no rebuild: ```yaml services: server: environment: SEED_PATH: /app/seed/books.cypher # which seed file to load SEED_PROBE_LABEL: Book # label used to tell whether the database already has data REFRESH_COUNTS: "false" # the Movies sample keeps film counts on Person; you have none volumes: - ./my-app/schema.graphql:/app/schema/schema.graphql:ro - ./my-app/books.cypher:/app/seed/books.cypher:ro ``` ### 4. Start Neo4j and the server Stop the Movies stack first if it is running (it uses the same ports), then start only Neo4j and the server under their own project name so the Movies data is not mixed in: ```sh cd grandstack docker compose down docker compose -p mybooks -f compose.yaml -f compose.books.yaml up --build neo4j server ``` The server log should say `Loaded seed` and `GraphQL ready at http://localhost:4000/graphql`. Open that URL for GraphiQL, or: ```sh curl -s localhost:4000/graphql -H 'content-type: application/json' \ -d '{"query":"{ books(sort:[{rating:DESC}]) { title published authors { name } genres { name } } }"}' ``` ### 5. Try writes Everything the library generates for `Book` works with no more code: filters, sorting, `booksConnection` with `totalCount`, and create, update and delete. This one connects an existing author and creates a new one, setting the relationship property on each: ```graphql mutation($i: [BookCreateInput!]!) { createBooks(input: $i) { books { title authorsConnection { edges { properties { role } node { name } } } } } } ``` ```json { "i": [{ "title": "Anathem", "published": 2008, "authors": { "connect": [{ "where": { "node": { "name": { "eq": "William Gibson" } } }, "edge": { "role": "translator" } }], "create": [{ "node": { "name": "Neal Stephenson" }, "edge": { "role": "author" } }] } }] } ``` The rules for updates and deletes, including how to change relationships safely, are in the server's `docs/mutations.md`. ### 6. Change the seed later Edit `books.cypher` and **bump `// seed-version`**; on the next server start the database reloads it. Without a bump nothing happens. Note: the marker node is named `movies` regardless of your data (an implementation detail, harmless). Check the log for `Loaded seed` or `Seed already loaded, skipping`. ## Part 2: a front end The web app is written for Movies. Codegen shows exactly what depends on that. ### 7. Copy the web app and see what breaks ```sh cp -R grandstack-web my-library-web cd my-library-web rm -rf node_modules dist && npm ci npm run codegen ``` With the Books server running, codegen fails with a list like `Cannot query field "movies" on type "Query"` (49 errors for the stock app). That list is every place the app assumes Movies, which is the point: the schema is the contract. ### 8. Keep what is generic, remove what is not ```sh rm -rf src/pages src/search src/forms src/graph e2e docs rm src/lib/filters.ts src/lib/filters.test.ts src/lib/peopleFilters.ts src/lib/peopleFilters.test.ts src/lib/compare.tsx npm pkg delete scripts.e2e scripts.docs:check ``` What stays and is reusable as it is: `src/ui` (buttons, chips, dialog, avatar), `src/poster` (generated posters), `src/theme` and `styles.css`, and `src/lib/hash.ts` and `initials.ts`. See the web repo's `docs/customising.md` for the full table. ### 9. Write a typed page Create `src/pages/Books.tsx`. The query in `graphql()` is validated against your schema and typed by `codegen`: ```tsx import { useState } from "react"; import { useQuery } from "urql"; import { graphql } from "../graphql/generated"; import { Poster } from "../poster/Poster"; import { RatingBadge } from "../ui"; const BooksQuery = graphql(` query Books($search: String!) { books(where: { title: { contains: $search } }, sort: [{ rating: DESC }]) { title published pages rating genres { name } authorsConnection { edges { properties { role } node { name } } } } } `); export function Books() { const [search, setSearch] = useState(""); const [{ data, fetching, error }] = useQuery({ query: BooksQuery, variables: { search } }); return (
setSearch(e.target.value)} aria-label="Search books" /> {error &&

{error.message}

} {fetching && !data &&

Loading…

}
{data?.books.map((b) => (
{b.title}

{b.authorsConnection.edges.map((e) => `${e.node.name} (${e.properties.role})`).join(", ")}

{b.genres.map((g) => g.name).join(" · ")}{b.pages ? ` · ${b.pages} pages` : ""}

))}
); } ``` Replace `src/App.tsx` with: ```tsx import { Books } from "./pages/Books"; export function App() { return (

My Library

); } ``` And replace `src/main.tsx` with this version, which drops the router (you removed every route): ```tsx import "@fontsource-variable/public-sans"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { MotionConfig } from "motion/react"; import { Client, Provider, cacheExchange, fetchExchange } from "urql"; import { App } from "./App"; import "./theme/tokens.css"; import "./styles.css"; const client = new Client({ url: import.meta.env.VITE_GRAPHQL_URL ?? "http://localhost:4000/graphql", exchanges: [cacheExchange, fetchExchange], requestPolicy: "cache-and-network", }); createRoot(document.getElementById("root")!).render( , ); ``` ### 10. Generate types, check, run ```sh npm run codegen # must succeed now: it validates your query against the Books schema npm run typecheck npm test npm run dev # http://localhost:3000 ``` You should see six book cards with generated posters, authors with their roles and genres. (The dev server uses port 3000, which the server allows by default through `CORS_ORIGIN`.) ## Where to go next Use the web repo's `docs/recipes.md` for the patterns, adapted to your entities: - **Filters in the URL:** copy the idea of `src/lib/filters.ts` (parse, serialize, `buildWhere`) in the original repo for your own fields. - **Facet counts, people browser, compare, graph views:** see the web repo's `docs/features.md` for how each works. - **Create, edit, delete forms:** `src/forms/model.ts` in the original repo shows how form state becomes nested mutation inputs and relationship diffs. - **Computed fields:** add `@cypher` fields like `Movie.similar` (see `docs/schema-guide.md` in `grandstack-server`). - Keep the server's rules in mind: describe every field, bump the seed version, and update relationships as diffs. ## Adding login to your app The auth setup in `grandstack-auth` is not tied to Movies. The pieces you would reuse: - **The gateway** (`gateway/`) is generic: point `UPSTREAM_URL` at your server, set the issuer, audience and JWKS URL, and list your mutations in `MUTATION_PERMISSIONS` in `gateway/rules.ts` with the permission each needs (unknown mutations are refused). - **Row-level rules** live in an extra schema file for your own types (see `schema/auth.graphql` for `filter` rules, `$jwt.sub` comparisons and the "author comes from the token" pattern). The server loads it with `SCHEMA_EXTRA_PATHS` and verifies tokens when the three `AUTH_*` variables are set. Mind the v7 pitfalls: `validate` rules and `DateTime` need APOC. - **Roles and users** come from your identity provider; the dev realm file shows the Keycloak side, and `docs/swapping-providers.md` lists what to change for another provider. - **A front end** needs an OpenID Connect client; `grandstack-auth/docs/exercise-add-auth-to-grandstack-web.md` walks through it. ## What is Movies-specific and what is not | Reusable as is | Pattern to copy | Movies-specific | | --- | --- | --- | | The server image, config, plugin mechanism, seed versioning, `src/ui`, posters, theme, graph view component | URL-synced filters, facet counts, diff-based update inputs, typed operations | `schema/schema.graphql`, `seed/movies.cypher`, `src/pages`, `src/search`, the people and movie filters | --- # grandstack: Configuration URL: https://www.grandstack.ai/docs/grandstack/configuration/ How the stack is wired and every setting that matters, with pointers to the repo that owns each one. > **Where to run this.** Docker Compose commands run inside a clone of this repository (`git clone https://github.com/grand-stack/grandstack`, then `cd grandstack`), with the repositories it builds cloned beside it. The [quickstart](/docs/grandstack/#quickstart) has the exact steps. ## Compose services `compose.yaml` defines three services: | Service | Image or build | Ports (host:container) | Notes | | --- | --- | --- | --- | | `neo4j` | `neo4j:2026.10-community` | 7474:7474 (browser), 7687:7687 (bolt) | Data in the named volume `neo4j-data`. Healthcheck runs `cypher-shell`; the server waits for it. | | `server` | built from `../grandstack-server` | 4000:4000 | Needs Neo4j healthy first. | | `web` | built from `../grandstack-web` | 3000:8080 | Static files served by unprivileged nginx. | Because the builds use `../grandstack-server` and `../grandstack-web`, **the three repositories must sit side by side in one folder.** The auth setup also builds from `../grandstack-auth`, the agents setup from `../grandstack-agents`, and the federation setup from `../grandstack-federation`, so those repositories must be there too if you use them. ### With authentication: `compose.auth.yaml` An override file, used together with `compose.yaml`: `docker compose -f compose.yaml -f compose.auth.yaml up --build`. Development only. It changes and adds: | Service | Change | | --- | --- | | `keycloak` (new) | `quay.io/keycloak/keycloak:26.8.1` in dev mode, imports `../grandstack-auth/keycloak/grandstack-realm.json`, issuer fixed to `http://localhost:8081/realms/grandstack`, port 8081, healthcheck | | `gateway` (new) | built from `../grandstack-auth/gateway`, in front of the server, port 4000 | | `auth-client` (new) | the demo client, built from `../grandstack-auth/client`, port 3001 | | `server` | **no host port**; gets `AUTH_JWKS_URL`, `AUTH_ISSUER`, `AUTH_AUDIENCE`, `SCHEMA_EXTRA_PATHS`, `SETUP_CYPHER_PATHS`; mounts `auth.graphql` and `auth-setup.cypher`; a healthcheck; waits for Keycloak | | `web` | unchanged: still http://localhost:3000, still calling `http://localhost:4000/graphql`, which is now the gateway, so it is an anonymous client | Ports in the auth setup: 3000 web, 3001 demo client, 4000 gateway, 8081 Keycloak, 7474 and 7687 Neo4j. Everything else about the auth settings (roles, permissions, rules, the issuer-versus-keys addresses) is documented in `grandstack-auth` (`docs/how-it-works.md`, `docs/role-and-scope-model.md`). ### With agents: `compose.agents.yaml` An override file, used together with `compose.yaml`: `docker compose -f compose.yaml -f compose.agents.yaml up --build`. It adds one service: | Service | Detail | | --- | --- | | `agents-mcp` (new) | built from `../grandstack-agents`; MCP over streamable HTTP at `http://localhost:4100/mcp`; `GRAPHQL_URL=http://server:4000/graphql`; `ALLOW_MUTATIONS` from `AGENTS_ALLOW_MUTATIONS` (default `false`) | It talks to the open server, so it needs the default stack and has no auth of its own. Keep port 4100 on your machine. To use it with the auth setup, give it a token as described in `grandstack-agents` (`docs/adding-auth.md`). ### With federation: `compose.federation.yaml` An override file, used together with `compose.yaml`: `docker compose -f compose.yaml -f compose.federation.yaml up --build`. It changes and adds: | Service | Detail | | --- | --- | | `server` | `FEDERATION=true`; mounts `../grandstack-federation/schema/federation.graphql` (`SCHEMA_EXTRA_PATHS`) and `seed/wikipedia-links.cypher` (`SETUP_CYPHER_PATHS`); **host port 4001** instead of 4000; a healthcheck | | `wikipedia` (new) | built from `../grandstack-federation`; the Wikipedia subgraph, port 4010 | | `gateway` (new) | built from `../grandstack-federation/gateway`; serves the composed supergraph on **port 4000**; waits for the server and the Wikipedia subgraph | | `web` | unchanged: still http://localhost:3000, still calling `http://localhost:4000/graphql`, which is now the gateway | It needs internet for the Wikipedia part, and it replaces the default, auth and agents setups on port 4000. Ports in the federation setup: 3000 web, 4000 gateway, 4001 Neo4j subgraph, 4010 Wikipedia subgraph, 7474 and 7687 Neo4j. ## Settings you are likely to change | Setting | Where | Default | Effect | | --- | --- | --- | --- | | `NEO4J_PASSWORD` | `.env` (copy from `.env.example`) | `grandstack-dev` | Neo4j password; used by Neo4j, the healthcheck and the server. Set before the first start: Neo4j stores the password in the volume. | | Server variables (`SEED`, `SEED_PATH`, `SEED_PROBE_LABEL`, `REFRESH_COUNTS`, `CORS_ORIGIN`, `NEO4J_DATABASE`, `AUTH_*`, `SCHEMA_EXTRA_PATHS`, `SETUP_CYPHER_PATHS`, ...) | `server.environment` in `compose.yaml` | see the server's `docs/configuration.md` | How the server behaves. Add the ones you need under `server.environment`. | | `VITE_GRAPHQL_URL` | `web.build.args` in `compose.yaml` | `http://localhost:4000/graphql` | The URL the **browser** uses to reach the server. Fixed at build time, so rebuild `web` after changing it. | | Host ports | `ports` in `compose.yaml` | 3000, 4000, 7474, 7687 | Change the left side to avoid clashes. If you move the web port, also set the server's `CORS_ORIGIN` to the new web origin. If you move the server port, change `VITE_GRAPHQL_URL`. | The server's own variable list lives in `docs/configuration.md` in `grandstack-server`; it is the source of truth. ## Everyday commands ```sh docker compose up --build # start everything (first run builds the images) docker compose up -d # start in the background docker compose logs -f server # follow one service docker compose down # stop, keep the data docker compose down -v # stop and DELETE the Neo4j data (the sample reseeds on next start) ``` Use your own schema and data with an override file: [build your own app](/docs/grandstack/build-your-own-app/). ## Podman On macOS `docker` may actually be Podman. If commands cannot connect, start the VM with `podman machine start` and set `DOCKER_HOST` to the socket it prints. Compose works the same way. The stack in this repo was developed and tested under Podman. ## Resetting data The default stack has no authentication, and the app can edit and delete everything. (With the auth setup, editing needs a login with the right role.) To get the sample data back, run `docker compose down -v` then `docker compose up` (add the `-f` files you started with). A seed newer than the loaded one reloads by itself on server start; see the server's `docs/seeding.md`. ## If a port is already in use If you already run Neo4j (or anything else) on one of these ports, compose may fail to bind it, or a client may reach the wrong server: on the machine this was built on, a local Neo4j listened on `127.0.0.1:7687` while the container's forwarded port was bound as well, so `bolt://localhost:7687` could hit either one. The stack itself is unaffected, because the server reaches Neo4j over the compose network (`bolt://neo4j:7687`). To avoid confusion, change the left side of the port mapping in `compose.yaml` (for example `"17687:7687"`) before connecting a tool from your machine, and check what is listening with `lsof -nP -iTCP:7687 -sTCP:LISTEN`. ## Ports at a glance | URL | What | | --- | --- | | http://localhost:3000 | Web app | | http://localhost:4000/graphql | GraphQL API and GraphiQL | | http://localhost:4001/graphql | Neo4j subgraph (federation setup only) | | http://localhost:4010/graphql | Wikipedia subgraph (federation setup only) | | http://localhost:4100/mcp | Agents MCP server (agents setup only; `/healthz` for a check) | | http://localhost:7474 | Neo4j Browser (user `neo4j`, password from `.env`) | | http://localhost:3001 | Auth demo client (auth setup only) | | http://localhost:8081 | Keycloak (auth setup only; admin console `admin` / `admin`) | | bolt://localhost:7687 | Neo4j Bolt | --- # grandstack: Agent guide URL: https://www.grandstack.ai/docs/grandstack/agents/ For coding agents (Claude Code or any other) and the people directing them. It says what the system is, where to change what, how to verify, and what goes wrong. It is agent-agnostic; the worked example uses Claude Code. > **Where to run this.** Docker Compose commands run inside a clone of this repository (`git clone https://github.com/grand-stack/grandstack`, then `cd grandstack`), with the repositories it builds cloned beside it. The [quickstart](/docs/grandstack/#quickstart) has the exact steps. ## Mental model in six lines 1. Neo4j holds a graph. The GraphQL API is **generated** from `grandstack-server/schema/schema.graphql`; there are no resolvers to edit. 2. The web app (`grandstack-web`) asks that API for data using operations written with `graphql()` and typed by `npm run codegen` against the live schema. 3. State a user may want to share (filters) lives in the URL. 4. Sample data comes from a versioned Cypher seed; titles and names are unique keys. 5. Writes go through generated mutations; a server plugin keeps stored film counts correct. 6. The default stack has **no authentication**, and the app can edit and delete everything. An opt-in setup (`compose.auth.yaml`, the `grandstack-auth` repo) adds login, a gateway and row-level rules; `grandstack-web` is intentionally not part of it. ## Notes on setup `npm ci` prints `npm warn install-scripts ... approve` warnings; they are harmless. The package versions in `package.json` match the git tags. ## Where to change what | You want to... | Change | Then run | | --- | --- | --- | | Add or change a field, type or relationship | `grandstack-server/schema/schema.graphql` (+ seed if it needs data) | server checks, then `npm run codegen` in the web repo | | Change the sample data | `grandstack-server/seed/movies.cypher`, bump `// seed-version` | restart the server | | Add a computed field | `@cypher` in the schema | server checks | | Change server behaviour or add a setting | `grandstack-server/src/` | server checks | | Show a field, add a filter, page or form | `grandstack-web/src/` | web checks | | Change look and feel | `grandstack-web/src/theme/tokens.css`, `src/styles.css` | web checks | | Change ports, services or settings of the stack | `grandstack/compose.yaml` | `docker compose up --build` | | Change who can do what (roles, permissions, rules) | `grandstack-auth` (`gateway/rules.ts`, `schema/auth.graphql`, `keycloak/grandstack-realm.json`) | its smoke script and tests | | Add login to a front end | follow `grandstack-auth/docs/exercise-add-auth-to-grandstack-web.md` in your own copy | its acceptance check | | Use your own dataset | see [build your own app](/docs/grandstack/build-your-own-app/) | | Each repository has recipes for the common changes: `docs/recipes.md` in `grandstack-server` and in `grandstack-web`. Start there. ## Start the stack ```sh cd grandstack cp .env.example .env # once docker compose up --build # web :3000, GraphQL :4000/graphql, Neo4j browser :7474 # with login (development only): gateway :4000, Keycloak :8081, auth demo client :3001 docker compose -f compose.yaml -f compose.auth.yaml up --build # with federation (Wikipedia summaries on films and people; needs internet): gateway :4000, subgraphs :4001 and :4010 docker compose -f compose.yaml -f compose.federation.yaml up --build # with an MCP server for agents: http://localhost:4100/mcp (read-only; AGENTS_ALLOW_MUTATIONS=true to write) docker compose -f compose.yaml -f compose.agents.yaml up --build ``` Wait for the server log line `GraphQL ready at http://localhost:4000/graphql`. Use `docker compose down -v` to reset the data to the sample. ## Verification checklist Run what matches the repo you changed. All of it must pass before you call a change done. ```sh # grandstack-server npm run lint && npm test && npm run schema:check && npm run docs:check # grandstack-web (codegen needs a server running with the schema you want; it reads # GRAPHQL_URL, default http://localhost:4000/graphql) npm run codegen && npm run typecheck && npm test && npm run docs:check # grandstack-web browser checks (freshly seeded stack; creates and deletes ZZ-prefixed test data) npm run e2e # grandstack-auth: unit tests, then (with the auth stack running) the token-level and browser checks (cd gateway && npm ci && npm test && npm run typecheck) (cd client && npm ci && npm test && npm run typecheck) npm ci && npm run docs:check && npm run smoke && npm run e2e # grandstack-agents: tests and types need no stack; live needs the open stack, auth:check the auth stack npm ci && npm test && npm run typecheck && npm run docs:check && npm run live # grandstack-federation: tests and types need no stack; smoke needs the federation stack and internet npm ci && npm test && npm run typecheck && npm run docs:check && npm run smoke ``` `schema:check` needs a git checkout with the schema committed at `HEAD` (commit the baseline before you edit). `docs:check` fails until you have run `npm run docs:schema` after a schema change; commit the regenerated reference. For a server schema change also check the live API, for example: ```sh curl -s localhost:4000/graphql -H 'content-type: application/json' \ -d '{"query":"{ movies(limit:1) { title rating } }"}' ``` Do not claim a UI change works from a build alone: open the page (or use `npm run e2e` screenshots with `OUT=dir`) and look. ## Using the API without writing code The endpoint accepts introspection, so an agent can discover the schema itself: ```sh curl -s localhost:4000/graphql -H 'content-type: application/json' \ -d '{"query":"{ __schema { queryType { fields { name description } } mutationType { fields { name } } } }"}' ``` Every type and field defined in the schema has a description (lint enforces it), and `grandstack-server/docs/schema-reference.md` lists them. The generated queries and mutations (`movies`, `createMovies`, ...) have no descriptions but follow a fixed naming pattern per type (see that repo's `docs/schema-guide.md`). Filters look like `movies(where: { rating: { gte: 8 }, genres: { some: { name: { in: ["Drama"] } } } })`; writes are covered in `grandstack-server/docs/mutations.md`. An MCP server that runs GraphQL statements is available with `compose.agents.yaml` (port 4100, read-only by default); see `grandstack-agents` (`README.md`, `docs/direct-graphql.md`). How well an agent does depends on how the type definitions are written; see `grandstack-agents` `docs/schema-design-for-agents.md`. ## Gotchas that cost time - **Every type, field and argument needs a description** in the schema, or lint fails. - **Types need `@node`** (v7). New Neo4j directives need a stub in `grandstack-server/tooling/neo4j-directives.graphql`. - **Scalar updates are `{ set: value }`.** A bare value fails validation. - **Relationship updates must be diffs.** "Disconnect all, then connect" in one mutation removes the new links, because the disconnect runs last. `buildUpdateInput` in the web repo shows the right shape. - **`contains` is case-sensitive.** The web app matches several casings. - **Seed changes need a bumped `// seed-version`.** Cypher map keys are bare words. - **Operation names must be unique across the web app**, and variables used in non-null positions must be declared non-null. - **Keep `graphql` on 16.x** in both repos. Pin exact dependency versions. - **Do not add `@neo4j-ndl/*`** to the web app: it is GPL-3.0 and the repo is Apache-2.0. - **`VITE_GRAPHQL_URL` is fixed at build time** and must be reachable from the browser; the server's `CORS_ORIGIN` must allow the web origin. - **Text inputs that write to the URL need local state and a debounce**, or fast typing drops characters. - **Never expose the stack publicly.** The default stack has no auth, and the auth setup is development-only (demo realm, known passwords, plain HTTP). - **Auth:** a new mutation must be added to `MUTATION_PERMISSIONS` in `grandstack-auth/gateway/rules.ts` or the gateway refuses it; server rules must be `filter` (not `validate`) and avoid `DateTime` (both need APOC); `AUTH_ISSUER` is the browser-facing URL while `AUTH_JWKS_URL` is the container-reachable one; realm edits need a recreated Keycloak. More in `grandstack-auth/AGENTS.md`. - **Commit generated files when they change:** `grandstack-web/src/graphql/generated/` and `grandstack-server/docs/schema-reference.md`. ## Keep the docs true Docs live next to the code they describe. When you change behaviour, update the doc in the same change: environment variables in `docs/configuration.md` (server), routes and folder map in `docs/architecture.md` (web), versions in [versioning and releases](/docs/grandstack/versioning-and-releases/). `npm run docs:check` fails on a stale schema reference (server) or a broken relative link (all repos). ## Example: asking Claude Code to make a change Open Claude Code in the folder that holds the repositories, then give it a task and point it at the docs: > Add a `country` field to Movie and show it on the movie page. Follow `grandstack/docs/agents.md` and the recipes in each repo's `docs/recipes.md`. Run the checks before you say it is done. A good run follows the recipes: add the described field to the schema, lint and check for breaking changes, regenerate the schema reference, restart the server, add `country` to the movie query in the web app, run `npm run codegen`, render it in `MovieDetail.tsx`, then run the checks above and look at the page. Any other agent can follow the same documents. --- # grandstack: AGENTS.md: guide for coding agents URL: https://www.grandstack.ai/docs/grandstack/agents-md/ Compose stack plus the overview docs for GRANDstack (GraphQL on Neo4j: `grandstack-server`, `grandstack-web`). Read [docs/agents.md](/docs/grandstack/agents/) first; it covers the mental model, where to change what, the verification checklist and the gotchas. [llms.txt](https://github.com/grand-stack/grandstack/blob/main/llms.txt) indexes everything. - Start: `cp .env.example .env && docker compose up --build` (web :3000, GraphQL :4000/graphql, Neo4j :7474). The three repos must sit side by side because compose builds from `../grandstack-server` and `../grandstack-web`. - `docker compose down -v` deletes the Neo4j data; the sample reseeds on the next start. - Change the schema, seed or server behaviour in `grandstack-server`; change the UI in `grandstack-web`; follow each repo's `AGENTS.md` and `docs/recipes.md`. - Before you finish, run the checks for each repo you changed (listed in [docs/agents.md](/docs/grandstack/agents/#verification-checklist)) and `node scripts/check-links.mjs` here. - The default stack has no authentication. The opt-in auth setup (`docker compose -f compose.yaml -f compose.auth.yaml up --build`, from the `grandstack-auth` repo) is development-only. Do not expose either publicly. - Agents: `docker compose -f compose.yaml -f compose.agents.yaml up --build` adds an MCP server at http://localhost:4100/mcp (from the `grandstack-agents` repo). It is read-only unless `AGENTS_ALLOW_MUTATIONS=true`, has no auth of its own, and talks to the open server. Keep it local. - Federation: `docker compose -f compose.yaml -f compose.federation.yaml up --build` (from the `grandstack-federation` repo) puts a gateway on :4000 and moves the server subgraph to :4001; a Wikipedia subgraph adds summaries to films and people. Needs internet. It replaces the other setups on port 4000. Open, no auth. - Keep docs true: update the doc next to the behaviour you change, in the same change. - Diagrams: `docs/diagrams/*.svg` are hand-written SVG with numbered callouts, each embedded with a numbered text legend. When you change the behaviour a diagram shows, edit the SVG and its legend in the same change; `node scripts/check-links.mjs` fails if an image has no alt text, an SVG contains script or style, or the legend numbers differ from the callouts drawn. - New code files need the copyright header (two lines: `Copyright (c) "Neo4j"` and `Neo4j Sweden AB [http://neo4j.com]`, in the file's comment syntax). Run `node ../grandstack/scripts/add-headers.mjs` to add it everywhere; `--check` lists what is missing. Generated files are exempt. - Releases use the **release-cut** workflow (Actions tab), which sets the version in every `package.json`, tags and publishes. Do not bump versions or push tags by hand; see `grandstack/docs/releasing.md`. --- # grandstack: Versioning and releases URL: https://www.grandstack.ai/docs/grandstack/versioning-and-releases/ ## Scheme Every repository uses **semantic versions as git tags** (`v0.6.0`). A change that adds behaviour bumps the minor version while we are below 1.0; a fix or a docs-only change bumps the patch version. The server also publishes a Docker image whose tag matches the git tag. ## What is released today | Repository | Latest tag | Notes | | --- | --- | --- | | `grandstack-server` | `v0.9.3` | v0.9.3: setup Cypher runs after the seed (fixes first-start links on a fresh database), workflows. v0.9.2: copyright headers on code files. v0.9.1: LICENSE and NOTICE. v0.9.0: `FEDERATION` setting (federation subgraph), `@apollo/subgraph` pinned to 2.14.4. v0.7.1: annotated diagrams (docs only). v0.7.0 added the generic auth settings (`AUTH_*`, `SCHEMA_EXTRA_PATHS`, `SETUP_CYPHER_PATHS`); earlier: `SEED_PROBE_LABEL`, `REFRESH_COUNTS`, schema reference generator, docs | | `grandstack-web` | `v0.5.4` | v0.5.4: workflows. v0.5.3: copyright headers on code files. v0.5.2: LICENSE and NOTICE. v0.5.1: annotated diagrams (docs only). v0.5.0: Docs, shared `hash` and `initials` helpers, browser end-to-end script | | `grandstack-auth` | `v0.1.4` | v0.1.4: workflows. v0.1.3: copyright headers on code files. v0.1.2: LICENSE and NOTICE. v0.1.1: annotated diagrams (docs only). v0.1.0: gateway, Keycloak dev realm, auth schema rules, demo client, smoke and browser tests, the exercise and its reference patch (made against `grandstack-web` v0.5.0) | | `grand-stack.github.io` | `v0.1.3` | v0.1.3: `site.yml` (rebuild on every release), tag resolver, CI. v0.1.2: title "GraphQL and Neo4j", roadmap page removed from the site, copyright headers. v0.1.1: fixes after cold reads (diagram links, checker, quickstart, README). v0.1.0: | The website (https://grandstack.ai): hand-written Hugo theme, a generated copy of every repo's docs, examples verified against real stacks, llms.txt and llms-full.txt, Apache-2.0 | | `grandstack-federation` | `v0.1.4` | v0.1.4: docs note on setup order, workflows. v0.1.3: copyright headers on code files. v0.1.2: LICENSE and NOTICE. v0.1.1 and earlier: | v0.1.1: docs fixes after cold reads. v0.1.0: Wikipedia subgraph, gateway config and supergraph, link builder and committed links, smoke script, docs and diagrams | | `grandstack-agents` | `v0.1.5` | v0.1.5: workflows. v0.1.4: copyright headers on code files. v0.1.3: LICENSE and NOTICE. v0.1.2 and earlier: | v0.1.2: `@cypher` in the schema design docs. v0.1.1: docs on schema design for agents. v0.1.0: MCP server (stdio and streamable HTTP) with generic GraphQL tools, read-only by default; direct agent-loop example; auth example code; docs and diagrams; Dockerfile | | `.github` | `v1.1.1` (`v1`) | Shared reusable workflows (CI, release, release button, CodeQL, dependency review), scripts, org profile and community files | | `grandstack` | `v0.10.4` | v0.10.4: releasing guide corrected after an independent review of the workflows. v0.10.3: GitHub workflows (CI, release, nightly integration), `scripts/integration.sh`, releasing guide. v0.10.2: copyright headers on code files (`scripts/add-headers.mjs`), NOTICE copyright is Neo4j. v0.10.1: docs refresh (README versions, roadmap row). v0.10.0: LICENSE (Apache-2.0) and NOTICE in every repo, roadmap (phase 7 done), pointers to the website. v0.9.1: versions table. v0.9.0: `compose.federation.yaml`, roadmap (phase 6 done, site is phase 7), docs. v0.8.2: table update. v0.8.1: docs pointers to schema design for agents. v0.8.0: `compose.agents.yaml` (opt-in agents MCP server), roadmap re-cut, docs. v0.7.1: annotated diagrams. v0.7.0: `compose.auth.yaml` (opt-in auth), docs | The versions that were tested together are the ones in this table. All tags are **local so far**: nothing is pushed or published, and no images are in a registry. The release workflows that will publish them are described in [releasing](/docs/grandstack/releasing/). ## How the umbrella pins versions (today and planned) - **Today**, `compose.yaml` builds the server and web images from the sibling checkouts (`build: ../grandstack-server`). To get a known-good combination, check out the tags in the table in each repo. - **Planned:** once images are published, `compose.yaml` will reference image tags (for example `grandstack-server:0.6.0`), and the umbrella's own tag will name the set. That change is on the [roadmap](https://github.com/grand-stack/grandstack/blob/main/docs/roadmap.md). ## Cutting a release Use the **release-cut** workflow in the repository's Actions tab (patch, minor or major). It checks that CI is green on `main`, sets the version in every `package.json`, tags `vX.Y.Z`, publishes the GitHub Release and the container images, and tells the website to rebuild. The whole process, with a diagram, the one-time setup and a first-run checklist, is in [releasing, CI and the website](/docs/grandstack/releasing/). The tag must equal the `package.json` version; a mismatch fails the release. Before releasing, the checks that CI runs can be run locally: - server: `npm run lint && npm test && npm run schema:check && npm run docs:check` - web: `npm run typecheck && npm test && npm run docs:check`, and with a freshly seeded stack `npm run e2e` - auth: unit tests in `gateway/` and `client/`, `npm run docs:check`, `npm run verify-exercise`, and with the auth stack running `npm run smoke` and `npm run e2e` - all four compose setups: `scripts/integration.sh default|agents|auth|federation` (in this repository) If the server schema changed, run `npm run docs:schema` and commit the updated reference. Update the table above when a release changes what was tested together. ## Breaking changes For the GraphQL API, `npm run schema:check` in the server repo compares your schema with a git ref and fails on breaking changes (a removed field, a changed type). For the web app, `npm run codegen` fails when an operation no longer matches the server schema. Treat either as a signal to bump the minor version and note it in the release. ## Deprecated and old repositories The earlier `grand-stack` repositories are being replaced by these and will be made private and removed by their owner. Nothing here depends on them. --- # grandstack: Releasing, CI and the website URL: https://www.grandstack.ai/docs/grandstack/releasing/ How code is checked, how a release is made, and how every release reaches grandstack.ai. All of it runs as GitHub Actions in the `grand-stack` organisation. The shared pieces live in the organisation's `.github` repository (reusable workflows tagged `v1`); each repository has small workflow files that call them. > **Status.** These workflows have run on GitHub for the first time: CI is green in every repository, the website workflow checked out each repository at its tag, built, checked and deployed grandstack.ai, the nightly integration test passed for all four setups, and dry runs of the release and release-button workflows worked (the release dry run correctly refused a tag whose commit had no green CI run). **Not yet run for real:** a published release (GitHub Release, container images, the dispatch to the website), because that needs a real release and the `SITE_DISPATCH_TOKEN` secret. Follow the [first-run checklist](#first-run-checklist) for those. ## On every commit and pull request Each repository's `ci.yml` runs on pull requests and on pushes to `main`: | Repository | What runs | | --- | --- | | `grandstack-server` | lint (schema lint and TypeScript), tests, docs check, build; on pull requests the GraphQL breaking-change check; a Docker build and a smoke test against a real Neo4j | | `grandstack-web` | typecheck, tests, docs check, build, Docker build | | `grandstack-auth` | typecheck and tests for the gateway and the client (and the client build), docs check, Docker builds | | `grandstack-agents`, `grandstack-federation` | typecheck, tests, build, docs check, Docker build(s) | | `grandstack` (this repo), `.github` | the docs link check (`.github`: its script tests and a workflow lint) | | `grand-stack.github.io` | the site's unit tests, a build, and the site checks on the committed docs copy | Every one of them also checks that **every code file has the copyright header**. A newer push cancels the still-running checks of the one before it. None of these checks blocks a merge until you mark it as required in branch protection or a ruleset. Code scanning is GitHub's CodeQL default setup (it is switched on for the organisation's repositories; results are under each repository's Security tab), a dependency review fails pull requests that add a dependency with a high-severity vulnerability (it does not check licences yet), and Dependabot proposes weekly updates for npm, Docker and Actions. Dependabot is told to leave alone the pins that must hold: `graphql` stays 16.x, `@apollo/subgraph` stays 2.14.4, and the MCP SDK stays on its v1 API. ## Making a release Versions are semantic (`vMAJOR.MINOR.PATCH`) git tags, and the tag must equal the version in every `package.json` of the repository. ![What happens on a release](/diagrams/grandstack/release-flow.svg) 1. **The release button.** In the repository's Actions tab, run **release-cut**: choose patch, minor or major (or tick dry-run to see the next version and the change without pushing). 2. **`cut-release`** checks that CI succeeded on the tip of `main`, works out the next version from the latest tag, sets it in every `package.json` (and lock file), commits `release vX.Y.Z`, tags it and pushes. The release-cut workflow then runs the release workflow in the same run, because a tag pushed with the default token does not start other workflows. (The release commit does not get its own CI run for the same reason; the next release checks the commit before it.) 3. **The release workflow** validates the tag, that it is on `main`, and that all `package.json` versions agree with it. For a hand-pushed tag it also requires a successful `ci.yml` run on that commit. Images are tagged `latest` only when the tag is the highest release. You can start it by pushing a tag yourself (the version must already be committed) or by hand from the Actions tab with a tag and `dry-run`. 4. **GitHub Release** with generated notes. 5. **Container images**, built for `linux/amd64` and `linux/arm64` (the arm64 build is emulated and can take many minutes) and pushed to `ghcr.io/grand-stack/` with the tags `1.2.3`, `1.2` and `latest`. 6. **Notify**: a `repo-released` event goes to the website repository and to this repository. If the `SITE_DISPATCH_TOKEN` secret is missing, a notice says so, the website catches up at its next hourly check, and the integration test waits for its nightly run. 7. **The website** (`site.yml`) rebuilds. See below. 8. **The integration test** runs the four compose setups against the latest `main` of every repository. | Repository | Images | | --- | --- | | `grandstack-server` | `grandstack-server` | | `grandstack-web` | `grandstack-web` | | `grandstack-auth` | `grandstack-auth-gateway`, `grandstack-auth-client` | | `grandstack-agents` | `grandstack-agents-mcp` | | `grandstack-federation` | `grandstack-federation-wikipedia`, `grandstack-federation-gateway` | The web and auth-client images carry the URLs they were built with (`VITE_GRAPHQL_URL` and the OIDC settings point at `localhost`), so a published image fits the default local setup only; to use another address, build from source with build arguments. `docker compose` still builds from the sibling folders: pinning compose to published image tags is a later step. This repository (`grandstack`) and `.github` have no images and no `package.json`: the release button only tags. A repository's tag history is how the website and these docs name "the versions tested together" (see [versioning and releases](/docs/grandstack/versioning-and-releases/)). ## How a release reaches the website `grand-stack.github.io` has one workflow, `site.yml`, that runs when a repository sends `repo-released`, **every hour** as a fallback, when someone pushes to its `main`, and by hand. 1. It asks GitHub for the highest `vX.Y.Z` tag of each of the six documented repositories and compares them with the versions the site was last built from (`data/versions.json`). An hourly run with nothing new stops here. 2. It checks each repository out **at that tag**, beside the site, so the docs shown are the docs of the released version. 3. It runs `npm run sync`, `hugo --gc --minify` and the site checks (links, anchors, alt text, `llms.txt`). 4. It uploads the built site, and deploys it to GitHub Pages. 5. Last, if the docs copy changed, it commits it to the site's `main` as `github-actions[bot]` ("docs: sync from releases (...)"). If the branch rules refuse the bot, this step fails (the site has already been deployed), and the hourly check will keep rebuilding until the push works, so fix the rule for `github-actions[bot]`. Both the website and the integration test check out the other repositories with the default token, so **the repositories must be public** (or you must give the workflows a token that can read them). A repository the token cannot see makes the site job fail rather than silently use an old copy. A release of the website repository itself is not needed: it deploys on every push. ## The nightly integration test `integration.yml` (this repository) runs at night, after any release, and by hand. For each of `default`, `agents`, `auth` and `federation` it checks out the latest `main` of every repository, starts that compose setup, runs its checks, and tears it down: | Setup | Checks | | --- | --- | | default | `grandstack-web`'s browser end-to-end checks, the website's default examples | | agents | `grandstack-agents`' live and container checks, the agents examples | | auth | `grandstack-auth`'s smoke and browser checks, the auth example | | federation | `grandstack-federation`'s smoke test and the federation examples (live Wikipedia: a failure here is reported but does not fail the run) | The logic is a script, so you can run any setup yourself, beside the other repositories: `scripts/integration.sh agents`. (On a Mac with Podman, set `DOCKER_HOST` to the machine socket first.) Running it already found a real bug: the federation links were applied before the seed on a fresh database, so the first start had none. The server now runs `SETUP_CYPHER_PATHS` after the seed. ## Scripts you can run | Command | Does | | --- | --- | | `node ../.github/scripts/headers.mjs --root . --check` | Lists code files without the copyright header (omit `--check` to add it) | | `node ../.github/scripts/next-version.mjs --bump patch` | Prints the next version from the repository's tags | | `node ../.github/scripts/check-version.mjs --tag v1.2.3 --root .` | Checks the tag and the `package.json` versions | | `scripts/integration.sh ` | Runs one integration setup (above) | | `node scripts/latest-tags.mjs --local ..` (in the website repo) | Shows the latest tag of each repository and whether the site is out of date | ## One-time setup (the maintainers) None of this can be done by the workflows themselves. - Push all repositories and `.github` to `https://github.com/grand-stack`. Push `.github` first and make it public: the other repositories' workflows call it at `v1` (a moving major tag you move after each compatible change) and fetch its scripts with the default token. Push history with `git push origin main` then `git push origin --tags`: when more than three tags are pushed at once GitHub starts no tag workflows, whereas pushing one to three old tags would start real release runs for them (which stop at the CI check, but are noise). - Create the organisation secret **`SITE_DISPATCH_TOKEN`**: a fine-grained token (or GitHub App) with *Contents: read and write* on `grand-stack.github.io` and `grandstack`, available to every repository that releases. - **Pages:** in `grand-stack.github.io` settings, set the source to *GitHub Actions* and the custom domain to `grandstack.ai` (with Actions-based Pages the domain is a setting; the `CNAME` file in the repository is kept for tools that read it), and enforce HTTPS once DNS is ready. - **Branch rules:** the release button pushes a commit and a tag directly, and the site job pushes the refreshed docs copy, both as `github-actions[bot]`. If a repository's `main` requires pull requests or status checks, add `github-actions[bot]` (or a GitHub App) as a bypass actor, or those steps fail. - **Packages:** after the first publish, set each GHCR package to public (images are linked to their repository automatically by a label). - Enable Dependabot alerts and the **dependency graph** (dependency review needs it). Code scanning needs no workflow here: leave CodeQL's *default setup* on (adding a CodeQL workflow file as well makes both fail). Protect `v*` tags against deletion and rewriting, but do not block their creation, or the release button's tag push fails. Decide who may run the release button. ## First-run checklist 1. After the first push, run **ci** in each repository (a pull request, or the push to `main`) and fix anything that is red. 2. Run **release** by hand (Actions tab, *Run workflow*) with an existing tag and `dry-run` ticked: it validates and builds without publishing. Do not test it by pushing a tag: that is a real release. 3. Run **release-cut** with `dry-run` on the smallest repository, then for real as a patch release. Check the GitHub Release, the images, and that the website rebuilt. 4. Run **site** by hand once; run **integration** by hand once. ## When something fails | Symptom | Cause and fix | | --- | --- | | "No successful ci.yml run for commit ..." | The release needs green CI on that commit. Wait for it, or re-run it, then release again | | "package.json says version X, but the tag is Y" | Use the release button (it sets the versions), or run `npm version Y --no-git-tag-version` and commit before tagging | | A pushed tag did not start anything | Tags pushed with the default `GITHUB_TOKEN` do not start workflows; push it yourself, or use the release button, which chains the workflows | | "SITE_DISPATCH_TOKEN is not set" | The website was not told directly; it updates at its next hourly check. Create the secret to make it immediate | | The site workflow fails on a checkout or tag lookup (HTTP 404) | A repository is private or does not exist yet; the default token cannot read it. Make it public | | The site did not change after a release | Check the `site` run: the hourly check compares tags with `data/versions.json`; run it by hand. Pages must be set to *GitHub Actions* | | Copyright header check fails | Run `node ../.github/scripts/headers.mjs --root .` and commit | | Scheduled workflows stopped | GitHub disables schedules in repositories with no activity for 60 days; any commit or a manual run re-enables them | --- # grandstack-server: grandstack-server: overview URL: https://www.grandstack.ai/docs/grandstack-server/ A Docker image that serves GraphQL over Neo4j. You give it a Neo4j connection (environment variables), a GraphQL schema (a file) and optionally some seed data; it gives you a full API with queries, filters, sorting, relationships and create/update/delete. Built on [`@neo4j/graphql`](https://neo4j.com/docs/graphql/) v7, GraphQL Yoga and the Neo4j driver. It is the server half of GRANDstack, where the "A" now stands for Agent: the same API serves people and coding agents. > **Authentication is off by default.** Anyone who can reach the endpoint can read, change and delete data. To require logins, set the `AUTH_*` variables ([configuration](/docs/grandstack-server/configuration/#authentication-and-extra-schema)); the `grandstack-auth` repo is a complete example with a gateway, Keycloak and row-level rules. Do not expose an open server publicly. ## Run it The easiest way is the `grandstack` umbrella repo, which starts Neo4j, this server and the web app together (`docker compose up --build`). To run this server on its own against a Neo4j you already have, clone this repository, go into the folder, then build and run the image: ```sh git clone https://github.com/grand-stack/grandstack-server cd grandstack-server docker build -t grandstack-server:local . docker run --rm -p 4000:4000 \ -e NEO4J_URI=bolt://host.docker.internal:7687 -e NEO4J_PASSWORD=secret \ grandstack-server:local # GraphQL and GraphiQL: http://localhost:4000/graphql ``` `host.docker.internal` reaches a Neo4j running on your own machine. Under Podman `host.containers.internal` is the equivalent alias. Make sure it is the Neo4j you mean: if more than one Neo4j listens on port 7687 you can reach the wrong one and get an authentication error. The bundled Movies seed loads into an empty database on first start. See [configuration](/docs/grandstack-server/configuration/) for every setting, including how to use your own schema and data. ## Documentation | Read | To learn | | --- | --- | | [How it works](/docs/grandstack-server/how-it-works/) | Startup sequence, request flow, the per-request context | | [Schema guide](/docs/grandstack-server/schema-guide/) | Writing the schema: `@node`, relationships, `@cypher`, v7 rules | | [Schema reference](/docs/grandstack-server/schema-reference/) | Every type and field in the current schema (generated) | | [Mutations](/docs/grandstack-server/mutations/) | Create, update and delete, relationship diffs, the counts plugin | | [Seeding](/docs/grandstack-server/seeding/) | Seed format, versioning, using your own data | | [Configuration](/docs/grandstack-server/configuration/) | Environment variables, files, image, releases | | [Recipes](/docs/grandstack-server/recipes/) | Step-by-step common changes | | [Troubleshooting](/docs/grandstack-server/troubleshooting/) | Errors we hit and their fixes | `npm ci` prints `npm warn install-scripts` notices; they are harmless. Working with a coding agent? Start with [AGENTS.md](/docs/grandstack-server/agents-md/). The bundled data is sample data: titles, casts and relationships are real, but ratings and runtimes are approximate. Last verified: v0.7.1 with `@neo4j/graphql` 7.6.4, Neo4j 2026.10 community, Node 24 (image) and Node 26 (development). ## Licence Apache License 2.0. See [LICENSE](https://github.com/grand-stack/grandstack-server/blob/main/LICENSE) and [NOTICE](https://github.com/grand-stack/grandstack-server/blob/main/NOTICE). The same licence applies to every GRANDstack repository and to [grandstack.ai](https://grandstack.ai). --- # grandstack-server: How grandstack-server works URL: https://www.grandstack.ai/docs/grandstack-server/how-it-works/ grandstack-server is a small Node service that exposes a GraphQL API over a Neo4j database. You describe your data in a GraphQL schema file; [`@neo4j/graphql`](https://neo4j.com/docs/graphql/) turns it into a full API (queries, filters, sorting, create/update/delete) and translates every request into Cypher. [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server) serves it over HTTP. Applies to grandstack-server v0.7.1 (`@neo4j/graphql` 7.6.4, `graphql` 16.14, Node 22 or later). ## What is in the repo | Path | Purpose | | --- | --- | | `src/index.ts` | Entry point: wires everything together (below). | | `src/config.ts` | Reads and validates environment variables ([configuration](/docs/grandstack-server/configuration/)). | | `src/seed.ts` | Loads the seed Cypher file when needed ([seeding](/docs/grandstack-server/seeding/)). | | `src/counts.ts`, `src/refreshCountsPlugin.ts` | Keeps stored film counts correct after mutations ([mutations](/docs/grandstack-server/mutations/#stored-counts-are-refreshed-after-every-mutation)). | | `schema/schema.graphql` | **The API.** Your types, fields and relationships ([schema guide](/docs/grandstack-server/schema-guide/)). | | `seed/movies.cypher` | Sample data (152 films, 464 people). | | `tooling/neo4j-directives.graphql` | Stub declarations of Neo4j directives so lint and breaking-change tools can read the schema. | | `scripts/` | `schema-check.sh` (breaking changes), `generate-schema-docs.mjs`, `check-links.mjs`. | | `test/` | Unit tests (vitest). | | `Dockerfile` | Multi-stage build, runs as the non-root `node` user. | ## Startup sequence `src/index.ts` runs these steps in order. If any step throws, the process exits with the error, so a bad configuration fails fast. 1. **Read configuration** from environment variables. `NEO4J_URI` and `NEO4J_PASSWORD` are required. 2. **Create the Neo4j driver** and call `verifyConnectivity`. This fails immediately if Neo4j is unreachable or the credentials are wrong. 3. **Seed** (unless `SEED=false`): decide whether to load `seed/movies.cypher` ([rules](/docs/grandstack-server/seeding/)). 4. **Run setup Cypher** from `SETUP_CYPHER_PATHS`, if set (idempotent constraints, indexes and data fix-ups, run at every start, after the seed so it also applies to freshly loaded data). 5. **Read the schema**: the file at `SCHEMA_PATH` plus any `SCHEMA_EXTRA_PATHS`, joined, and build the executable schema with `new Neo4jGraphQL({ typeDefs, driver, features }).getSchema()`. If the `AUTH_*` variables are set, `features.authorization` is a remote JWKS with issuer and audience checks. With `FEDERATION=true` it calls `getSubgraphSchema()` instead, which needs a federation `@link` in the schema. Schema errors, for example a missing `@node`, surface here. 6. **Create the Yoga server** with CORS, the counts plugin (if `REFRESH_COUNTS` is not `false`) and the per-request context below. 7. **Listen** on `PORT`. The GraphQL endpoint is `/graphql`; opening it in a browser shows GraphiQL. 8. On `SIGINT` or `SIGTERM` it closes the HTTP server and the driver. ## What happens to a request [![grandstack-server start-up in eight steps: read config, connect, seed, setup Cypher, build the schema, create Yoga, listen, shut down; a failure at any step exits with the error.](/diagrams/grandstack-server/server-startup.svg)](/diagrams/grandstack-server/server-startup.svg) *Figure: the eight start-up steps in order. Click the image for full size; the numbered notes below carry the same information as text.* 1. **Read config.** Environment variables; `NEO4J_URI` and `NEO4J_PASSWORD` are required, and a bad value exits with a message that names it. 2. **Connect.** Create the driver and call `verifyConnectivity`; an unreachable Neo4j or a wrong password exits here. 3. **Seed.** Unless `SEED=false`, decide whether to load the seed file (see the seed decision in [seeding](/docs/grandstack-server/seeding/)). 4. **Setup Cypher.** Run the files in `SETUP_CYPHER_PATHS` on every start, after the seed (idempotent constraints, indexes and data fix-ups). 5. **Build the schema.** The base schema file plus `SCHEMA_EXTRA_PATHS`; with the `AUTH_*` variables set, tokens are verified against the JWKS. Schema mistakes (for example a missing `@node`) surface here. 6. **Create Yoga.** CORS, the counts plugin (unless `REFRESH_COUNTS=false`) and the per-request context. 7. **Listen.** On `PORT` (default 4000); GraphiQL is at `/graphql`. 8. **Shut down.** On `SIGINT` or `SIGTERM` close the HTTP server and the driver. [![The path every request takes in grandstack-server: Yoga CORS check, per-request context, the counts plugin for mutations, the Neo4j GraphQL library translating to Cypher, the driver, and Neo4j.](/diagrams/grandstack-server/request-path.svg)](/diagrams/grandstack-server/request-path.svg) *Figure: the path every request takes. Click the image for full size; the numbered notes below carry the same information as text.* 1. **Per-request context.** `executionContext` (the driver), `sessionConfig.database`, `token` (the `Authorization` header) and `cypherQueryOptions`. 2. **Counts plugin.** Wraps execution for mutations only: after the operation runs it recalculates the stored counts, then returns. 3. **The library does the work.** `@neo4j/graphql` translates the operation to Cypher and applies `@authentication` and `@authorization` rules when auth is on. 4. **Driver and database.** One session per request on `NEO4J_DATABASE`; rows come back the same way as a GraphQL JSON response. Nothing in this repo writes resolvers. Behaviour comes from the schema file and the directives in it. ## The per-request context Yoga calls `context` for every request in `src/index.ts`. `@neo4j/graphql` reads these keys: | Key | Value here | What it does | | --- | --- | --- | | `executionContext` | the shared driver | The driver (or session or transaction) used to run the generated Cypher. With a driver, the library opens a session for each request. | | `sessionConfig.database` | `NEO4J_DATABASE` (default `neo4j`) | Database every request runs against. Only used when `executionContext` is a driver. | | `token` | the `Authorization` header, if any | A bearer token (with or without `Bearer `). Ignored unless the `AUTH_*` variables are set; then it is verified and its claims drive `@authentication` and `@authorization` ([schema guide](/docs/grandstack-server/schema-guide/#authentication-and-authorization-rules-in-v7)). | | `cypherQueryOptions.addVersionPrefix` | `NEO4J_CYPHER_VERSION_PREFIX` (default `false`) | `true` prefixes generated queries with the Cypher version the library targets; `false` lets the database use its default. | Other keys exist but are not set. `jwt` supplies an already-decoded token payload and takes precedence over `token`. `cypherParams` supplies named parameters that `@cypher` fields can reference as `$name`, for example a tenant id. `transaction` sets metadata and a timeout for the transaction. The rest of `cypherQueryOptions` (`runtime`, `planner`, `replan`, and so on) are query-tuning options left at the database default. ## Limits to know about - Titles and names act as keys (unique constraints), so there are no ids. See [seeding](/docs/grandstack-server/seeding/). - Community Neo4j supports one database, which is why the default is `neo4j`. - `graphql` must stay on 16.x, because `@neo4j/graphql` 7.6.4 declares it as a peer dependency (graphql 17 is newer but not supported). - **Authentication is off by default.** With no `AUTH_*` variables anyone who can reach the endpoint can read and write everything. Turn it on as described in [configuration](/docs/grandstack-server/configuration/#authentication-and-extra-schema). Next: [schema guide](/docs/grandstack-server/schema-guide/), [mutations](/docs/grandstack-server/mutations/), [recipes](/docs/grandstack-server/recipes/). --- # grandstack-server: Schema guide URL: https://www.grandstack.ai/docs/grandstack-server/schema-guide/ The schema file, `schema/schema.graphql`, is the whole API. This page explains the pieces `@neo4j/graphql` v7 understands and the rules that tripped us up. For a list of what the current schema contains see the generated [schema reference](/docs/grandstack-server/schema-reference/). ## Node types A type stored as nodes in Neo4j needs the `@node` directive in v7: ```graphql """A film in the Movies graph.""" type Movie @node { """Film title. Unique within the dataset.""" title: String! released: Int } ``` - The node label is the type name (`Movie`). Use `@node(labels: ["Film"])` to store under a different label. - Properties map to fields with the same name. - Without `@node`, a type that uses `@relationship` fails at startup with `Directive "relationship" must be in a type with "@node"`. ## Relationships ```graphql type Movie @node { genres: [Genre!]! @relationship(type: "IN_GENRE", direction: OUT) actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") } type Person @node { actedIn: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") } type ActedIn @relationshipProperties { roles: [String!]! } ``` - Declare each relationship from **both** sides if you want to traverse it from both sides. `direction` is relative to the type the field is on: `Movie.actors` is `IN` (the arrow goes from Person to Movie), `Person.actedIn` is `OUT` for the same relationship. - `properties` names a `@relationshipProperties` type. Its fields are stored on the relationship. In queries they appear under `edges { properties { roles } }` in the `...Connection` fields. - Relationship types are the Neo4j relationship names (`ACTED_IN`), so the seed Cypher and the schema must agree. ## Computed fields with `@cypher` A field whose value is computed by Cypher each time it is requested: ```graphql similar(limit: Int = 8): [Movie!]! @cypher( statement: """ MATCH (this)-[r1:IN_GENRE|ACTED_IN|DIRECTED]-(shared)-[r2:IN_GENRE|ACTED_IN|DIRECTED]-(other:Movie) WHERE other <> this AND type(r1) = type(r2) WITH other, sum(CASE type(r1) WHEN 'DIRECTED' THEN 3 WHEN 'ACTED_IN' THEN 2 ELSE 1 END) AS score ORDER BY score DESC, other.rating DESC, other.title RETURN other LIMIT $limit """ columnName: "other" ) ``` - `this` is the current node. Field arguments are available as `$name`. - `columnName` must match the name returned by the statement. - Computed fields always reflect the current graph, but you cannot sort or filter by them in plain GraphQL. That is why film counts are stored as real properties instead ([seeding](/docs/grandstack-server/seeding/#derived-counts)). - Return type can be a node type or a scalar. A plain object type (like `Collaboration` below) works if the statement returns a map of scalars. **Known limitation:** a plain object type returned from `@cypher` cannot contain a node. `Person.collaborators` was first written to return `{person: , films: Int}` and failed with "Unexpected error." when `person.name` was requested. It returns `{name, films}` instead and clients look the person up by name. ## Rules that apply when you edit the schema 1. **Describe everything.** Lint (`npm run lint`) uses GraphQL ESLint's `require-description` for types, fields and arguments. A field with no description fails the build. 2. **Add stubs for new directives.** Lint and the breaking-change check cannot read Neo4j directives on their own. `tooling/neo4j-directives.graphql` declares the ones in use (`@node`, `@relationship`, `@relationshipProperties`, `@cypher`). If you use another directive, add a declaration there. 3. **Check for breaking changes.** `npm run schema:check` compares your working copy with `HEAD` (or pass a git ref: `sh scripts/schema-check.sh origin/main`). Removing or changing a field's type is reported as breaking; adding is not. 4. **Regenerate the reference.** `npm run docs:schema` rewrites [schema-reference.md](/docs/grandstack-server/schema-reference/). CI fails if the committed file is stale. 5. **Restart the server.** The schema is read at startup. In Docker you can mount a different file over `/app/schema/schema.graphql` without rebuilding (see [configuration](/docs/grandstack-server/configuration/#using-your-own-schema-and-seed)). ## Authentication and authorization rules in v7 These come from building the `grandstack-auth` extension. Rules only work when the server verifies tokens (the `AUTH_*` variables in [configuration](/docs/grandstack-server/configuration/)); put them in a separate file loaded with `SCHEMA_EXTRA_PATHS` so the default, open schema still starts. - **Claims:** declare the token shape once with a `@jwt` type, then use it in rules: ```graphql type JWT @jwt { roles: [String!]! @jwtClaim(path: "roles") sub: String } ``` - **Require a login or a role** with `@authentication`; `operations` can be `CREATE`, `READ`, `AGGREGATE`, `UPDATE`, `DELETE`, `CREATE_RELATIONSHIP`, `DELETE_RELATIONSHIP`, `SUBSCRIBE`: ```graphql extend type Movie @authentication(operations: [CREATE, UPDATE, DELETE], jwt: { roles: { includes: "editor" } }) ``` Unauthenticated requests get an `Unauthenticated` error and wrong-role requests get `Forbidden`. - **Row-level rules** with `@authorization`. Prefer **`filter`** rules: for anyone not allowed, the row is simply not matched, so an update or delete changes nothing. Compare a relationship to the caller with `$jwt`: ```graphql extend type Review @authorization(filter: [{ operations: [UPDATE, DELETE] where: { OR: [{ node: { author: { sub: { eq: "$jwt.sub" } } } }, { jwt: { roles: { includes: "moderator" } } }] } }]) ``` v7 filters use operators (`sub: { eq: ... }`); the old style (`sub: "$jwt.sub"`) is rejected. - **`validate` rules need the APOC plugin** (they call `apoc.util.validate`) and fail with `There is no procedure with the name apoc.util.validate` when it is missing. This stack's Neo4j has no APOC, so the example uses `filter` rules. Add APOC to Neo4j if you need rules that raise an error. - **`DateTime` fields also need APOC** to be read (`apoc.date.convertFormat`). The example stores timestamps as ISO 8601 strings instead. - **Stop clients rewiring relationships** with `nestedOperations: []` on the field, for example `author: User @relationship(type: "WROTE_REVIEW", direction: IN, nestedOperations: [])`. - **Make the author come from the token** by not exposing generated creation: `type Review @node @mutation(operations: [UPDATE, DELETE])` removes `createReviews`, and a custom `@cypher` mutation creates the review using `$jwt.sub`. In `@cypher` statements the token claims are available as `$jwt`. - **Other rules we hit:** there is no `@unique` in v7 (create constraints with Cypher in `SETUP_CYPHER_PATHS`); declare custom root mutations with `type Mutation { ... }` in an extra file, not `extend type Mutation`; a relationship field that points to one node must be nullable; `@mutation(operations: [])` removes all generated mutations for a type. ## What gets generated for each type For a `@node` type `Movie`, `@neo4j/graphql` adds: - Queries: `movies(where, sort, limit, offset)`, `moviesConnection(where, sort, first, after)` (adds `totalCount` and edge properties). - Mutations: `createMovies(input)`, `updateMovies(where, update)`, `deleteMovies(where)`. - Filter input `MovieWhere` (fields, `AND`/`OR`/`NOT`, relationship filters such as `genres: { some: { name: { in: [...] } } }`), sort input `MovieSort`, create and update inputs with nested relationship operations. Filters for strings use `eq`, `in`, `contains`, `startsWith`, `endsWith`; numbers use `eq`, `in`, `gt`, `gte`, `lt`, `lte`. `contains` is case-sensitive. Explore the exact shapes in GraphiQL at `/graphql` or by introspection. Next: [mutations](/docs/grandstack-server/mutations/), [seeding](/docs/grandstack-server/seeding/). --- # grandstack-server: Mutations URL: https://www.grandstack.ai/docs/grandstack-server/mutations/ `@neo4j/graphql` generates `create`, `update` and `delete` mutations for each node type. The examples below use the Movies schema and have been run against a live server. Send them as `POST /graphql` with `content-type: application/json`, or paste the GraphQL into GraphiQL. ## Create, including relationships ```graphql mutation Create($input: [MovieCreateInput!]!) { createMovies(input: $input) { movies { title } } } ``` ```json { "input": [{ "title": "My Film", "released": 2024, "genres": { "connect": [{ "where": { "node": { "name": { "eq": "Drama" } } } }], "create": [{ "node": { "name": "Brand New Genre" } }] }, "directors": { "connect": [{ "where": { "node": { "name": { "eq": "Nora Ephron" } } } }] }, "actors": { "connect": [{ "where": { "node": { "name": { "eq": "Tom Hanks" } } }, "edge": { "roles": ["Hero"] } }], "create": [{ "node": { "name": "New Person" }, "edge": { "roles": ["Villain", "Boss"] } }] } }] } ``` - `connect` links to something that already exists (matched by `where`). `create` makes a new node and links it. - `edge` holds the relationship properties (here `roles`, from `ActedIn`). - Creating something that already exists (a duplicate title, or a `create` of an existing name) fails with `Constraint validation failed` and nothing is saved. ## Update Scalar fields use set operators, not plain values: ```graphql mutation { updateMovies(where: { title: { eq: "My Film" } }, update: { tagline: { set: "A new tagline" }, rating: { set: 7.5 } }) { movies { title tagline } } } ``` - `{ set: null }` clears an optional field. - Passing a bare value fails validation: inline (`tagline: "x"`) with `Expected value of type "StringScalarMutations", found "x"`, and through variables with `Expected type "StringScalarMutations" to be an object`. - Renaming the key field works the same way: `title: { set: "New title" }`. ### Changing relationships: send a diff Each relationship field on the update input is a **list** of operations. Use `disconnect`, `connect`, `create` and `update`: ```json { "actors": [ { "disconnect": [{ "where": { "node": { "name": { "in": ["Tom Hanks"] } } } }], "connect": [{ "where": { "node": { "name": { "eq": "Brad Pitt" } } }, "edge": { "roles": ["Cameo"] } }], "create": [{ "node": { "name": "ZZ New" }, "edge": { "roles": ["Extra"] } }] }, { "update": { "where": { "node": { "name": { "eq": "Meg Ryan" } } }, "edge": { "roles": { "set": ["Changed"] } } } } ] } ``` - `update` is a single object per list entry (not a list), so changing the roles of several people takes one list entry each. - **Do not "disconnect everything, then connect the new set" in one mutation.** In v7 the disconnect runs *after* the connect, which removes the links you just made (the result is an empty cast). Send only what changed. The web app's `buildUpdateInput` in `grandstack-web/src/forms/model.ts` does this and has unit tests for it. - `disconnect` with `where: {}` removes all links; only do that on its own, not combined with `connect`. ## Delete ```graphql mutation { deleteMovies(where: { title: { eq: "My Film" } }) { nodesDeleted relationshipsDeleted } } ``` Deleting a node also deletes its relationships (it detaches), but the nodes at the other end stay: deleting a movie keeps its people and genres, and deleting a person removes them from the casts of their films. Both return counts of what was removed. ## Stored counts are refreshed after every mutation `Person.actedCount` and `directedCount` are stored properties ([seeding](/docs/grandstack-server/seeding/#derived-counts)). `src/refreshCountsPlugin.ts` is a Yoga plugin that wraps execution: after any operation whose type is `mutation`, it runs the recalculation in `src/counts.ts` and only then returns the response. A client that refetches straight after a mutation therefore sees correct counts. Details: [![Editing a movie: the web app sends a relationship diff, the library applies it in one transaction, the counts plugin recalculates every person's counts, and only then the response is returned, so the refetch shows correct counts.](/diagrams/grandstack-server/edit-and-counts.svg)](/diagrams/grandstack-server/edit-and-counts.svg) *Figure: an edit from the web app, step by step. Click the image for full size; the numbered notes below carry the same information as text.* 1. **One update with only the changes.** A diff: `disconnect` what was removed, `connect` or `create` what was added, one edge update per changed role. 2. **The library applies it.** `@neo4j/graphql` runs the update as one Cypher transaction and hands the result back to the plugin that wrapped it. 3. **Then the counts refresh.** The plugin recalculates `actedCount` and `directedCount` for all people before the response goes out. 4. **The response is built before the refresh.** Counts selected inside a mutation result can be `null` or one step behind; read them with a query afterwards. 5. **Why a diff.** In one mutation v7 runs a disconnect after the connect, so "disconnect all, then connect the new set" removes the new links. 6. **Always and everywhere.** It runs after every mutation, even one that returned errors, and a failed refresh is logged, not returned. - It also runs when the mutation returned errors, because a partly applied mutation may still have changed credits. - If the refresh itself fails, the error is logged and the mutation result is returned unchanged. - It recalculates for **all** people on each mutation. That is fine at this size (a few hundred people); for a large graph, change `REFRESH_COUNTS_CYPHER` in `src/counts.ts` to touch only affected nodes, or set `REFRESH_COUNTS=false` if you have no derived values. - It applies to every client: the web app, `curl`, or an agent. - **The mutation's own response is built before the refresh.** Counts selected inside a mutation result can be `null` (a new person) or one step behind. Read them with a query after the mutation. The web app refetches, so it always shows the refreshed values. ## Trying it ```sh curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { createPeople(input:[{name:\"Test Person\"}]) { people { name } } }"}' # counts are refreshed after the mutation, so read them with a separate query: curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ people(where:{name:{eq:\"Test Person\"}}) { name actedCount directedCount } }"}' curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { deletePeople(where:{name:{eq:\"Test Person\"}}) { nodesDeleted } }"}' ``` --- # grandstack-server: Seeding URL: https://www.grandstack.ai/docs/grandstack-server/seeding/ On startup the server can load sample data from a Cypher file. This page describes the file format, when the server loads it, and how to replace it with your own data. ## The seed file `seed/movies.cypher` (path set by `SEED_PATH`) is a plain Cypher script: - **Statements end with a semicolon at the end of a line.** The server splits the file on `;` followed by a newline and runs each statement in order. A semicolon inside a line (for example inside a string) does not split. - Lines starting with `//` are comments. A chunk that is only comments is skipped. - The first lines should be the constraints, then the data, then any derived values: ```cypher // seed-version: 3 CREATE CONSTRAINT movie_title IF NOT EXISTS FOR (m:Movie) REQUIRE m.title IS UNIQUE; UNWIND [ {title:"The Matrix", released:1999, genres:["Sci-Fi"]} ] AS f MERGE (m:Movie {title: f.title}) SET m.released = f.released FOREACH (g IN f.genres | MERGE (ge:Genre {name: g}) MERGE (m)-[:IN_GENRE]->(ge)); ``` - **Use `MERGE`, not `CREATE`, for data.** Reloading is then safe and idempotent. - When you write maps in Cypher, keys are bare words (`title:`), not quoted strings. - The bundled file was **generated** by a one-off script that is not in the repo: it is a batch of `UNWIND` statements (20 films each), a few `SET` statements for taglines and birth years, and the derived-counts statement. Edit it directly; to change a few records add `MATCH ... SET ...;` lines before the last statement. - Unique constraints (`REQUIRE ... IS UNIQUE`) are what make titles and names usable as keys. They are also why creating a duplicate returns `Constraint validation failed`. ## When the server loads it The decision is made by `decideSeed` in `src/seed.ts` using three facts: how many nodes with the **probe label** exist (`SEED_PROBE_LABEL`, default `Movie`), the version stored in a marker node `(:SeedMeta {name: 'movies'})`, and the `// seed-version: N` header in the file (1 if absent). [![Seed decision: with seeding on, the server checks for a SeedMeta marker and for nodes with the probe label, and loads into an empty database, reloads when the seed file is newer, skips when current, and leaves data it did not load alone.](/diagrams/grandstack-server/seed-decision.svg)](/diagrams/grandstack-server/seed-decision.svg) *Figure: what the server does with the seed file on start. Click the image for full size; the numbered notes below carry the same information as text.* 1. **Seeding on.** Everything below happens only when `SEED` is not `false`. The seed file's `// seed-version: N` header gives its version (1 if missing). 2. **Two facts from Neo4j.** Is there a `(:SeedMeta {name: 'movies'})` marker and with what version, and how many nodes carry the probe label (`SEED_PROBE_LABEL`, default `Movie`). 3. **Reload by bumping the version.** If the marker exists and the file's version is newer, the seed loads again (it uses `MERGE`, so this is safe) and the marker is updated. Same or older: skip. 4. **Foreign data is protected.** No marker but nodes with the probe label exist: they are someone else's data, so nothing is loaded. An empty database loads and gets a marker. | Database state | Result | | --- | --- | | No marker, no nodes with the probe label | **Load**, then write the marker with the file's version. | | Marker exists, file version is newer | **Load again** (safe because of `MERGE`), then update the marker. | | Marker exists, file version is the same or older | Skip ("already loaded"). | | No marker, but nodes with the probe label exist | **Skip**: it is someone else's data, so it is left alone. | Consequences: - **Pointing the server at your own Neo4j** with its own `Movie` data never overwrites it. Set `SEED=false` to be explicit. - **To ship an updated seed, bump `// seed-version`.** Existing databases reload it on the next start. - A database seeded before versioning existed (4 movies, no marker) is treated as foreign and is not upgraded. Reset it with `docker compose down -v`. - Changing the file without bumping the version does nothing on a database that already has the marker. ## Derived counts `Person.actedCount` and `Person.directedCount` are stored on the nodes so people can be sorted and filtered by film count in plain GraphQL (computed `@cypher` fields cannot be sorted). The last statement of the seed sets them: ```cypher MATCH (p:Person) SET p.actedCount = COUNT { (p)-[:ACTED_IN]->() }, p.directedCount = COUNT { (p)-[:DIRECTED]->() }; ``` After startup the server recalculates them after every mutation, so they stay correct when data is edited through the API ([mutations](/docs/grandstack-server/mutations/#stored-counts-are-refreshed-after-every-mutation)). If you change relationships with Cypher directly, run the statement above yourself. ## Using your own data 1. Write your constraints and `MERGE` statements in a Cypher file and set `// seed-version: 1` at the top. 2. Set `SEED_PATH` to the file and `SEED_PROBE_LABEL` to the label of your main entity (for example `Book`), so the foreign-data check looks at the right nodes. 3. If you have no derived counts, set `REFRESH_COUNTS=false` so the plugin does not run the Person count statement. 4. Start with an empty database. The `grandstack` umbrella repo has a worked example of all of this end to end in `docs/build-your-own-app.md`. ## Sample-data caveat Titles, casts and relationships are real, but ratings and runtimes are approximate and a few casts may be incomplete. Treat the data as illustrative. --- # grandstack-server: Recipes URL: https://www.grandstack.ai/docs/grandstack-server/recipes/ Step-by-step changes for the server. Each ends with how to check it worked. Run commands from the repo root of `grandstack-server`. These are written so a developer or a coding agent can follow them literally. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-server`, then `cd grandstack-server` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## Add a field to an existing type Example: add `country: String` to `Movie`. 1. Edit `schema/schema.graphql`: add the field **with a description**. ```graphql """Country of production.""" country: String ``` 2. Run `npm run lint` (fails if you forgot the description) and `npm run schema:check` (adding a field is not breaking). `schema:check` compares with the schema committed at git `HEAD`, so it needs a git checkout and only means something if you committed the baseline before editing; outside git it stops with a message saying so. 3. Run `npm run docs:schema` to refresh the reference, and commit it. (`npm run docs:check` fails until you have run this.) 4. If the field should be in the sample data, bump `// seed-version` and add statements to `seed/movies.cypher`. The bundled file was produced by a one-off script that is not shipped in the repo, so edit the file directly: to set a property on a few existing films add a line like `MATCH (m:Movie {title: "The Matrix"}) SET m.country = "USA";` before the final derived-counts statement (the file already does this for `Person.born`). 5. Restart the server: `docker compose up --build server` in the `grandstack` repo (the schema is copied into the image), or, without a rebuild, mount your schema file as shown in [configuration](/docs/grandstack-server/configuration/#using-your-own-schema-and-seed). A bumped seed only reloads on a database that has the seed marker; with `SEED=false` you must write the data yourself. Check: `curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ movies(limit:1){ title country } }"}'`. 6. The web app needs `npm run codegen` to see the field (see `docs/recipes.md` in the `grandstack-web` repo). ## Add a relationship Example: `Movie` has a `Studio`. 1. Add `type Studio @node { """Studio name.""" name: String! movies: [Movie!]! @relationship(type: "PRODUCED", direction: OUT) }`. 2. On `Movie` add `studio: Studio @relationship(type: "PRODUCED", direction: IN)`. Use `[Studio!]!` for many. 3. Add the unique constraint and the `MERGE` statements to the seed; bump the seed version. 4. Lint, `schema:check`, `docs:schema`, restart. 5. Check with a query for `movies { title studio { name } }`. ## Add a computed field Use `@cypher` ([schema guide](/docs/grandstack-server/schema-guide/#computed-fields-with-cypher)). Remember `columnName`, arguments as `$name`, and that you cannot sort or filter by it. If you need to sort or filter, store the value as a property and refresh it (see how the film counts work in [seeding](/docs/grandstack-server/seeding/#derived-counts)). ## Change the sample data 1. Edit the seed (it is generated data; if you have the generator, change that). 2. Bump `// seed-version: N`. 3. Restart. A database seeded by this server reloads; check the log for `Loaded seed`. ## Add types without touching the base schema Put them in a new file and load it with `SCHEMA_EXTRA_PATHS=/app/schema/extra.graphql` (mount the file into the image). Use `extend type Movie { ... }` to add fields to existing types. For constraints and indexes the new types need, write idempotent Cypher (`CREATE CONSTRAINT ... IF NOT EXISTS`) in a file listed in `SETUP_CYPHER_PATHS`. This is how `grandstack-auth` adds users, reviews and its access rules. ## Add a server plugin Plugins wrap execution. `src/refreshCountsPlugin.ts` is a complete example: it uses `onExecute` with `setExecuteFn` to run code after a mutation. Add yours to the `plugins` array in `src/index.ts`, and write unit tests with fakes like `test/refreshCountsPlugin.test.ts`. ## Turn on an environment option Add the variable to `Config` and `loadConfig` in `src/config.ts` with a default, validate it, add a test in `test/config.test.ts`, use it in `src/index.ts`, then add it to the table in [configuration](/docs/grandstack-server/configuration/) and to `AGENTS.md`. ## Before you commit ```sh npm run lint && npm test && npm run schema:check && npm run docs:check ``` --- # grandstack-server: Configuration URL: https://www.grandstack.ai/docs/grandstack-server/configuration/ The server is configured only through environment variables (read once at startup by `src/config.ts`) and two mounted files: the schema and the seed. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-server`, then `cd grandstack-server` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## Environment variables | Variable | Default | Required | Effect | | --- | --- | --- | --- | | `NEO4J_URI` | none | yes | Bolt URI, for example `bolt://neo4j:7687` or `neo4j+s://xxxx.databases.neo4j.io`. | | `NEO4J_PASSWORD` | none | yes | Password for the Neo4j user. | | `NEO4J_USERNAME` | `neo4j` | no | Neo4j user. | | `NEO4J_DATABASE` | `neo4j` | no | Database used for every request and for seeding. Community Neo4j has only `neo4j`. | | `NEO4J_CYPHER_VERSION_PREFIX` | `false` | no | `true` prefixes generated queries with the Cypher version the library targets; `false` lets the database choose. | | `SCHEMA_PATH` | `schema/schema.graphql` | no | GraphQL schema file. In the image this is relative to `/app`. | | `SEED` | `true` | no | `false` disables seeding entirely. | | `SEED_PATH` | `seed/movies.cypher` | no | Seed Cypher file ([format and rules](/docs/grandstack-server/seeding/)). | | `SEED_PROBE_LABEL` | `Movie` | no | Node label used to detect existing data. Set it to your main entity's label. Letters, digits and underscore only. | | `REFRESH_COUNTS` | `true` | no | `false` turns off the plugin that recalculates `Person` counts after mutations. | | `AUTH_JWKS_URL` | none | no | URL of the identity provider's JWKS (public signing keys). Must be reachable **from the server**. With the next two, turns on JWT verification and `@authentication`/`@authorization`. | | `AUTH_ISSUER` | none | no | Expected token issuer (`iss`), exactly as it appears in tokens. | | `AUTH_AUDIENCE` | none | no | Expected token audience (`aud`). | | `SCHEMA_EXTRA_PATHS` | none | no | Comma-separated extra `.graphql` files appended to the base schema (new types, `extend type`). | | `SETUP_CYPHER_PATHS` | none | no | Comma-separated Cypher files run at **every** start, **after** the seed, for idempotent setup such as `CREATE CONSTRAINT ... IF NOT EXISTS` or filling in properties on the seeded data. | | `FEDERATION` | `false` | no | `true` serves the schema as an [Apollo Federation](https://neo4j.com/docs/graphql/current/integrations/apollo-federation/) subgraph (`getSubgraphSchema()`), adding `_service` and `_entities`. The schema must contain `extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])` (add it through `SCHEMA_EXTRA_PATHS`); without it startup fails with `typeDefs must contain \`@link\` schema extension to be used with Apollo Federation`. See `grandstack-federation`. | | `PORT` | `4000` | no | HTTP port. | | `CORS_ORIGIN` | `http://localhost:3000` | no | Allowed browser origin(s): one origin, a comma-separated list, or `*`. With `*`, credentials are not allowed. | The three `AUTH_*` variables must be set together; setting only some stops startup and names the missing ones. Without them the server does no authentication (the default). A missing required variable, a bad `PORT`, a bad `SEED_PROBE_LABEL` or a non-http(s) `AUTH_JWKS_URL` also stops startup with a message that names the problem. Booleans accept `true` or `false` in any letter case. ## Files | File | Used for | | --- | --- | | `schema/schema.graphql` | The API. Read at startup. | | `seed/movies.cypher` | Sample data, loaded by the rules in [seeding](/docs/grandstack-server/seeding/). | ## Using your own schema and seed Without rebuilding the image, mount your files and point the variables at them: ```sh docker run --rm -p 4000:4000 \ -e NEO4J_URI=bolt://host.docker.internal:7687 -e NEO4J_PASSWORD=secret \ -e SEED_PROBE_LABEL=Book -e REFRESH_COUNTS=false \ -v "$PWD/my-schema.graphql:/app/schema/schema.graphql:ro" \ -v "$PWD/my-seed.cypher:/app/seed/movies.cypher:ro" \ grandstack-server:0.6.0 ``` (Under Podman on macOS add `:z` to the mount options if SELinux relabeling is needed.) ## Running locally without Docker ```sh npm ci NEO4J_URI=bolt://localhost:7687 NEO4J_PASSWORD=secret npm run dev # tsx watch, restarts on change npm run lint # GraphQL ESLint on the schema, then tsc --noEmit npm test # vitest unit tests npm run schema:check # breaking changes against git HEAD npm run docs:schema # regenerate docs/schema-reference.md npm run docs:check # fail if the reference is stale or a doc link is broken ``` ## The image `Dockerfile` is multi-stage: build stage installs dependencies and compiles TypeScript; the final stage is `node:24-slim`, copies `node_modules`, `dist`, `schema` and `seed`, and runs as the non-root `node` user on port 4000. Tag images with the same version as the git tag ([releases](#releases)). ## Releases Versions are semver git tags (`v0.6.0`) with a matching image tag. The umbrella repo pins the versions it was tested with. Cut a release by running lint, tests and `npm run docs:check`, tagging the commit, and building the image with the same tag. ## CORS The browser calls the server directly, so the web app's origin must be allowed. If the browser console shows a CORS error, set `CORS_ORIGIN` to the exact origin of the web app (scheme, host and port), for example `http://localhost:3000`. ## Authentication and extra schema The server itself stays auth-agnostic. With the `AUTH_*` variables set it verifies the `Authorization: Bearer` token on every request (signature from the JWKS, expiry, issuer, audience) and makes the decoded claims available to `@authentication` and `@authorization` rules in the schema. Without them those directives do not work, which is why rules are added through `SCHEMA_EXTRA_PATHS` only when auth is on. The `grandstack-auth` repository is the worked example: it supplies a gateway, a Keycloak dev realm, and `schema/auth.graphql` (users, reviews, rules), which the umbrella's `compose.auth.yaml` mounts and loads through these settings. See that repo's docs, and [the schema guide](/docs/grandstack-server/schema-guide/#authentication-and-authorization-rules-in-v7) for the syntax and the pitfalls we found. --- # grandstack-server: Schema reference URL: https://www.grandstack.ai/docs/grandstack-server/schema-reference/ Every type and field below comes from `schema/schema.graphql`. `@neo4j/graphql` also generates queries, mutations, filters and sort inputs for each `@node` type (for example `movies`, `moviesConnection`, `createMovies`, `updateMovies`, `deleteMovies`); those are not listed here. See [mutations](/docs/grandstack-server/mutations/) and the live schema at `/graphql` for the generated parts. ## Movie *node type (stored in Neo4j).* A film in the Movies graph. | Field | Type | Description | | --- | --- | --- | | `title` | `String!` | Film title. Unique within the dataset. | | `tagline` | `String` | Tagline shown on the poster. | | `released` | `Int` | Year the film was released. | | `rating` | `Float` | Audience rating from 0 to 10. Sample data, approximate. | | `runtime` | `Int` | Running time in minutes. | | `genres` | `[Genre!]!` | Genres the film belongs to. *(relationship `IN_GENRE` (OUT))* | | `actors` | `[Person!]!` | People who acted in the film. *(relationship `ACTED_IN` (IN), properties `ActedIn`)* | | `directors` | `[Person!]!` | People who directed the film. *(relationship `DIRECTED` (IN))* | | `similar(limit: Int = 8)` | `[Movie!]!` | Films most like this one, best match first. Scored by shared director (3 points), shared cast member (2) and shared genre (1). Argument `limit`: Maximum number of films to return. *(computed by a `@cypher` statement (see below))* | ## Person *node type (stored in Neo4j).* A person who acted in or directed films. | Field | Type | Description | | --- | --- | --- | | `name` | `String!` | Full name. Unique within the dataset. | | `born` | `Int` | Year the person was born. Only known for some people. | | `actedCount` | `Int` | Number of films the person acted in. Stored on the node and recalculated by the server after every mutation. | | `directedCount` | `Int` | Number of films the person directed. Stored on the node and recalculated by the server after every mutation. | | `actedIn` | `[Movie!]!` | Films the person acted in. *(relationship `ACTED_IN` (OUT), properties `ActedIn`)* | | `directed` | `[Movie!]!` | Films the person directed. *(relationship `DIRECTED` (OUT))* | | `collaborators(limit: Int = 8)` | `[Collaboration!]!` | People this person has worked with most, ranked by number of shared films (acting or directing). Argument `limit`: Maximum number of collaborators to return. *(computed by a `@cypher` statement (see below))* | ## Collaboration *plain object type.* A person someone has worked with, and how many films they share. | Field | Type | Description | | --- | --- | --- | | `name` | `String!` | Name of the collaborator. Look the person up by this name. | | `films` | `Int!` | Number of films the two people worked on together. | ## Genre *node type (stored in Neo4j).* A film genre. | Field | Type | Description | | --- | --- | --- | | `name` | `String!` | Genre name, for example Drama. | | `movies` | `[Movie!]!` | Films in this genre. *(relationship `IN_GENRE` (IN))* | ## ActedIn *relationship properties.* Properties on the ACTED_IN relationship. | Field | Type | Description | | --- | --- | --- | | `roles` | `[String!]!` | Character names played in the film. | ## Computed fields (`@cypher`) These fields run a Cypher statement on request, so they always reflect the current graph. ### `Movie.similar` ```cypher MATCH (this)-[r1:IN_GENRE|ACTED_IN|DIRECTED]-(shared)-[r2:IN_GENRE|ACTED_IN|DIRECTED]-(other:Movie) WHERE other <> this AND type(r1) = type(r2) WITH other, sum(CASE type(r1) WHEN 'DIRECTED' THEN 3 WHEN 'ACTED_IN' THEN 2 ELSE 1 END) AS score ORDER BY score DESC, other.rating DESC, other.title RETURN other LIMIT $limit ``` ### `Person.collaborators` ```cypher MATCH (this)-[:ACTED_IN|DIRECTED]->(m:Movie)<-[:ACTED_IN|DIRECTED]-(other:Person) WHERE other <> this WITH other, count(DISTINCT m) AS films ORDER BY films DESC, other.name LIMIT $limit RETURN {name: other.name, films: films} AS result ``` --- # grandstack-server: Troubleshooting URL: https://www.grandstack.ai/docs/grandstack-server/troubleshooting/ Errors we actually hit while building this, with causes and fixes. ## Startup | Symptom | Cause and fix | | --- | --- | | `Missing required environment variables: NEO4J_URI, NEO4J_PASSWORD` | Set both. In compose they come from the `server` service environment. | | Process exits right after `verifyConnectivity` with a connection or auth error | Neo4j is not up yet, the URI is wrong, or the password does not match `NEO4J_AUTH`. In compose the server waits for the Neo4j healthcheck; outside compose start Neo4j first. | | `Directive "relationship" must be in a type with "@node"` | v7 requires `@node` on every type that uses `@relationship`. Add `@node` ([schema guide](/docs/grandstack-server/schema-guide/#node-types)). | | `Invalid input '"title"': expected an expression` while seeding | Cypher map keys must be bare words (`{title: "x"}`), not quoted (`{"title": "x"}`). JSON dumped into Cypher needs its keys unquoted. | | `npm install` complains about a `graphql` peer dependency | Keep `graphql` on 16.x. `@neo4j/graphql` 7.6.4 does not support graphql 17. | | Seed says "Database already has Movie data not loaded by this seed, skipping" | The database has `Movie` nodes but no `SeedMeta` marker, so it is treated as someone else's data. Use an empty database, or `docker compose down -v`. See [seeding](/docs/grandstack-server/seeding/). | | Changed the seed but nothing reloaded | Bump `// seed-version: N` at the top of the file. | ## Queries and mutations | Symptom | Cause and fix | | --- | --- | | Search by text finds nothing | `contains` is case-sensitive. Match as typed and in Title Case, or store a lowercase copy to search. | | `Expected value of type "StringScalarMutations", found "x"` (inline) or `Variable "$u" got invalid value "x" at "u.tagline"; Expected type "StringScalarMutations" to be an object` (variables) | Updates need set operators: `tagline: { set: "x" }`. | | `Constraint validation failed` | You tried to create a duplicate of a unique key (title or name), including via a nested `create`. Use `connect` for things that exist. | | After updating a relationship list the relationships are gone | You disconnected and connected in the same mutation; the disconnect runs last in v7. Send a diff ([mutations](/docs/grandstack-server/mutations/#changing-relationships-send-a-diff)). | | `Unexpected error.` when reading a field from a custom `@cypher` type | A plain object returned from `@cypher` cannot contain a node. Return scalars (see `Collaboration`) ([schema guide](/docs/grandstack-server/schema-guide/#computed-fields-with-cypher)). | | Film counts look wrong, or are `null` inside a mutation result | The refresh runs after the mutation's response is built, so read counts with a query afterwards. Counts are stored and refreshed after mutations. If you changed relationships with Cypher directly, rerun the statement in [seeding](/docs/grandstack-server/seeding/#derived-counts). If the plugin is off (`REFRESH_COUNTS=false`) nothing refreshes them. | ## Auth | Symptom | Cause and fix | | --- | --- | | Startup: `AUTH_JWKS_URL, AUTH_ISSUER and AUTH_AUDIENCE must be set together; missing: ...` | Set all three or none. | | Every request with a token fails with `Unauthenticated` although the token is fresh | The token's `iss` or `aud` does not match `AUTH_ISSUER` or `AUTH_AUDIENCE` exactly (scheme, host, port, trailing slash), or the server cannot reach `AUTH_JWKS_URL` (use the address the server can reach, for example `http://keycloak:8080/...`, which can differ from the issuer URL). Decode the token (jwt.io, or `cut -d. -f2 | base64 -d`) and compare. | | `There is no procedure with the name apoc.util.validate` | An `@authorization` `validate` rule needs APOC. Use a `filter` rule or add APOC ([schema guide](/docs/grandstack-server/schema-guide/#authentication-and-authorization-rules-in-v7)). | | `Unknown function 'apoc.date.convertFormat'` | Reading a `DateTime` field needs APOC. Store ISO strings, or add APOC. | | Startup crash `Cannot read properties of undefined (reading 'kind')` | Usually `extend type Mutation` in an extra schema file. Use `type Mutation { ... }`. | | `Unknown directive "@unique"` | Not in v7. Create a constraint in a `SETUP_CYPHER_PATHS` file. | | `Invalid argument: validate, error: Expected type to be an object` | The rule's `where` uses the old filter style. Use operators: `sub: { eq: "$jwt.sub" }`. | | `Non-list relationship property "x" cannot have non-nullable type` | Single-node relationship fields must be nullable (`author: User`, not `User!`). | ## Federation | Symptom | Cause and fix | | --- | --- | | `typeDefs must contain \`@link\` schema extension to be used with Apollo Federation` | `FEDERATION=true` but no `extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])` in the schema. Add it through `SCHEMA_EXTRA_PATHS` | | `doc.definitions is not iterable` at startup with `FEDERATION=true` | `@apollo/subgraph` 2.15.1 is incompatible with `@neo4j/graphql` 7.6.4. This repo pins 2.14.4 with `overrides` in `package.json`; keep that when you update dependencies | ## From a browser | Symptom | Cause and fix | | --- | --- | | CORS error in the browser console | Set `CORS_ORIGIN` to the exact web origin, for example `http://localhost:3000`. | | `Failed to fetch` | The web app's `VITE_GRAPHQL_URL` is wrong, or the server is not running. The URL is fixed at the web app's build time. | ## Containers (Docker or Podman) | Symptom | Cause and fix | | --- | --- | | `docker` commands say they cannot connect | On macOS `docker` may be Podman. Run `podman machine start`, and set `DOCKER_HOST` to the socket path it prints. | | `The client is unauthorized due to authentication failure` although the password is right | You may be reaching a different Neo4j than you think (for example one already running on your machine on port 7687). Check with `lsof -nP -iTCP:7687 -sTCP:LISTEN`. Repeated failures can also lock the account for a short time (`too many times in a row`); wait a few seconds. | | Neo4j keeps restarting or the healthcheck never passes | Give it time on first start (the compose healthcheck allows about 2.5 minutes), and check `docker compose logs neo4j`. | | Data is back to the original after restart | You ran `docker compose down -v`, which deletes the volume. Use `down` without `-v` to keep data. | --- # grandstack-server: AGENTS.md: guide for coding agents URL: https://www.grandstack.ai/docs/grandstack-server/agents-md/ GraphQL API over Neo4j using `@neo4j/graphql` v7 and GraphQL Yoga. Configured by environment variables and a schema file. Read [docs/how-it-works.md](/docs/grandstack-server/how-it-works/) first, then [docs/recipes.md](/docs/grandstack-server/recipes/) for the change you are making. ## Commands - Run the whole stack: `cd ../grandstack && docker compose up --build` (GraphQL at http://localhost:4000/graphql). - Local dev against a running Neo4j: `NEO4J_URI=bolt://localhost:7687 NEO4J_PASSWORD=... npm run dev`. - Before you finish any change: `npm run lint && npm test && npm run schema:check && npm run docs:check`. `schema:check` needs a git checkout with the schema committed at `HEAD`; `docs:check` fails until you run `npm run docs:schema` after a schema change. ## Rules that are easy to get wrong - Every type, field and argument in `schema/schema.graphql` needs a `"""description"""` (lint enforces it). - Types need `@node` (v7). New Neo4j directives need a stub in `tooling/neo4j-directives.graphql`. - Scalar updates are `{ set: value }`. Relationship updates must be a diff, never "disconnect all then connect" ([docs/mutations.md](/docs/grandstack-server/mutations/)). - `contains` filters are case-sensitive. Keep `graphql` on 16.x. Pin exact dependency versions. - Seed changes need a bumped `// seed-version`. Seed maps use bare keys. See [docs/seeding.md](/docs/grandstack-server/seeding/). - `Person.actedCount` and `directedCount` are refreshed by a plugin after every mutation; do not maintain them by hand. - Auth is off unless the three `AUTH_*` variables are set (they must be set together). Rules (`@authentication`/`@authorization`) belong in an extra schema file loaded with `SCHEMA_EXTRA_PATHS`; v7 pitfalls (APOC for `validate` and `DateTime`, no `@unique`, `type Mutation` not `extend`) are in [docs/schema-guide.md](/docs/grandstack-server/schema-guide/#authentication-and-authorization-rules-in-v7). Do not expose an open server publicly. - New code files need the copyright header (two lines: `Copyright (c) "Neo4j"` and `Neo4j Sweden AB [http://neo4j.com]`, in the file's comment syntax). Run `node ../grandstack/scripts/add-headers.mjs` (or `node ../.github/scripts/headers.mjs --root .` for one repository) to add it; `--check` lists what is missing. Generated files are exempt. ## Keep the docs true When you change behaviour, update the matching doc in the same change: environment variables in `docs/configuration.md` and the table in `src/config.ts` tests, schema changes via `npm run docs:schema`, and new gotchas in `docs/troubleshooting.md`. `npm run docs:check` fails on a stale schema reference or a broken relative link. Diagrams are SVG files in `docs/diagrams/`: edit the SVG and its numbered legend together when behaviour changes; `npm run docs:check` fails on a missing alt text, unsafe SVG content, or legend numbers that do not match the callouts drawn. Versioning: semver git tags with a matching image tag. - Releases use the **release-cut** workflow (Actions tab), which sets the version in every `package.json`, tags and publishes. Do not bump versions or push tags by hand; see `grandstack/docs/releasing.md`. --- # grandstack-web: grandstack-web: overview URL: https://www.grandstack.ai/docs/grandstack-web/ A movie and people search app for grandstack-server (GraphQL over Neo4j), built to show what a GraphQL-on-Neo4j front end can look like, and to be adapted to your own data. React, Vite and urql, with every operation typed from the schema by GraphQL Code Generator. - Faceted filters with live counts (genre, decade, rating, runtime, cast or director), kept in the URL so results are shareable - Grid or list results, sorting, and side-by-side compare of up to 4 movies - A people browser, and person pages with stats, filmography, frequent collaborators and a career graph - Movie pages with an interactive graph and graph-powered recommendations - Create, edit and delete movies and people, including genres, directors and cast with roles (people can be created inline) - Dark and light themes, responsive down to phone width, posters and avatars generated in the browser (no image files) > **No authentication yet.** Anyone who can reach the app can create, edit and delete data. ## Run it The web app needs the server and Neo4j, so the easiest way is the `grandstack` umbrella repo: `docker compose up --build`, then open http://localhost:3000. For development you need Node 22 or later and the server already running on port 4000 (for example from the umbrella stack). Clone this repository, go into the folder, then: ```sh git clone https://github.com/grand-stack/grandstack-web cd grandstack-web npm ci npm run dev # http://localhost:3000 ``` Set `VITE_GRAPHQL_URL` to use a different server (it must be reachable from the browser). ## Documentation | Read | To learn | | --- | --- | | [Architecture](/docs/grandstack-web/architecture/) | Stack, folder map, routes, how URL state and data flow, build and deploy | | [Features](/docs/grandstack-web/features/) | How each feature works and where the code is | | [Customising](/docs/grandstack-web/customising/) | Theme, posters, what is generic, the Needle licence note | | [Recipes](/docs/grandstack-web/recipes/) | Step-by-step common changes | | [Testing](/docs/grandstack-web/testing/) | Type checks, codegen, unit tests, browser checks | | [e2e/README.md](https://github.com/grand-stack/grandstack-web/blob/main/e2e/README.md) | Running the browser end-to-end checks | `npm ci` prints `npm warn install-scripts` notices; they are harmless. Working with a coding agent? Start with [AGENTS.md](/docs/grandstack-web/agents-md/). The movie data is sample data with approximate ratings. Last verified: v0.5.1 against grandstack-server v0.7.1. ## Licence Apache License 2.0. See [LICENSE](https://github.com/grand-stack/grandstack-web/blob/main/LICENSE) and [NOTICE](https://github.com/grand-stack/grandstack-web/blob/main/NOTICE). The same licence applies to every GRANDstack repository and to [grandstack.ai](https://grandstack.ai). --- # grandstack-web: Architecture of grandstack-web URL: https://www.grandstack.ai/docs/grandstack-web/architecture/ grandstack-web is a single-page React app that talks to grandstack-server over GraphQL. It is a movie and people search, filter, compare and edit app, written to show what a GraphQL-on-Neo4j front end looks like and to be adapted to your own data. Applies to grandstack-web v0.5.1 (React 19, Vite 8, urql 5, GraphQL Code Generator, TypeScript). ## Stack | Concern | Choice | Notes | | --- | --- | --- | | UI | React 19 + react-router 8 | Client-side routes; nginx falls back to `index.html`. | | GraphQL client | urql 5 with `cacheExchange` and `fetchExchange` | Default request policy is `cache-and-network`. | | Types | GraphQL Code Generator, client preset | Operations written with the `graphql()` tag are typed from the live schema. | | Animation | `motion` | Respects `prefers-reduced-motion`. | | Graph view | `react-force-graph-2d` (MIT) | Neo4j's own NVL has a custom licence, so it is not used. | | Styling | Plain CSS with design tokens | `src/theme/tokens.css`, styled after Neo4j's Needle design system (see [customising](/docs/grandstack-web/customising/#about-needle)). | | Posters, avatars | Generated in the browser | No image files. | ## Folder map ``` src/ main.tsx urql client, router, motion config App.tsx routes, header, Add menu, compare tray, theme toggle pages/ one component per route (Home, Results, MovieDetail, MovieNew, MovieEdit, People, PersonDetail, PersonNew, PersonEdit, Compare) search/ FilterSidebar, MovieCard, PersonCard forms/ MovieForm, PersonForm, PersonPicker, DeleteButtons, and model.ts (pure logic) graph/ GraphView (shared), MovieGraph, PersonGraph lib/ filters.ts, peopleFilters.ts (URL state and GraphQL where/sort), compare.tsx, hash.ts and initials.ts (small helpers used by posters and avatars) poster/Poster.tsx generated poster art ui/ Button, Chip, Checkbox, RatingBadge, Skeleton, Avatar, ConfirmDialog, PublicEditBanner theme/tokens.css colours, spacing, radii, light and dark styles.css all component styles graphql/generated/ committed output of codegen (do not edit) e2e/ browser end-to-end checks (see e2e/README.md) codegen.ts where the schema comes from and which files hold operations nginx.conf, Dockerfile production image ``` ## Routes | Path | Page | Purpose | | --- | --- | --- | | `/` | Home | Hero search, genre chips, top rated, newest, most prolific actors and directors | | `/movies` | Results | Faceted search with filters in the URL | | `/movies/new` | MovieNew | Create a movie | | `/movies/:title` | MovieDetail | Facts, cast, graph, recommendations | | `/movies/:title/edit` | MovieEdit | Edit or delete a movie | | `/people` | People | Browse people by role | | `/people/new` | PersonNew | Create a person | | `/people/:name` | PersonDetail | Stats, filmography, collaborators, graph | | `/people/:name/edit` | PersonEdit | Edit or delete a person | | `/compare` | Compare | Up to four movies side by side | | `/new` | redirect | Goes to `/movies/new` | Titles and names are the keys, so they appear URL-encoded in paths (`/people/Timoth%C3%A9e%20Chalamet`). ## Data flow State lives in the **URL**, not in component state, for anything a user might want to share or reload: [![In grandstack-web the URL query string is parsed into a Filters object, turned into typed GraphQL variables, sent with urql, rendered, and any filter change is written back to the URL.](/diagrams/grandstack-web/url-state-data-flow.svg)](/diagrams/grandstack-web/url-state-data-flow.svg) *Figure: one loop from the URL to the screen and back. Click the image for full size; the numbered notes below carry the same information as text.* 1. **The URL is the source of truth.** Filters live in the query string (`g`, `d`, `r`, `rt`, `p`, `s`, `q`), so results are shareable, reloadable and work with the back button. 2. **Pure functions in the middle.** `parseFilters`, `buildWhere` and `buildSort` turn the query string into the `where` and `sort` variables; they are unit-tested. `buildWhere` can leave one facet out, which is how facet counts work. 3. **Typed documents.** Every operation is written with `graphql()` and typed by `npm run codegen` against the live schema, so a wrong field fails at codegen or `tsc`. 4. **Components only render and emit events.** A changed filter calls `serializeFilters` and `setSearchParams`, which closes the loop. Text inputs keep local state and debounce the URL update, otherwise fast typing drops characters. 5. **Almost nothing else is stored.** Only the compare list and the theme are kept in `localStorage`. - `src/lib/filters.ts` owns the movie filters (`q`, `g` genres, `d` decades, `r` minimum rating, `rt` maximum runtime, `p` person, `s` sort). `src/lib/peopleFilters.ts` owns the people filters (`q`, `role`, `mf` minimum films, `s`). Both are pure functions with unit tests. - Text inputs that write to the URL keep **local state and debounce** the URL update (see `People.tsx`). Binding an input straight to the URL drops characters when typing fast, because the URL update is asynchronous. - The compare selection (`localStorage` key `grandstack.compare`, at most four titles) and the theme (`grandstack.theme`) are the only browser-stored state. - Every GraphQL document is written next to the component that uses it with the `graphql()` tag from `src/graphql/generated`. Codegen validates it against the live schema and generates the result types, so a typo in a field name fails at codegen time and a wrong use of a result field fails at `tsc`. ## Caching and freshness The urql client uses `requestPolicy: "cache-and-network"`: pages show cached data immediately and always refetch, so lists and counts are fresh after an edit without wiring cache invalidation by hand. Edit forms use `network-only` so they never start from stale data. The server refreshes stored film counts after every mutation, so a refetch after a save is correct. ## Build and deploy `npm run build` type-checks and builds static files with Vite. The `Dockerfile` builds them and serves them with unprivileged nginx on port 8080, with long-lived caching for `/assets/` and a fallback to `index.html` for client routes. `VITE_GRAPHQL_URL` is **baked in at build time** (a build argument in the Dockerfile, default `http://localhost:4000/graphql`). It must be a URL the *browser* can reach, not a container-internal hostname. The server's `CORS_ORIGIN` must allow the web origin. Next: [features](/docs/grandstack-web/features/), [customising](/docs/grandstack-web/customising/), [recipes](/docs/grandstack-web/recipes/), [testing](/docs/grandstack-web/testing/). --- # grandstack-web: How the features work URL: https://www.grandstack.ai/docs/grandstack-web/features/ Each section says what the user sees, where the code is, and the one non-obvious thing. Facts here were checked against the running app. ## Faceted search (`/movies`) Code: `src/pages/Results.tsx`, `src/search/FilterSidebar.tsx`, `src/lib/filters.ts`. - Filters: title text, genres (several), decades (several), minimum rating, maximum runtime, and a person (cast or director). Sort: rating, newest, oldest, title, shortest. [![One Results operation carries three where inputs: where for the movies and total, genreWhere (filters minus genres) for genre counts, and decadeWhere (filters minus decades) for decade counts.](/diagrams/grandstack-web/search-facets-sequence.svg)](/diagrams/grandstack-web/search-facets-sequence.svg) *Figure: how one request returns the movies, the total and every filter option's count. Click the image for full size; the numbered notes below carry the same information as text.* 1. **`where`.** All active filters: used for the results grid and the total in the heading. 2. **`genreWhere`.** The filters without the genre filter, used to count each genre option. 3. **`decadeWhere`.** The filters without the decade filter, used to count each decade (nine aliased fields in the same operation). 4. **Why "as if the filter were off".** Ticking Drama still shows how many films each other genre would give; options are not hidden by their own selection, and an option with 0 stays disabled. 5. **`$decadeWhere` is non-null.** It is used inside a list literal, so it is declared `MovieWhere!` and the app always sends `{}` rather than `null`. - **Facet counts respect the other filters.** Results is one GraphQL operation with several fields: - `movies(where: $where, ...)` and `moviesConnection(where: $where) { totalCount }` for the results and the total; - `genres { name moviesConnection(where: { node: $genreWhere }) { totalCount } }`, where `$genreWhere` is the current filters **without** the genre filter, so selecting Drama still shows how many films each other genre would give; - nine aliased `moviesConnection` fields, one per decade (`d1940` ... `d2020`), using `$decadeWhere` (the filters without the decade filter) combined with that decade's year range. - Options with a count of zero are disabled unless already selected. - Title search is case-sensitive in the API (`contains`), so `buildWhere` matches the text as typed, in Title Case and in lower case. - 24 results per page; "Show more" raises the `limit`. - Gotcha for new facets: `$decadeWhere` is declared `MovieWhere!` (non-null) because it is used inside a list literal; always send `{}` rather than `null`. ## People browser (`/people`) Code: `src/pages/People.tsx`, `src/lib/peopleFilters.ts`, `src/search/PersonCard.tsx`. - Role chips (Everyone, Actors, Directors) with counts, a name search, a minimum-films select and a sort (most films, name). - Sorting and role filtering use the stored `actedCount` and `directedCount` properties on `Person`, because computed fields cannot be sorted in plain GraphQL. The server recalculates them after every mutation (see `docs/mutations.md` in the `grandstack-server` repo). - Role counts use the same trick as facets: an operation field per role with the filters minus the role. ## Person page (`/people/:name`) Code: `src/pages/PersonDetail.tsx`, `src/graph/PersonGraph.tsx`. - Stats (films, active years, average rating, top genres) are computed **in the browser** from the person's filmography, since a person has at most a few dozen films. - Filmography is split into "Acted in" and "Directed" with a sort control. - "Frequent collaborators" comes from the server's `Person.collaborators` field (a Cypher traversal ranked by shared films). - The career graph shows the person, up to 12 acted-in films, up to 12 directed films and up to 6 collaborators. ## Movie page and recommendations (`/movies/:title`) Code: `src/pages/MovieDetail.tsx`, `src/graph/MovieGraph.tsx`. - "You might also like" is the server's `Movie.similar` field: films scored by shared director (3), shared cast (2) and shared genre (1). - The graph shows the movie with its directors, up to 10 cast members and its genres. Clicking a person opens their page; clicking a genre opens filtered results. ## Graph views Code: `src/graph/GraphView.tsx` (shared), `MovieGraph.tsx` and `PersonGraph.tsx` (build nodes and links). `GraphView` wraps `react-force-graph-2d` (lazy-loaded) and draws nodes on a canvas. To add a graph for another page, build a list of `{ id, label, kind }` nodes and `{ source, target, label }` links and render `` with a legend and a click handler. Edge labels are the Neo4j relationship types. ## Compare (`/compare`) Code: `src/lib/compare.tsx`, `src/pages/Compare.tsx`, the tray in `src/App.tsx`. - Tick "Compare" on up to four movies; the selection survives reloads (`localStorage`). - The page highlights the best rating and shortest runtime and shows cast shared by more than one selected movie in bold. - The tray is hidden on the compare screen and on create/edit forms, because a fixed bar at the bottom would cover the form buttons. ## Create, edit and delete Code: `src/forms/*`, `src/pages/MovieNew.tsx`, `MovieEdit.tsx`, `PersonNew.tsx`, `PersonEdit.tsx`. - `MovieForm` edits fields, genres (existing or new), directors and cast with roles. `PersonPicker` is a typeahead over existing people with a "Create new person" option; people are connected if they exist and created if not. - `src/forms/model.ts` holds the logic and has unit tests: - `validateMovie`, `validatePerson` (title and name required, year 1888 to 2100, rating 0 to 10, runtime 1 or more, no duplicate cast member); - `buildCreateInput` makes the `createMovies` input (connect existing, create new, roles on edges); - `buildUpdateInput(before, after, existingGenres)` makes the `updateMovies` input as a **diff** (disconnect what was removed, connect or create what was added, one edge update per changed role). It never sends "disconnect all, then connect", because the server runs disconnects last and would remove the new links; - `friendlyError` turns the unique-constraint error into a readable message. - Roles are typed as one text box; several roles for one person are separated by semicolons. - Editing a field clears its error until the next submit; submit focuses the first invalid field. - Deleting asks for confirmation in a native `` (focus trapped, Esc cancels). Every form shows a banner that there is no authentication. ## Posters and avatars Code: `src/poster/Poster.tsx`, `src/ui/Avatar.tsx`. Posters are generated, not downloaded. `hash(title)` (FNV-1a) picks gradient angle and hues; the first genre sets the base hue (`GENRE_HUE`). The same title always gives the same poster. Avatars use the same hash on the person's name and show initials. Change the look by editing `posterStyle` and `GENRE_HUE`. ## Theme Code: `src/theme/tokens.css`, `useTheme` in `src/App.tsx`. Dark is the default; the header toggle switches to light and remembers the choice. Colours are CSS custom properties on `:root` and `:root[data-theme="light"]`. --- # grandstack-web: Customising the web app URL: https://www.grandstack.ai/docs/grandstack-web/customising/ What to change to make this look and behave like your own app, and what depends on the Movies model. ## What is generic and what is Movies-specific | Part | Generic? | Notes | | --- | --- | --- | | `src/ui/*`, `src/poster/Poster.tsx`, `src/theme/tokens.css`, `src/styles.css` | Generic | Buttons, chips, dialog, avatar, generated posters, tokens. They depend only on `src/lib/hash.ts` and `src/lib/initials.ts`. | | `src/graph/GraphView.tsx` | Generic | Takes nodes and links. | | `src/forms/PersonPicker.tsx` | Mostly generic | A typeahead over one entity type; copy it for another. | | `src/forms/model.ts` | Pattern is generic | `buildCreateInput` and `buildUpdateInput` show how to turn form state into nested mutation inputs and diffs; the fields are Movie-specific. | | `src/lib/filters.ts`, `peopleFilters.ts` | Pattern is generic | URL-synced filters and `where`/`sort` builders; the filter set is Movie-specific. | | `src/pages/*`, `src/search/*` | Movie-specific | Rename and rework per entity. | | `src/graphql/generated/*` | Generated | Re-run `npm run codegen`. | ## Change the look - **Colours, spacing, radii, shadows:** edit the custom properties in `src/theme/tokens.css`. `:root` is the dark theme and `:root[data-theme="light"]` the light one; keep both in step. Text and background pairs need enough contrast (4.5:1 for normal text). - **Fonts:** Public Sans is loaded from `@fontsource-variable/public-sans` in `src/main.tsx`; swap the package and the `--font` token. - **Layout and components:** all styles are in `src/styles.css`, grouped by area (header, inputs, cards, results, detail, forms, dialog). - **Brand name:** the header text is in `src/App.tsx` (`Header`), the page title in `index.html`. ## Change the posters and avatars Posters are generated from the title and first genre in `src/poster/Poster.tsx`. Edit `GENRE_HUE` for the genre colours and `posterStyle` for the gradients. To use real images instead, add an image URL field to the schema and render an `` in `Poster` when it exists, falling back to the generated style. ## About Needle The look is styled after Neo4j's Needle design system (Public Sans, blue and teal palette, dark and light themes), reimplemented in our own components. The colour values are **approximations that have not been checked against Needle's published tokens**; compare them with neo4j.design before presenting this as Needle. The app does **not** use the `@neo4j-ndl/*` packages: they are GPL-3.0 and this repo is Apache-2.0, so adding them would make the combined work GPL-3.0. Do not add them without changing this repo's licence. ## Point it at a different server Set `VITE_GRAPHQL_URL` at **build time** (Docker build argument, or an env var for `vite`). It must be reachable from the browser. For local development: `VITE_GRAPHQL_URL=http://localhost:4000/graphql npm run dev`. The server's `CORS_ORIGIN` must include the web origin (the dev server uses `http://localhost:3000`). ## Adapt it to another dataset The step-by-step version, with a worked example, is in the `grandstack` umbrella repo (`docs/build-your-own-app.md`). In short: change the server schema and seed, run `npm run codegen` here, and the compiler lists every place that used a field you removed. Then rename pages and components, rewrite the filters to your fields, and update the forms. --- # grandstack-web: Recipes URL: https://www.grandstack.ai/docs/grandstack-web/recipes/ Step-by-step changes to the web app. Run commands from the repo root of `grandstack-web` with the stack running (`docker compose up` in the `grandstack` repo) so codegen can read the live schema. Each recipe ends with how to check it. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-web`, then `cd grandstack-web` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## Show a new field from the schema Example: show `country` on the movie page after adding it to the server schema. 1. Server first: add the field to `schema/schema.graphql` and restart (server recipe "Add a field"). 2. In `src/pages/MovieDetail.tsx`, add `country` to the fields in the `graphql()` query. 3. Run `npm run codegen`. It fails if the field does not exist on the server. 4. Use it in the JSX. On `MovieDetail.tsx` the facts are rows of a definition list, so follow that: add `{movie.country && (<>
Country
{movie.country}
)}` inside the `
`. 5. Check: `npm run typecheck`, then open a movie page (for example `/movies/Inception` on the standard stack at http://localhost:3000, or your own web port) and look at it. Add a unit test if you added logic. To make the field editable, also follow "Add a field to the forms" below. If your server runs somewhere other than http://localhost:4000, run codegen with `GRAPHQL_URL=http://host:port/graphql npm run codegen`. ## Add a filter facet Example: filter movies by minimum year. 1. In `src/lib/filters.ts`: add the field to `Filters`, read it in `parseFilters`, write it in `serializeFilters`, count it in `activeCount`, and add its clause in `buildWhere` (use the `omit` parameter if the facet needs counts that ignore itself). 2. In `src/search/FilterSidebar.tsx`: add the control and call `onChange({ minYear: ... })`. 3. In `src/pages/Results.tsx`: show a chip for it with a remove handler, and include it in "Clear all" (add it to `emptyFilters` with an explicit `undefined`, or spreading will not clear it). 4. Add cases to `src/lib/filters.test.ts` (URL round trip, where clause, clearing). 5. Check: `npm test`, then open `/movies?...` with the new parameter and confirm the count and chips; reload to confirm it persists. ## Add a field to the forms 1. Add it to `MovieFormState` and `emptyMovieForm` in `src/forms/model.ts`; validate it in `validateMovie`. 2. Include it in `buildCreateInput` and in `buildUpdateInput` (a changed scalar is `{ set: value }`, blank is `{ set: null }`). 3. Add the input in `src/forms/MovieForm.tsx` using the `field(...)` and `fieldError(...)` helpers. 4. Load it into the edit form in `src/pages/MovieEdit.tsx` (query it and map it to the form state). 5. Add cases to `src/forms/model.test.ts`. Check: `npm test` and a manual create and edit. ## Add a page 1. Create `src/pages/Thing.tsx` with its query written using `graphql()`. 2. Add a `` in `src/App.tsx` (and a nav link if needed). 3. Run `npm run codegen` and `npm run typecheck`. 4. Operation names must be **unique across the whole app** (codegen fails on duplicates), so name queries after what they do (`PeopleBrowse`, not `People`, if `People` exists). ## Add another entity type (for example Studio) 1. Server: add the type and relationships (see `docs/schema-guide.md` in the `grandstack-server` repo). 2. Copy the People pattern: `peopleFilters.ts` -> `studioFilters.ts`, `People.tsx` -> `Studios.tsx`, `PersonCard.tsx` -> `StudioCard.tsx`, and the detail and form pages. Keep the URL-state and diff-update patterns. 3. Add routes, run codegen, add tests next to each pure module. ## Before you commit ```sh npm run typecheck && npm test && npm run docs:check # with the stack running and freshly seeded: npm run e2e ``` --- # grandstack-web: Testing URL: https://www.grandstack.ai/docs/grandstack-web/testing/ Four layers, from fastest to most realistic. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-web`, then `cd grandstack-web` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## 1. Type checking `npm run typecheck` (also run by `npm run build`) runs `tsc --noEmit`. Because every GraphQL document is typed from the schema, using a field that is not in the query fails here. ## 2. Codegen as a schema check `npm run codegen` reads the live schema from `GRAPHQL_URL` (default `http://localhost:4000/graphql`) and validates every `graphql()` document against it. An unknown field, a wrong argument type or a duplicate operation name fails the run and writes nothing. Run it after any server schema change, then commit `src/graphql/generated/`. ## 3. Unit tests `npm test` (vitest) covers the pure logic: URL parsing and `where`/`sort` building for movies and people (`src/lib/*.test.ts`), form validation and mutation inputs including the relationship diff (`src/forms/model.test.ts`), and the poster generator. They need no server or browser. ## 4. Browser end-to-end checks `npm run e2e` drives real Chrome against the running stack and checks 78 behaviours, including create, edit and delete with the stored film counts. Setup, variables and caveats are in [e2e/README.md](https://github.com/grand-stack/grandstack-web/blob/main/e2e/README.md). In short: run it against a freshly seeded database, because it expects the sample data. ## Continuous integration `.github/workflows/ci.yml` runs install, typecheck, unit tests and a build, then builds the Docker image. Codegen and the browser checks need a running server and are not in CI yet. ## When a check fails - `codegen` fails with `Cannot query field "x" on type "Y"`: the server schema does not have the field; check the server is running the version you expect. - A query works in GraphiQL but codegen reports `Variable "$x" of type "T" used in position expecting type "T!"`: the variable must be declared non-null where it is used in a non-null position. - e2e fails on counts: the database is not the freshly seeded sample. --- # grandstack-web: AGENTS.md: guide for coding agents URL: https://www.grandstack.ai/docs/grandstack-web/agents-md/ React + Vite client for grandstack-server: faceted movie and people search, compare, graph views, and create/edit/delete. urql with typed operations from GraphQL Code Generator. Read [docs/architecture.md](/docs/grandstack-web/architecture/) first, then [docs/recipes.md](/docs/grandstack-web/recipes/) for the change you are making. ## Commands - Whole stack: `cd ../grandstack && docker compose up --build` (web http://localhost:3000, GraphQL http://localhost:4000/graphql). - Dev server: `npm ci && npm run dev` (server must be running; `VITE_GRAPHQL_URL` changes the endpoint). - After changing any GraphQL document or the server schema: `npm run codegen` (needs the server running), commit `src/graphql/generated/`. - Before you finish: `npm run typecheck && npm test && npm run docs:check`. With a freshly seeded stack also `npm run e2e`. ## Rules that are easy to get wrong - Write operations with `graphql()` from `src/graphql/generated`; never edit that folder by hand. Operation names must be unique across the app. - Filters live in the URL ([docs/architecture.md](/docs/grandstack-web/architecture/#data-flow)). Text inputs that write to the URL need local state plus a debounce. - Clearing filters: add new fields to `emptyFilters` with an explicit `undefined`, or `{ ...filters, ...emptyFilters }` will not clear them. - Relationship edits are diffs (`buildUpdateInput` in `src/forms/model.ts`), never "disconnect all, then connect". Scalar updates are `{ set: value }`. - Title search is case-sensitive in the API; `buildWhere` matches several casings. - Do **not** add `@neo4j-ndl/*` (GPL-3.0; this repo is Apache-2.0). The Needle-style tokens are approximations. See [docs/customising.md](/docs/grandstack-web/customising/#about-needle). - `VITE_GRAPHQL_URL` is fixed at build time and must be reachable from the browser. The compare tray is hidden on form pages on purpose. - No authentication: edit screens show a banner and deletes ask for confirmation. Do not expose the app publicly. - Keep `graphql` on 16.x to match the server. Pin exact dependency versions. - New code files need the copyright header (two lines: `Copyright (c) "Neo4j"` and `Neo4j Sweden AB [http://neo4j.com]`, in the file's comment syntax). Run `node ../grandstack/scripts/add-headers.mjs` (or `node ../.github/scripts/headers.mjs --root .` for one repository) to add it; `--check` lists what is missing. Generated files are exempt. ## Keep the docs true When you change behaviour, update the matching doc in the same change (routes and folder map in `docs/architecture.md`, feature behaviour in `docs/features.md`, new gotchas here or in `docs/testing.md`). `npm run docs:check` fails on a broken relative link. Diagrams are SVG files in `docs/diagrams/`: edit the SVG and its numbered legend together when behaviour changes; `npm run docs:check` fails on a missing alt text, unsafe SVG content, or legend numbers that do not match the callouts drawn. - Releases use the **release-cut** workflow (Actions tab), which sets the version in every `package.json`, tags and publishes. Do not bump versions or push tags by hand; see `grandstack/docs/releasing.md`. --- # grandstack-auth: grandstack-auth: overview URL: https://www.grandstack.ai/docs/grandstack-auth/ Authentication and authorization for GRANDstack: a **gateway** that validates Keycloak tokens and enforces permissions per mutation, **row-level rules** for the GraphQL server, a Keycloak **development realm** with demo users, and a small **demo client** with a permissions playground. It is opt-in, and it leaves `grandstack-web` untouched. > **Development only.** Keycloak runs in dev mode with known passwords over plain HTTP. See [what to change for production](/docs/grandstack-auth/keycloak/#not-for-production). ## What you get - Reads are public; writes need a login. Roles: `user` (write reviews), `editor` (change movies, people, genres), `moderator` (also delete any review). - Two independent checks: the gateway validates every token and applies permissions; the server verifies the token again and applies row-level rules. Calling the server directly cannot bypass auth. - A review's author always comes from the token and cannot be spoofed; only the author or a moderator can change a review. - Works with any OpenID Connect provider by configuration ([swapping providers](/docs/grandstack-auth/swapping-providers/), with an [untested Auth0 guide](/docs/grandstack-auth/auth0/)). ## Run it This repo is used through the `grandstack` umbrella repo. Clone `grandstack`, `grandstack-server`, `grandstack-web` and this repo side by side into one folder, then start the stack from the `grandstack` folder: ```sh mkdir grand-stack && cd grand-stack # any folder name for repo in grandstack grandstack-server grandstack-web grandstack-auth; do git clone https://github.com/grand-stack/$repo; done cd grandstack cp .env.example .env # once docker compose -f compose.yaml -f compose.auth.yaml up --build ``` The plain `docker compose up` (no `-f` override) still starts the open stack with no login. | Open | What | | --- | --- | | http://localhost:3001 | The demo client: log in, write reviews, try the permissions playground | | http://localhost:4000/graphql | The gateway: the public GraphQL endpoint (the server is not published) | | http://localhost:8081 | Keycloak (admin console: `admin` / `admin`) | | http://localhost:3000 | The unchanged `grandstack-web`. It has no login, so through the gateway it can read but every write is refused | Demo users (password is the name plus `-pass`): | User | Password | Role | Can | | --- | --- | --- | --- | | `alice` | `alice-pass` | user | write and edit own reviews | | `bob` | `bob-pass` | editor | the above, plus create, edit and delete movies, people and genres | | `carol` | `carol-pass` | moderator | the above, plus delete anyone's review | ## Documentation | Read | To learn | | --- | --- | | [How it works](/docs/grandstack-auth/how-it-works/) | The pieces, the two checks, what is allowed, responses, why two addresses | | [Role and permission model](/docs/grandstack-auth/role-and-scope-model/) | Roles, permissions, the mutation table, the server rules | | [Keycloak realm](/docs/grandstack-auth/keycloak/) | What the dev realm contains, changing it, not for production | | [The demo client](/docs/grandstack-auth/client/) | Login flow, playground, configuration, files | | [Recipes](/docs/grandstack-auth/recipes/) | Add a role, protect a mutation, add a user | | [Testing](/docs/grandstack-auth/testing/) | Unit tests, the smoke script, browser checks | | [Swapping providers](/docs/grandstack-auth/swapping-providers/), [Auth0](/docs/grandstack-auth/auth0/) | Using another OpenID Connect provider (Auth0 untested) | | [Troubleshooting](/docs/grandstack-auth/troubleshooting/) | Errors we hit and their fixes | | [**Exercise:** add auth to grandstack-web](/docs/grandstack-auth/exercise-add-auth-to-grandstack-web/) | A follow-up task for a developer or coding agent | Working with a coding agent? Start with [AGENTS.md](/docs/grandstack-auth/agents-md/). `npm ci` prints `npm warn install-scripts` notices; they are harmless. Last verified: grandstack-auth v0.1.1 with grandstack-server v0.7.1, Keycloak 26.8.1, Hive Gateway 2.15.2, Neo4j 2026.10 community, under Podman 6 on macOS. ## Licence Apache License 2.0. See [LICENSE](https://github.com/grand-stack/grandstack-auth/blob/main/LICENSE) and [NOTICE](https://github.com/grand-stack/grandstack-auth/blob/main/NOTICE). The same licence applies to every GRANDstack repository and to [grandstack.ai](https://grandstack.ai). --- # grandstack-auth: How authentication and authorization work URL: https://www.grandstack.ai/docs/grandstack-auth/how-it-works/ This repository adds login and access rules to GRANDstack without changing `grandstack-web`. It is opt-in: the default stack stays open, and `compose.auth.yaml` in the `grandstack` repository turns auth on. Applies to grandstack-auth v0.1.1 with grandstack-server v0.7.1, Keycloak 26.8, Hive Gateway 2.15. ## The pieces [![With auth on, the browser logs in at Keycloak, sends tokens to the gateway which validates them and checks permissions, the gateway forwards the header to the server which verifies again and applies row rules, and the server is reachable only through the gateway.](/diagrams/grandstack-auth/auth-architecture.svg)](/diagrams/grandstack-auth/auth-architecture.svg) *Figure: the auth setup. Every write passes two independent checks, and the server is only reachable through the gateway. Click the image for full size; the numbered notes below carry the same information as text.* 1. **Two addresses.** The token's issuer (`iss`) is the browser-facing `http://localhost:8081/realms/grandstack` because Keycloak runs with `KC_HOSTNAME`; the gateway and server fetch public keys from `http://keycloak:8080/...`, which works inside the compose network. Mixing them up is the most common setup mistake. 2. **Gateway: the coarse check.** Validates signature, expiry, issuer and audience, then checks the permission each mutation needs (`gateway/rules.ts`), refusing anything unknown by default. 3. **Server: the fine check.** Verifies the token again with the same keys, then applies `@authentication` (roles) and `@authorization` (row filters) from `schema/auth.graphql`. 4. **Not published.** The server has no host port in the auth setup. Even a direct call without a valid token is refused. 5. **The unchanged web app.** `grandstack-web` has no login, so through the gateway it is an anonymous client: reads work, writes are refused. 6. **Refusals at the gateway.** 401 for no or bad token on a write (including a token signed with an unknown key id), 403 for a missing permission, and unknown mutations are refused. | Piece | Where | Job | | --- | --- | --- | | Keycloak | `keycloak/grandstack-realm.json`, image `quay.io/keycloak/keycloak` | Logs users in and signs access tokens. Development realm with demo users. | | Gateway | `gateway/` (`gateway.config.ts`, `rules.ts`) | The public GraphQL endpoint. Validates tokens, enforces coarse permissions per mutation. | | Server rules | `schema/auth.graphql` | `User` and `Review` types and the row-level `@authentication`/`@authorization` rules. Loaded into `grandstack-server`. | | Demo client | `client/` | A small app that logs in, writes reviews and lets you try protected actions. | | Compose override | `compose.auth.yaml` in the `grandstack` repo | Starts Keycloak, the gateway and the client, and stops publishing the server. | ## Two checks on purpose The **gateway** is the front door: it rejects bad tokens and applies permissions by operation. The **server** verifies the token again and applies rules by row. The server is not published to the host in the auth setup, but even if someone reached it directly it would still refuse unauthenticated writes and still apply the row rules (the smoke script proves this by calling it from inside the network). ## What is allowed Reads are public. Writes need a token. | | Anonymous | `user` (alice) | `editor` (bob) | `moderator` (carol) | | --- | --- | --- | --- | --- | | Read movies, people, reviews | yes | yes | yes | yes | | Write a review (`createReview`) | no (401) | yes | yes | yes | | Edit or delete **your own** review | no | yes | yes | yes | | Edit or delete **someone else's** review | no | nothing changes | nothing changes | yes | | Create, edit, delete movies, people, genres | no (401) | no (403) | yes | yes | "Nothing changes" is deliberate: row-level rules are `filter` rules, so a review that is not yours is simply not matched and the mutation affects zero rows, with no error. Roles are cumulative in Keycloak (moderator includes editor includes user). More detail in [the role and permission model](/docs/grandstack-auth/role-and-scope-model/). ## A request, step by step [![A login with PKCE, then bob (editor) creates a movie and it succeeds, alice (user) makes the same call and the gateway answers 403 without calling the server, and alice editing a review she did not write returns an empty result because the row filter matches nothing.](/diagrams/grandstack-auth/login-and-protected-write.svg)](/diagrams/grandstack-auth/login-and-protected-write.svg) *Figure: login, an allowed write, a write refused at the gateway, and a write filtered out at the server. Click the image for full size; the numbered notes below carry the same information as text.* 1. **Login once.** Authorization code with PKCE at Keycloak; the access token carries `sub`, the name and the `roles`. 2. **Allowed.** Bob's roles imply `catalog:write`, so the gateway forwards the same `Authorization` header; the server verifies it again and requires the `editor` role. 3. **Refused at the gateway.** Alice has no `catalog:write`: 403, and the server is never called. 4. **Filtered at the server.** `updateReviews` is allowed (alice has `reviews:write`), but the review's author is not alice and she is not a moderator, so the row filter matches nothing: 200 with an empty list and no change. ## What the responses look like | Situation | Where it is decided | Response | | --- | --- | --- | | No token, mutation | gateway | HTTP 401, `Unauthenticated: log in to change data` | | Token without the permission | gateway | HTTP 403, `Forbidden: "createMovies" needs the "catalog:write" permission` | | Bad signature, wrong issuer or audience, expired, unknown key id | gateway | HTTP 401 `Unauthenticated` | | Not the review's author | server | HTTP 200, nothing changed (`nodesDeleted: 0` or no reviews returned) | | A mutation the gateway does not know about | gateway | HTTP 403, refused by default | ## A token, and why the addresses differ Keycloak puts the user's id (`sub`), name, and a `roles` claim (realm roles) in the access token, and the audience `grandstack-api`. The **issuer** in the token is the browser-facing address, `http://localhost:8081/realms/grandstack`, because Keycloak is started with `KC_HOSTNAME=http://localhost:8081`; that keeps `iss` identical whether a token was requested from your browser or from another container. The **gateway and server fetch the public keys** from `http://keycloak:8080/realms/grandstack/protocol/openid-connect/certs`, the address that works inside the compose network. Those two addresses are different on purpose, and mixing them up is the most common setup mistake (see [troubleshooting](/docs/grandstack-auth/troubleshooting/)). ## Using the API with a token ```sh TOKEN=$(curl -s -X POST localhost:8081/realms/grandstack/protocol/openid-connect/token \ -d grant_type=password -d client_id=grandstack-smoke -d username=alice -d password=alice-pass -d scope=openid \ | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])') curl -s localhost:4000/graphql -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"query":"mutation { createReview(movie:\"Inception\", rating:9, text:\"Great\") { id author { name } } }"}' ``` (The `grandstack-smoke` client allows the password grant for tests. Real apps use the authorization code flow, as the demo client does.) ## The unchanged web app `grandstack-web` is not modified by this project. In the auth setup it still runs on port 3000 and still calls `http://localhost:4000/graphql`, which is now the gateway, so it behaves as an **anonymous** client: reading works, and any create, edit or delete is refused and shown as an error. That is expected. Adding login to it is a follow-up exercise: [exercise-add-auth-to-grandstack-web.md](/docs/grandstack-auth/exercise-add-auth-to-grandstack-web/). ## Not for production Keycloak runs in development mode with known passwords, plain HTTP, no rate limiting and an in-container database. Tokens in the demo client live in `sessionStorage`. See [Keycloak](/docs/grandstack-auth/keycloak/#not-for-production) for what to change before a real deployment. --- # grandstack-auth: Role and permission model URL: https://www.grandstack.ai/docs/grandstack-auth/role-and-scope-model/ ## Roles, permissions and where each is checked | Concept | Defined in | Checked by | Example | | --- | --- | --- | --- | | **Role** | Keycloak realm role, sent as the `roles` claim | the server, in `schema/auth.graphql` rules | `editor`, `moderator` | | **Permission** | derived from roles in the gateway (`DEFAULT_ROLE_PERMISSIONS` in `gateway/rules.ts`) | the gateway, per mutation field | `catalog:write`, `reviews:write` | The gateway answers "may this token call this mutation at all?" using permissions. The server answers "may this token change this row?" using roles and the token's `sub`. ### Why permissions are derived from roles, not read from the OAuth `scope` In OAuth, a *scope* is something a **client application** is allowed to request, so Keycloak grants scopes per client, not per user: every user logging in through the same client gets the same scopes. What differs between alice and bob is their **realm roles**. So the gateway maps roles to permissions. It also accepts permissions the token already carries, in a `permissions` array (Auth0 puts RBAC permissions there) or in the space-separated `scope` claim, so other providers work without changes. `permissionsFromClaims` (in `gateway/rules.ts`) is the whole rule: a token's permissions are the union of the permissions of its roles, its `permissions` array and its `scope` string. ## The default map | Role | Permissions at the gateway | Server-level extras | | --- | --- | --- | | `user` | `reviews:write` | may edit or delete their own reviews | | `editor` (includes `user`) | `reviews:write`, `catalog:write` | `@authentication` on movies, people and genres requires the `editor` role | | `moderator` (includes `editor`) | same as editor | may edit or delete anyone's review | Keycloak makes the roles composite, so a moderator's token carries `["editor", "moderator", "user"]`. Override the map without changing code: set the gateway's `ROLE_PERMISSIONS` environment variable to JSON, for example `{"user":["reviews:write"],"critic":["reviews:write"]}`. It must be an object of role to array of permission strings; anything else stops the gateway at startup. ## Which permission each mutation needs Defined by `MUTATION_PERMISSIONS` in `gateway/rules.ts`: | Mutation fields | Permission | | --- | --- | | `createMovies`, `updateMovies`, `deleteMovies`, `createPeople`, `updatePeople`, `deletePeople`, `createGenres`, `updateGenres`, `deleteGenres` | `catalog:write` | | `createReview`, `updateReviews`, `deleteReviews` | `reviews:write` | | anything else | **refused** (default deny) | Queries are never checked at the gateway: reading is public. If a request contains several mutation fields, all of them must be allowed. If you add a mutation to the schema, add it to this table or the gateway will refuse it. ## Row-level rules on the server All in `schema/auth.graphql`: - `Movie`, `Person`, `Genre`: `@authentication` on create, update, delete and relationship changes with `jwt: { roles: { includes: "editor" } }`. - `Review`: `@authentication` on update and delete, and an `@authorization` **filter**: only reviews whose `author.sub` equals the token's `sub`, or any review if the token has the `moderator` role, can be updated or deleted. - `Review.author` and `Review.movie` (and `Movie.reviews`) have `nestedOperations: []`, so nobody can re-point a review at another author or movie through an update. - `createReview` is a custom mutation that takes the author from `$jwt.sub`; the generated `createReviews` is removed with `@mutation(operations: [UPDATE, DELETE])` on `Review`. So a client cannot pass an author, and cannot create a review as someone else. - `User` has no generated mutations; users are created by `createReview` the first time someone reviews. ## Adding a role See [recipes](/docs/grandstack-auth/recipes/#add-a-role-that-can-delete-any-review). Note that the `Review` rule in `schema/auth.graphql` is a single filter shared by update and delete; the recipe shows how to split it when a role should be able to do only one of them. --- # grandstack-auth: The Keycloak development realm URL: https://www.grandstack.ai/docs/grandstack-auth/keycloak/ `keycloak/grandstack-realm.json` is imported when Keycloak starts. It defines everything the stack needs, so there is nothing to click in the admin console. > **Where to run this.** Docker Compose commands run inside a clone of the `grandstack` repository, with the repositories it builds cloned beside it: `git clone https://github.com/grand-stack/grandstack`, and the others the same way. The [quickstart](https://grandstack.ai/quickstart/) has the exact steps. ## What is in it | Item | Value | | --- | --- | | Realm | `grandstack` | | Roles | `user`, `editor` (includes user), `moderator` (includes editor). New users get `user`. | | Demo users | `alice` (user), `bob` (editor), `carol` (moderator). Password is the username plus `-pass`, for example `alice-pass`. | | Client `grandstack-auth-client` | Public client for browser apps. Authorization code flow with PKCE (S256). Redirect URIs `http://localhost:3001/*` and `http://localhost:3000/*`, web origins the same. This is the client the demo client uses. | | Client `grandstack-smoke` | Allows the password grant, for the smoke script and `curl` examples. Development only. | | Client `grandstack-smoke-expiring` | Same, with a 5 second access token lifetime, to test expiry. | | Token claims | `sub`, `name`, `preferred_username`, `email`, `roles` (realm roles), and audience `grandstack-api`. | | Token lifetime | 5 minutes (access token); SSO session idle 30 minutes. | Keycloak admin console: http://localhost:8081, user `admin`, password `admin`. ## How it is started In `compose.auth.yaml` (the `grandstack` repo): image `quay.io/keycloak/keycloak:26.8.1`, command `start-dev --import-realm`, the realm file mounted into `/opt/keycloak/data/import/`, and: - `KC_HOSTNAME=http://localhost:8081` fixes the **issuer** of every token to the browser-facing address, whichever address the token was requested from. - `KC_HTTP_ENABLED=true` because dev mode uses plain HTTP. - `KC_HEALTH_ENABLED=true` and a healthcheck on the management port (9000), so other services wait until Keycloak is ready. It is up in about 20 seconds. ## Changing the realm Keycloak imports the file only when the realm **does not exist yet** (strategy "ignore existing"). Edits to the JSON are ignored by a Keycloak that has already imported it. Recreate the container so it starts from scratch: ```sh docker compose -f compose.yaml -f compose.auth.yaml rm -sf keycloak docker compose -f compose.yaml -f compose.auth.yaml up -d keycloak ``` On a **first** start nothing needs recreating: the file is imported automatically. The development database lives inside the container, so anything you create in the admin console is lost when the container is recreated; put lasting changes in the JSON file. Gotchas that bit us while building it: a client needs the `basic` client scope or its tokens have no `sub`; custom claims need protocol mappers on the client (the realm roles mapper produces the top-level `roles` claim; the audience mapper adds `grandstack-api`). ## Not for production This setup is for local development. Before a real deployment you would at least: - run Keycloak in production mode behind HTTPS with a real database, and a real hostname instead of `localhost`; - change the admin password and remove the demo users and the `grandstack-smoke*` clients (they allow the password grant); - turn on brute-force protection, set sensible token lifetimes, and review CORS and redirect URIs; - not use `sessionStorage` for tokens if your threat model includes XSS (consider a backend-for-frontend that keeps tokens server-side); - add rate limiting at the gateway, and keep secrets out of the repository. None of this is done here. --- # grandstack-auth: The demo client URL: https://www.grandstack.ai/docs/grandstack-auth/client/ `client/` is a small React app (Vite, urql, `oidc-client-ts`) that shows the auth setup working. It is **separate from `grandstack-web`**, which stays an open, unauthenticated app. It runs on http://localhost:3001 in the auth stack. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-auth`, then `cd grandstack-auth` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## What it does - **Log in and out** through the identity provider (authorization code flow with PKCE). The header shows your name and role. - **Movies and reviews:** browse movies (public); logged-in users write reviews, edit and delete their own; moderators can delete any review. - **Permissions playground** (`/playground`): buttons that try protected actions as whoever you are logged in as (create a movie, delete a movie, write a review, edit someone else's review, look for a way to spoof the author). Each card says what the rules predict, runs the request, shows the HTTP status and response, and tells you whether the result matched. Log in as alice, bob and carol to compare. - Anonymous visitors can read everything and use the playground, where write attempts are refused. ## Configuration (build time) | Variable | Default in the compose file | Purpose | | --- | --- | --- | | `VITE_GRAPHQL_URL` | `http://localhost:4000/graphql` | The gateway, as the browser reaches it | | `VITE_OIDC_AUTHORITY` | `http://localhost:8081/realms/grandstack` | OIDC issuer URL (discovery is at `/.well-known/openid-configuration`) | | `VITE_OIDC_CLIENT_ID` | `grandstack-auth-client` | The registered public client | | `VITE_OIDC_SCOPE` | `openid profile` | Scopes to request | | `VITE_OIDC_AUDIENCE` | none | Extra `audience` parameter some providers need (Auth0) | These are baked into the static files, so changing them means rebuilding the image. `src/auth/config.ts` throws a clear error at startup if the authority or client id is missing. ## How login works 1. **Log in** calls `signinRedirect`, remembering the current path. The browser goes to the provider with a PKCE challenge. 2. After login the provider redirects to `/callback?code=...`. `pages/Callback.tsx` calls `signinRedirectCallback`, which exchanges the code for tokens and returns the remembered path. (The code can be used once, so reloading `/callback` fails; go back to `/` and log in again.) 3. The access token is kept by the library in **`sessionStorage`**, so it lasts for the tab and is readable by scripts on the page (a known trade-off for single-page apps; see [Keycloak, not for production](/docs/grandstack-auth/keycloak/#not-for-production)). 4. `lib/urqlClient.ts` adds `Authorization: Bearer ` to every request. `automaticSilentRenew` is on; when the session ends, the app drops to the signed-out state. 5. **Log out** clears the token and redirects through the provider's logout back to `/`. The client only *decodes* the token (`src/auth/claims.ts`) to show the name and decide which buttons to show. That is for convenience; the gateway and server verify the token and make the real decisions. `ROLE_PERMISSIONS` in `claims.ts` mirrors the gateway's default map; keep them in step, or rely on a `permissions` claim. ## Files ``` src/auth/config.ts reads the OIDC settings src/auth/claims.ts decodes a token, derives permissions (+ tests) src/auth/AuthProvider.tsx oidc-client-ts UserManager, login, logout, callback src/lib/urqlClient.ts urql client with the bearer token src/lib/errors.ts readable messages for 401/403, review validation (+ tests) src/pages/ Movies, MovieDetail (reviews), Playground, Callback src/ui, src/poster, src/theme, src/styles.css, src/lib/hash.ts, initials.ts copied from grandstack-web src/graphql/generated/ typed operations from codegen ``` The files in the last two groups marked "copied" are **copies** of `grandstack-web` files, not shared packages, so they can drift; the app is small enough that this is acceptable. ## Run and develop With the auth stack running (`docker compose -f compose.yaml -f compose.auth.yaml up --build` in the `grandstack` repo): open http://localhost:3001. To work on the client against the running stack: ```sh cd client npm ci npm run codegen # types from the gateway's schema; needs the stack running npm run dev # http://localhost:3001, with the same defaults npm run typecheck && npm test ``` Use the realm's demo users (`alice`, `bob`, `carol`; password is the name plus `-pass`). ## Adding auth to the other app `grandstack-web` has none of this on purpose. [exercise-add-auth-to-grandstack-web.md](/docs/grandstack-auth/exercise-add-auth-to-grandstack-web/) walks through adding it, using this client as the reference. --- # grandstack-auth: Using a different OpenID Connect provider URL: https://www.grandstack.ai/docs/grandstack-auth/swapping-providers/ Nothing in the gateway, server or demo client is specific to Keycloak: they use standard OpenID Connect and JWTs. To use another provider (Auth0, Okta, Entra ID, ...), change configuration, not code. Checklist: | What | Setting | Notes | | --- | --- | --- | | Issuer | `AUTH_ISSUER` (gateway **and** server) | Exactly the token's `iss`, including any trailing slash (Auth0 issuers end with `/`). | | Audience | `AUTH_AUDIENCE` (gateway and server) | The API identifier the token is issued for. The client must request it (`VITE_OIDC_AUDIENCE` for Auth0). | | Public keys | `AUTH_JWKS_URL` (gateway and server) | The provider's JWKS URL, reachable from the containers. | | Where roles are | `@jwtClaim(path: "roles")` in `schema/auth.graphql`, and `roles` in `gateway/rules.ts` | Providers name or namespace the claim differently (Auth0 requires a namespaced custom claim added by an Action). Change the claim path in the schema and, if needed, read your claim in `permissionsFromClaims`. | | Permissions | the gateway reads `permissions` and `scope` claims too | If your provider emits `catalog:write` and `reviews:write` as permissions, you do not need roles at the gateway. | | Client | `VITE_OIDC_AUTHORITY`, `VITE_OIDC_CLIENT_ID`, `VITE_OIDC_SCOPE`, `VITE_OIDC_AUDIENCE` | Register the app as a single-page app (public client, authorization code with PKCE). Allowed callback URLs: `/callback`; allowed logout URL and web origin: the origin. | | CORS | `CORS_ORIGIN` on the gateway | List every browser origin that calls it. | Then run `npm run smoke` (token-level checks) against your setup. The smoke script gets its tokens from Keycloak's password grant, so for another provider replace the `login` function in `scripts/auth-smoke.mjs` with how you get test tokens (a test user and a client that allows it), and keep the assertions. The Auth0 version of this checklist, written but **not tested**, is in [auth0.md](/docs/grandstack-auth/auth0/). --- # grandstack-auth: Auth0 (written, not tested) URL: https://www.grandstack.ai/docs/grandstack-auth/auth0/ > **Untested.** This guide follows from how Auth0 works and from the provider-agnostic settings in this repository, but nobody has run it against a real Auth0 tenant yet. Treat it as a starting point and expect to adjust. If you try it, please fix this page with what you learn. You need an Auth0 developer tenant. Everything below is configuration; no code changes. ## 1. Auth0 setup 1. **API.** Create an API (Applications > APIs). Its *Identifier* is your audience, for example `grandstack-api`. Set *Allow Offline Access* if you want refresh tokens. 2. **Permissions and RBAC.** In the API, enable *RBAC* and *Add Permissions in the Access Token*. Add the permissions `catalog:write` and `reviews:write`. The gateway reads a `permissions` claim natively, so no role mapping is needed there. 3. **Roles.** Create roles `user`, `editor` and `moderator` (User Management > Roles) and attach permissions: `user` gets `reviews:write`; `editor` gets both; `moderator` gets both. Assign roles to test users. 4. **Application.** Create a *Single Page Application*. Allowed Callback URLs: `http://localhost:3001/callback`. Allowed Logout URLs and Allowed Web Origins: `http://localhost:3001`. Note the Domain and Client ID. 5. **A `roles` claim for the server's row rules.** Auth0 does not put roles in access tokens by default. Add a post-login Action that adds a namespaced claim, for example: ```js exports.onExecutePostLogin = async (event, api) => { api.accessToken.setCustomClaim("https://grandstack.example/roles", event.authorization?.roles ?? []); }; ``` Then point the schema at it: in `schema/auth.graphql` change `@jwtClaim(path: "roles")` to `@jwtClaim(path: "https://grandstack\\.example/roles")` (dots in a claim name are escaped), and make `permissionsFromClaims` in `gateway/rules.ts` read the same claim if you also want role-based permissions. ## 2. Stack settings Replace the Keycloak values in `compose.auth.yaml` (in the `grandstack` repo): | Setting | Auth0 value | | --- | --- | | `AUTH_JWKS_URL` (server and gateway) | `https://YOUR_TENANT.auth0.com/.well-known/jwks.json` | | `AUTH_ISSUER` (server and gateway) | `https://YOUR_TENANT.auth0.com/` (note the trailing slash) | | `AUTH_AUDIENCE` (server and gateway) | the API identifier from step 1 | | `VITE_OIDC_AUTHORITY` (client build arg) | `https://YOUR_TENANT.auth0.com` | | `VITE_OIDC_CLIENT_ID` (client build arg) | the Client ID from step 4 | | `VITE_OIDC_SCOPE` | `openid profile email` | | `VITE_OIDC_AUDIENCE` | the API identifier | The Keycloak service is not needed; remove it and the `depends_on` entries that name it. `GATEWAY CORS_ORIGIN` stays `http://localhost:3001` (and 3000 if you add login to `grandstack-web`). ## 3. What to check - Log in through the Auth0 Universal Login page and come back to the demo client. - The playground shows the permissions from your token; try a user with and without `catalog:write`. - Anything refused for the wrong reason: decode the token and compare `iss`, `aud` and the claims with the values above ([troubleshooting](/docs/grandstack-auth/troubleshooting/#tokens-are-rejected-though-they-look-fine)). - The smoke script uses Keycloak's password grant, so it does not apply as is; see [swapping providers](/docs/grandstack-auth/swapping-providers/). --- # grandstack-auth: Recipes URL: https://www.grandstack.ai/docs/grandstack-auth/recipes/ Step-by-step changes. Commands run from the root of `grandstack-auth` with the auth stack up (`docker compose -f compose.yaml -f compose.auth.yaml up --build` in the `grandstack` repo) unless stated. Each recipe ends with how to check it. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-auth`, then `cd grandstack-auth` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## Add a role that can delete any review Example: a `critic` role, like a moderator but only for reviews. 1. **Keycloak.** In `keycloak/grandstack-realm.json` add a realm role `critic` that includes `user`, and a user for it: ```json { "name": "critic", "description": "Can delete any review", "composite": true, "composites": { "realm": ["user"] } } ``` Add a user in the `users` array with `"realmRoles": ["critic"]` and a password credential like the others. Add the role to the demo users' list in [keycloak.md](/docs/grandstack-auth/keycloak/). 2. **Recreate Keycloak** so it imports the change ([how](/docs/grandstack-auth/keycloak/#changing-the-realm)). On a first start there is nothing to recreate: the realm file is imported automatically. 3. **Gateway.** Nothing to change: the `critic` token also carries `user` (the role is composite), and that already grants `reviews:write`. If a role should *not* include `user`, add it to the map instead: set `ROLE_PERMISSIONS` on the gateway, or edit `DEFAULT_ROLE_PERMISSIONS` in `gateway/rules.ts` and its tests. 4. **Server rule.** The `Review` rule in `schema/auth.graphql` is **one filter shared by update and delete**, so adding the role to its `OR` would also let critics *edit* anyone's review. To allow only deleting, split it into one rule per operation and add the role to the delete rule only: ```graphql @authorization( filter: [ { operations: [UPDATE], where: { OR: [{ node: { author: { sub: { eq: "$jwt.sub" } } } }, { jwt: { roles: { includes: "moderator" } } }] } } { operations: [DELETE], where: { OR: [{ node: { author: { sub: { eq: "$jwt.sub" } } } }, { jwt: { roles: { includes: "moderator" } } }, { jwt: { roles: { includes: "critic" } } }] } } ] ) ``` (If you do want critics to edit too, just add the role to the existing single rule.) 5. **Apply:** restart the server (it reads `auth.graphql` at startup): `docker compose -f compose.yaml -f compose.auth.yaml restart server`, then the gateway (it reads the server's schema at startup). 6. **Test:** - In `scripts/auth-smoke.mjs` add the new user next to the others (the line `const [alice, bob, carol] = await Promise.all([...].map((u) => login(u)))`) and write checks: the critic can write a review, **cannot edit** alice's review (assert the text is unchanged), **can delete** it, and cannot create, update or delete a movie, person or genre. Then `npm run smoke`. - Optionally add a critic case to `gateway/test/rules.test.ts` (a critic token is allowed `createReview` and refused `createMovies`; no gateway code changes, because the role includes `user`). - If the client should show a delete button for critics, do **not** reuse `isModerator` (critics are not moderators, and the playground uses it to predict editing). Add a separate helper such as `canDeleteAnyReview` in `client/src/auth/claims.ts` with tests, and use it in `client/src/pages/MovieDetail.tsx` only. 7. **Docs:** update the role tables in [how-it-works.md](/docs/grandstack-auth/how-it-works/) and [role-and-scope-model.md](/docs/grandstack-auth/role-and-scope-model/), and the demo users in [keycloak.md](/docs/grandstack-auth/keycloak/). ## Protect a new mutation 1. Add the mutation to the server schema (or an extension file) and, if it changes data, a row rule in `schema/auth.graphql`. 2. Add its field name to `MUTATION_PERMISSIONS` in `gateway/rules.ts` with the permission it needs. **Without this the gateway refuses it** (default deny). 3. Add a unit test in `gateway/test/rules.test.ts` and checks in `scripts/auth-smoke.mjs`. 4. Rebuild the gateway: `docker compose -f compose.yaml -f compose.auth.yaml up -d --build gateway`. ## Change who can do what at the gateway Permissions per role are `DEFAULT_ROLE_PERMISSIONS` in `gateway/rules.ts`. To change them without editing code, set the gateway's `ROLE_PERMISSIONS` environment variable in `compose.auth.yaml` (JSON, role to array of permissions). The permission needed by each mutation is `MUTATION_PERMISSIONS`. Keep `client/src/auth/claims.ts` in step if you want the client's buttons to match. ## Add a demo user Add an entry to `users` in `keycloak/grandstack-realm.json` (copy `alice`), then recreate Keycloak ([how](/docs/grandstack-auth/keycloak/#changing-the-realm)). For a quick, non-permanent user use the admin console at http://localhost:8081 (`admin` / `admin`); it is lost when the container is recreated. ## Change the token lifetime In `keycloak/grandstack-realm.json`, `accessTokenLifespan` (seconds) sets it for the realm; a client can override with the attribute `access.token.lifespan` (the `grandstack-smoke-expiring` client does this with 5). Recreate Keycloak to apply. ## Before you commit ```sh (cd gateway && npm test && npm run typecheck) (cd client && npm test && npm run typecheck) npm run docs:check # with the stack running: npm run smoke && npm run e2e ``` --- # grandstack-auth: Exercise: add authentication to grandstack-web URL: https://www.grandstack.ai/docs/grandstack-auth/exercise-add-auth-to-grandstack-web/ A follow-up task for a developer, or a coding agent, to do in **their own copy** of `grandstack-web`. `grandstack-web` itself stays an open, unauthenticated app on purpose; this exercise shows how to add login to an app like it, using the gateway, Keycloak and server rules from this repository. > **Where to run this.** Docker Compose commands run inside a clone of the `grandstack` repository, with the repositories it builds cloned beside it: `git clone https://github.com/grand-stack/grandstack`, and the others the same way. The [quickstart](https://grandstack.ai/quickstart/) has the exact steps. Time: about 2 to 3 hours if you write everything yourself; much less if you borrow from the demo client. The reference solution is about 20 files and 350 lines, which is a good size to aim for. Level: you know React and have read [how it works](/docs/grandstack-auth/how-it-works/). ## Goal With the auth stack running, your copy of the web app must: 1. show **Log in** when signed out and **Log out** plus the user's **name and role** when signed in (the name in an element with `data-testid="who-name"`); 2. send the access token to the gateway as `Authorization: Bearer ...`; 3. hide Add, Edit and Delete for users without the `catalog:write` permission, and keep every route that edits data from them: anonymous visitors are sent to the login page, signed-in users without the permission see a clear "you need the editor role" message; 4. show readable messages for 401 (not logged in or session expired) and 403 (role not allowed) instead of raw errors; 5. **still work exactly as before when auth is switched off** (`VITE_AUTH_ENABLED` unset or not `true`): no login controls, everything editable, the open-mode warning banner shown. Out of scope for the core task: reviews (the [demo client](/docs/grandstack-auth/client/) has them; a stretch goal below), production hardening. ## Before you start - The auth stack runs: `docker compose -f compose.yaml -f compose.auth.yaml up --build` in the `grandstack` repo (client :3001, gateway :4000, Keycloak :8081). The realm and the gateway already allow the web app at `http://localhost:3000`: the Keycloak client `grandstack-auth-client` lists `http://localhost:3000/*` as a redirect URI and the gateway's CORS list includes port 3000. - Do **not** edit `grandstack-web` in place. Make a clean copy next to it and work there: ```sh git -c advice.detachedHead=false clone --branch v0.5.0 grandstack-web grandstack-web-auth # from the folder that holds the repos cd grandstack-web-auth && npm ci ``` - Read these first: [how it works](/docs/grandstack-auth/how-it-works/), [the demo client](/docs/grandstack-auth/client/) (it solves the same problem for a different app; you may borrow ideas, and the files in `client/src/auth/` are a good reference), and `src/App.tsx`, `src/main.tsx` and `src/forms/model.ts` in the web app. - The test users are `alice` (user), `bob` (editor), `carol` (moderator); password is the name plus `-pass`. ## Step 0: see the problem Open http://localhost:3000 in the running auth stack (the stock web app). Browse: it works. Try to add a movie: the form shows an error such as "Unauthenticated: log in to change data", because the app has no login and the gateway treats it as anonymous. Reading works because reads are public. That is the behaviour you are going to improve. ## Steps Each step says what to build, a hint, and how to check it. Run `npm run typecheck` and `npm test` as you go. ### 1. Add the OIDC library Add `oidc-client-ts` (it does the authorization code flow with PKCE). *Check:* `npm ls oidc-client-ts`. ### 2. Settings from the environment, off by default Create `src/auth/config.ts` that reads Vite environment variables: `VITE_AUTH_ENABLED` (auth is on only when it is exactly `true`), `VITE_OIDC_AUTHORITY`, `VITE_OIDC_CLIENT_ID`, optional `VITE_OIDC_SCOPE` (default `openid profile`) and `VITE_OIDC_AUDIENCE` (needed by Auth0, harmless here). Reading the OIDC settings when auth is on but they are missing should throw a clear error. *Hint:* take the environment as a parameter, defaulting to `import.meta.env`, so you can unit-test it without Vite. Declare the new variables in `src/vite-env.d.ts` so they are typed. The demo client's `client/src/auth/config.ts` is a good starting point, but it has no `VITE_AUTH_ENABLED`; that part is yours. *Check:* unit tests for: off by default, on only for `"true"`, missing authority or client id, defaults. ### 3. Read the token Create `src/auth/claims.ts`: decode the access token's payload (base64url, **UTF-8**) into an identity: `sub`, a display `name` (fall back through `name`, `preferred_username`, `email`, `sub`), `roles`, and `permissions`. Derive permissions from roles the way the gateway does ([role model](/docs/grandstack-auth/role-and-scope-model/)): `user` gives `reviews:write`; `editor` and `moderator` also give `catalog:write`; also accept a `permissions` array in the token. *Hint:* this is only for showing and hiding things; the server and gateway are what actually enforce access. You may copy `client/src/auth/claims.ts` and its tests from the demo client and adapt them. *Check:* unit tests, including a non-ASCII name such as "Zoë Müller" (a naive `atob` breaks it). ### 4. Provide the login state Create `src/auth/AuthProvider.tsx` with a React context and a `useAuth()` hook returning `{ enabled, loading, identity, login, logout, completeLogin, canWriteCatalog }`. - When auth is **off**, provide a constant "open" value: `enabled: false`, `canWriteCatalog: true`, no-op login and logout. - When it is **on**, create an `oidc-client-ts` `UserManager`: `redirect_uri` is `/callback`, `post_logout_redirect_uri` is `/`, `response_type: "code"`, your scope, and `extraQueryParams: { audience }` if configured. Turn `automaticSilentRenew` on. Keep the user in `sessionStorage` (`WebStorageStateStore`). - Keep the current access token where the GraphQL client can read it (a module-level variable with a getter is enough). Update it when the user loads, unloads, or the token expires. - `canWriteCatalog` is true when the token's permissions include `catalog:write` (always true when auth is off). *Hint:* the demo client's `AuthProvider.tsx` has everything except the open mode and `canWriteCatalog`; writing those is real work. In open mode the provider must not read the OIDC settings or create a `UserManager`, because those variables will not be set. *Pitfall:* `getUser()` can return an expired user; treat that as signed out. *Check:* typecheck; you will see it working in step 6. ### 5. The callback route The identity provider redirects to `/callback?code=...`. Add a `Callback` page and route that calls `signinRedirectCallback()`, then navigates to the path the user was on (store it in the sign-in `state` when you call `signinRedirect`). *Pitfalls:* the authorization code is single-use and React StrictMode runs effects twice in development, so guard the call with a ref. The route needs the SPA fallback, which the nginx config already has. *Check:* after login you land back on the page you came from, not on `/callback`. ### 6. Send the token In `src/main.tsx` wrap the app in your provider, between `BrowserRouter` and `App` (the provider uses router hooks if you navigate from it, and everything below needs the context), and give the urql client a `fetchOptions` function that adds a bearer token when there is one: ```ts fetchOptions: () => { const token = getAccessToken(); return token ? { headers: { authorization: `Bearer ${token}` } } : {}; }, ``` *Check:* once login works (step 7), creating a movie as bob succeeds, and as anonymous it fails. ### 7. Header: log in, log out, who am I When auth is on and loading has finished, show **Log in** (calls `login()`) when signed out; when signed in show the name (with `data-testid="who-name"`), the roles you care about (`user`, `editor`, `moderator`) and **Log out**. Show nothing when auth is off. *Check:* log in as alice: header shows "Alice Reader" and `user`; log out returns you to the app signed out. ### 8. Hide what the user cannot do Use `canWriteCatalog` to hide: the header **Add** menu; **Add movie** on the results page; **Add person** on the people page; **Edit** and **Delete** on movie and person pages. *Check:* as alice none of these appear; as bob they all do; with auth off they all appear. (The edit pages themselves need nothing extra here: step 9's guard covers them.) ### 9. Guard the routes Direct links must not bypass the UI: wrap `/movies/new`, `/movies/:title/edit`, `/people/new` and `/people/:name/edit` in a guard component. With auth off render the page. While loading show a placeholder. If there is no identity, start the login from a `useEffect`, never during render, and not before loading has finished, or logged-in users get bounced. If the identity lacks the permission, render a message that includes the words "editor role" and a link back. *Check:* anonymous opening `/movies/new` ends up on the Keycloak login page; alice opening it sees the message; bob sees the form. ### 10. Readable errors Extend `friendlyError` in `src/forms/model.ts`. The raw messages the app sees look like `[GraphQL] Unauthenticated: log in to change data` and `[GraphQL] Forbidden: "createMovies" needs the "catalog:write" permission` (urql prefixes them), and a bare 401 or 403 can appear in a `[Network]` message. Messages mentioning `Unauthenticated` or 401 become "You need to log in (or your session expired) to do that."; `Forbidden` or 403 become "Your role does not allow that." Add unit tests. *Check:* temporarily disable a guard and try a write as alice: you should see the readable message, not the raw GraphQL error. ### 11. The open-mode banner `PublicEditBanner` says "No authentication yet". When auth is on, it should not appear. (The stock browser checks look for this banner in open mode, so they are a regression test for this step.) ### 12. Build and run it as a container Add build arguments to your `Dockerfile` for `VITE_AUTH_ENABLED`, `VITE_OIDC_AUTHORITY`, `VITE_OIDC_CLIENT_ID`, `VITE_OIDC_SCOPE`, `VITE_OIDC_AUDIENCE`, turn them into `ENV` before `npm run build` (Vite bakes them into the files), and default `VITE_AUTH_ENABLED` to `false`. Create `compose.web-auth.yaml` in the `grandstack` repo, next to `compose.yaml` (it is yours; do not commit it to the shared repos), that points the `web` service at your copy. The path `../grandstack-web-auth` is relative to that folder, so run the compose commands from there: ```yaml services: web: build: context: ../grandstack-web-auth args: VITE_AUTH_ENABLED: "true" VITE_OIDC_AUTHORITY: http://localhost:8081/realms/grandstack VITE_OIDC_CLIENT_ID: grandstack-auth-client ``` and run it with the auth stack: ```sh docker compose -f compose.yaml -f compose.auth.yaml -f compose.web-auth.yaml up -d --build web ``` If the stack is already running and you only want to replace the web app, add `--no-deps` (`up -d --build --no-deps web`) so nothing else is recreated. If `docker` on your machine is Podman, start its machine first. Open http://localhost:3000. ## Check your work 1. **Unit tests and types** in your copy: `npm run typecheck && npm test`. 2. **Acceptance check** (needs the stack running with your web app as above): ```sh cd ../grandstack-auth && npm ci && node e2e/exercise-check.mjs ``` On Linux set `CHROME_PATH` (for example `/usr/bin/chromium`); `WEB_URL` and `GRAPHQL_URL` are the other settings. It logs in as anonymous, alice and bob in a real browser and checks the goal items: browsing, Log in/out controls, the Add menu, route guarding, the editor-role message, creating and deleting a movie with a token. It needs the hooks named in the goal: text "Log in" and "Log out", `data-testid="who-name"`, an "Add" menu button. It exits non-zero on failure. 3. **Open mode** still works. The stock browser checks (`npm run e2e` in your copy) write data as an anonymous user, so they can only pass against a stack that has **no auth rules**: the plain open stack, which also means a different, freshly seeded database. Stop the auth stack, build your copy without the auth build arguments (that is open mode, since `VITE_AUTH_ENABLED` defaults to `false`), and run it on the open stack: ```sh # in grandstack/, create compose.web-open.yaml: services: { web: { build: { context: ../grandstack-web-auth } } } docker compose -f compose.yaml -f compose.auth.yaml down -v docker compose -f compose.yaml -f compose.web-open.yaml up -d --build cd ../grandstack-web-auth && npm run e2e # all 78 checks should pass ``` They include "public edit banner shown" and the Add, Edit and Delete controls, so they regress steps 8 and 11. If the run stops at the first create with a 401, you are still talking to the auth gateway. With auth off you should see no login controls. 4. **By hand:** log in as each user and compare with the table in [how it works](/docs/grandstack-auth/how-it-works/#what-is-allowed). ## Pitfalls checklist - The redirect URI and the web origin must match exactly what the realm allows (`http://localhost:3000/*`); a mismatch shows "Invalid parameter: redirect_uri" on the Keycloak page. - The callback code is single-use: reloading `/callback` fails. Go to `/` and log in again. - Do not log tokens or put them in URLs. Session storage is readable by scripts on the page; that is the trade-off of an SPA ([notes](/docs/grandstack-auth/keycloak/#not-for-production)). - Hiding buttons is not security. If you remove the gateway and server rules, the API is open again no matter what the UI shows. Test with the playground in the demo client or with `npm run smoke` that the rules still hold. - The app must keep working with auth off: no `useAuth` code path may assume a logged-in user. - Environment variables are baked in at build time: change them, rebuild. ## Stretch goals - Show and manage **reviews** on the movie page: the server schema already has them (`createReview`, `updateReviews`, `deleteReviews`); copy the approach from `client/src/pages/MovieDetail.tsx`. Remember `reviews` exist only in the auth stack, so the page must tolerate their absence when auth is off. - Extend your copy's `e2e/run.mjs` with the login flows from `e2e/run.mjs` in this repo. - Show a friendly "session expired, log in again" state when silent renewal fails. - Run the same app against an **Auth0** tenant using [auth0.md](/docs/grandstack-auth/auth0/) (untested). ## Compare with the reference solution (spoiler) `exercise/reference-solution.patch` in this repo contains one complete solution as a patch against `grandstack-web` v0.5.0: 20 files, about 350 lines. Try the exercise first. When you are done, or stuck, read it with `git apply --stat exercise/reference-solution.patch` (summary) or open the file. `npm run verify-exercise` applies it to a clean checkout and confirms it type-checks, passes its tests and builds, so it stays valid. --- # grandstack-auth: Testing URL: https://www.grandstack.ai/docs/grandstack-auth/testing/ Four layers. The first two need nothing running; the last two need the auth stack (`docker compose -f compose.yaml -f compose.auth.yaml up --build` in the `grandstack` repo). > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-auth`, then `cd grandstack-auth` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## 1. Unit tests | Where | Command | Covers | | --- | --- | --- | | `gateway/` | `npm ci && npm test` (and `npm run typecheck`) | The permission rules: mutation root fields (aliases, fragments, named operations), permissions from roles, `permissions` and `scope` claims, default deny, custom role maps, `ROLE_PERMISSIONS` validation | | `client/` | `npm ci && npm test` | Token decoding (UTF-8 names), permission derivation, OIDC config validation, error messages, review validation | ## 2. Server config tests In `grandstack-server`: `npm test` covers the `AUTH_*` all-or-nothing validation, `SCHEMA_EXTRA_PATHS` and `SETUP_CYPHER_PATHS`. ## 3. Token-level smoke test: `scripts/auth-smoke.mjs` No browser. It logs in as the demo users alice, bob and carol with the password grant and calls the API, checking the whole rule matrix (add a user to the `const [alice, bob, carol] = ...` line to test another role): - anonymous reads work; anonymous writes are refused; - a user can write and edit their own review but cannot change the catalogue; - **a non-author cannot edit or delete a review and nothing changes** (it checks the state, not just the response), and the author link cannot be edited through a review; - the generated `createReviews` does not exist, and an invalid rating or unknown movie creates nothing; - an editor can create, update and delete movies; a moderator can delete anyone's review; - a token signed by another key, a tampered payload, garbage, and an **expired** token (from a client whose tokens last 5 seconds) are all refused; - with `SERVER_URL`, the **server called directly** (bypassing the gateway) refuses anonymous and forged writes and still applies the rules. ```sh npm ci npm run smoke # through the gateway ``` To include the direct-to-server checks, the script must run where it can reach the (unpublished) server, so run it inside the compose network (this adds a few checks): ```sh docker run --rm --network grandstack_default -v "$PWD:/app" -w /app \ -e GRAPHQL_URL=http://gateway:4000/graphql -e SERVER_URL=http://server:4000/graphql \ -e KEYCLOAK_URL=http://keycloak:8080 docker.io/library/node:24-slim node scripts/auth-smoke.mjs ``` (`grandstack_default` is the network compose creates for a project named `grandstack`, which is the name of the folder you run compose from. Find yours with `docker network ls | grep _default`, or start compose with `COMPOSE_PROJECT_NAME=grandstack`.) Variables: `GRAPHQL_URL`, `SERVER_URL`, `KEYCLOAK_URL` (where to get tokens), `AUTH_ISSUER` (the `iss` the services expect, used to forge tokens). The script creates data named `ZZ Auth ...` and removes it. It exits non-zero on any failure. ## 4. Browser end-to-end: `e2e/run.mjs` Drives real Chrome through the demo client, including the **real Keycloak login page**: - anonymous: movies visible, "log in to review" shown, playground refuses writes; - alice: logs in (name and role shown, session in `sessionStorage` only), writes and edits a review, playground matches her role, logs out; - bob: editor playground results, no delete button on alice's review; - carol: deletes alice's review as a moderator; - the **unchanged `grandstack-web`** against the gateway: reads work, a create fails with a readable error, nothing is created. ```sh npm ci OUT=/tmp/shots npm run e2e # OUT is optional: screenshots go there ``` Variables: `CLIENT_URL` (default `http://localhost:3001`), `WEB_URL` (`http://localhost:3000`), `GRAPHQL_URL`, `CHROME_PATH` (set it on Linux), `OUT`. Each scenario starts a fresh browser: in the environment this was built in, headless Chrome died after about 14 page loads in one session. It exits non-zero on failure. ## What is not tested - **Silent token renewal over a long session** is configured (`automaticSilentRenew`) but the browser checks run well inside the 5-minute token lifetime. Expiry is covered at the token level by the smoke script, not in the browser. - **Auth0** ([auth0.md](/docs/grandstack-auth/auth0/)) has not been run against a real tenant. - CI runs the unit tests, builds and the link check; it does not start the stack. --- # grandstack-auth: Troubleshooting URL: https://www.grandstack.ai/docs/grandstack-auth/troubleshooting/ Problems we actually hit, with causes and fixes. ## Setup and startup | Symptom | Cause and fix | | --- | --- | | Gateway container exits right after starting, log says `connect ECONNREFUSED ...:4000` while "Loading schema" | The gateway reads the server's schema at startup and the server was not ready. `compose.auth.yaml` makes the gateway wait for the server's healthcheck; if you start it by hand, start the server first. | | Changes to `keycloak/grandstack-realm.json` have no effect | Keycloak imports the realm only when it does not exist. Recreate the container: `docker compose -f compose.yaml -f compose.auth.yaml rm -sf keycloak` then `up -d keycloak`. | | Tokens have no `sub` | The client lacks the `basic` client scope. The realm file includes it; check any custom client you add. | | Keycloak takes a while | First start imports the realm and takes about 20 seconds; other services wait for its healthcheck. | | Nothing listens on 3001 / 4000 / 8081 | Another process owns the port. Check with `lsof -nP -iTCP:4000 -sTCP:LISTEN` and change the left side of the port mapping. On the machine this was built on, a local Neo4j also owned `127.0.0.1:7687`. | | Gateway fails at startup `Missing required environment variable AUTH_...` | Set `AUTH_JWKS_URL`, `AUTH_ISSUER`, `AUTH_AUDIENCE` and `UPSTREAM_URL` on the gateway. | | Gateway fails at startup `ROLE_PERMISSIONS must be a JSON object...` | The variable must be a JSON object of role to an array of permission strings. | ## Tokens are rejected though they look fine 1. **Compare the claims with the settings.** Decode the token (`cut -d. -f2 | base64 -d`, or jwt.io) and compare `iss` and `aud` with `AUTH_ISSUER` and `AUTH_AUDIENCE`, character for character. 2. **Issuer versus key address.** `AUTH_ISSUER` is the address in the token (`http://localhost:8081/realms/grandstack`); `AUTH_JWKS_URL` is where the *services* fetch keys (`http://keycloak:8080/...`). Using `localhost:8081` as the JWKS URL inside a container fails because `localhost` is the container itself. 3. **Clock or expiry.** Access tokens last 5 minutes in the dev realm. An expired token gets `Unauthenticated`. 4. **Unknown key id.** A token signed by a key that is not in the JWKS (forged, or from before a key rotation) gets a clean 401. (By default the gateway library answers 500 "Authentication is not available" for this; `gateway.config.ts` converts that case to 401.) ## Responses that surprise people | Symptom | Explanation | | --- | --- | | Editing or deleting someone else's review "succeeds" but nothing changes | Intended: row-level rules are filters, so the row is not matched (`nodesDeleted: 0`). It is not an error. | | The unchanged web app shows an error on every create, edit and delete | Intended: it has no login, so through the gateway it is an anonymous client. Reading works. See the [exercise](/docs/grandstack-auth/exercise-add-auth-to-grandstack-web/). | | `Forbidden: "x" needs the "catalog:write" permission` | The token's roles do not give that permission. Check the role (`/playground` shows your permissions). | | `Forbidden: mutation "x" is not allowed through the gateway` | The mutation is not in `MUTATION_PERMISSIONS` in `gateway/rules.ts`; unknown mutations are refused. | | `createReview` returns `null` | The rating is not 1 to 10, or the movie title does not exist. The mutation returns null instead of an error. | ## Server and schema errors (from the Neo4j GraphQL library) | Symptom | Cause and fix | | --- | --- | | `There is no procedure with the name apoc.util.validate` | An `@authorization` `validate` rule needs the APOC plugin. This repo uses `filter` rules instead. | | `Unknown function 'apoc.date.convertFormat'` | Reading a `DateTime` field needs APOC. `Review.createdAt` is stored as an ISO string for this reason. | | Startup `Invalid argument: validate, error: Expected type to be an object` | Rule `where` clauses must use operators: `sub: { eq: "$jwt.sub" }`. | | More in the server's `docs/troubleshooting.md` and `docs/schema-guide.md` | `@unique`, `extend type Mutation`, nullable relationship fields. | ## The demo client and Keycloak login | Symptom | Cause and fix | | --- | --- | | Keycloak page says `Invalid parameter: redirect_uri` | The client's redirect URI is not registered. The realm allows `http://localhost:3001/*` and `http://localhost:3000/*`; the app must be served from one of those origins. | | `Login failed: No matching state found in storage` | You reloaded `/callback` or opened it in another tab. The code is single-use and the state lives in `sessionStorage` of the original tab. Go to `/` and log in again. | | Browser console: CORS error from the gateway | The page's origin is not in the gateway's `CORS_ORIGIN` (default `http://localhost:3000,http://localhost:3001`). | | Logged out unexpectedly | The access token lasted 5 minutes and silent renewal did not succeed; log in again. (Renewal over long sessions is not covered by the tests.) | | After login the playground shows no permissions | The token has no `roles` claim: the client must use a client that has the roles mapper (`grandstack-auth-client` does). | --- # grandstack-auth: AGENTS.md: guide for coding agents URL: https://www.grandstack.ai/docs/grandstack-auth/agents-md/ Auth for GRANDstack: Hive Gateway (`gateway/`), Keycloak dev realm (`keycloak/`), row-level schema rules (`schema/auth.graphql`), demo client (`client/`), smoke and browser tests. Read [docs/how-it-works.md](/docs/grandstack-auth/how-it-works/) first, then [docs/recipes.md](/docs/grandstack-auth/recipes/). `grandstack-web` is deliberately **not** modified by this project; adding login to it is the exercise in [docs/exercise-add-auth-to-grandstack-web.md](/docs/grandstack-auth/exercise-add-auth-to-grandstack-web/). ## Commands - Start: `cd ../grandstack && docker compose -f compose.yaml -f compose.auth.yaml up --build` (client :3001, gateway :4000, Keycloak :8081). - Gateway: `cd gateway && npm ci && npm test && npm run typecheck`. Client: `cd client && npm ci && npm test && npm run typecheck` (`npm run codegen` needs the stack). - With the stack running: `npm ci && npm run smoke` and `npm run e2e`. - Before you finish, all of: `(cd gateway && npm test && npm run typecheck)`, `(cd client && npm test && npm run typecheck)`, `npm run docs:check`, then with the stack running `npm run smoke` and `npm run e2e`. - First time: `cp .env.example .env` in the `grandstack` repo (compose reads the Neo4j password from it). ## Rules that are easy to get wrong - Realm edits only apply to a fresh Keycloak (recreate the container); see [docs/keycloak.md](/docs/grandstack-auth/keycloak/#changing-the-realm). - **Two addresses:** `AUTH_ISSUER` is the browser-facing `http://localhost:8081/realms/grandstack`; `AUTH_JWKS_URL` is `http://keycloak:8080/...` (reachable from containers). They must match the token's `iss` and the services' network respectively. - A new mutation must be added to `MUTATION_PERMISSIONS` in `gateway/rules.ts` or the gateway refuses it (default deny). - Server rules use `filter`, not `validate` (`validate` needs the APOC plugin), and no `DateTime` fields (also APOC). Rule `where` clauses use operators (`sub: { eq: "$jwt.sub" }`). More in `grandstack-server/docs/schema-guide.md`. - The server and gateway read their config at startup: restart the server after changing `auth.graphql`, then the gateway. - `grandstack-web` stays unauthenticated and unchanged; against the gateway it is an anonymous client and its writes fail by design. - Never use the demo realm, its passwords or the password-grant clients outside local development. - New code files need the copyright header (two lines: `Copyright (c) "Neo4j"` and `Neo4j Sweden AB [http://neo4j.com]`, in the file's comment syntax). Run `node ../grandstack/scripts/add-headers.mjs` (or `node ../.github/scripts/headers.mjs --root .` for one repository) to add it; `--check` lists what is missing. Generated files are exempt. ## Keep the docs true Update the matching doc in the same change as the behaviour (role tables in `docs/how-it-works.md` and `docs/role-and-scope-model.md`, Keycloak facts in `docs/keycloak.md`). `npm run docs:check` fails on a broken relative link. Keep `exercise/reference-solution.patch` valid with `npm run verify-exercise`. Diagrams are SVG files in `docs/diagrams/`: edit the SVG and its numbered legend together when behaviour changes; `npm run docs:check` fails on a missing alt text, unsafe SVG content, or legend numbers that do not match the callouts drawn. - Releases use the **release-cut** workflow (Actions tab), which sets the version in every `package.json`, tags and publishes. Do not bump versions or push tags by hand; see `grandstack/docs/releasing.md`. --- # grandstack-agents: grandstack-agents: overview URL: https://www.grandstack.ai/docs/grandstack-agents/ How agents work with GraphQL. Two ways, both against the open `grandstack-server`: 1. **Directly**: the agent sends GraphQL queries and mutations itself, through a single `graphql` tool in its own loop. 2. **Through an MCP server**: a small server that accepts GraphQL statements and runs them. Any MCP client (Claude Code, Claude Desktop, your own app) can use it. ![Direct versus MCP](/diagrams/grandstack-agents/direct-vs-mcp.svg) 1. An agent you write: the model, the loop and one `graphql` tool. 2. Any MCP client: Claude Code, Claude Desktop or your own app. 3. The guarded executor both paths share: parse, one operation, lane check, mutations off by default, timeout, size cap. 4. `grandstack-server`, open, no auth. 5. The MCP server with its four tools, over stdio or HTTP. No login is needed here: it talks to `grandstack-server` as it is. [Adding auth](/docs/grandstack-agents/adding-auth/) shows how to point the same tools at the `grandstack-auth` gateway, with example code. Agent memory, where auth becomes part of the design, is a later phase and its own repo. > **Read-only by default.** `run_mutation` does nothing until you start the server with `ALLOW_MUTATIONS=true`. That flag is a guardrail for the MCP path, not access control: the server on port 4000 is open. See [guardrails](/docs/grandstack-agents/guardrails/). ## The tools | Tool | Does | | --- | --- | | `get_schema` | A compact summary of the API (root fields, the types the schema defines). `detail: "full"` returns everything. | | `describe_type` | One type or input in full, for example `MovieWhere`, so an agent learns exact shapes step by step. | | `run_query` | Runs a query. Mutations are refused. | | `run_mutation` | Runs a mutation, only when `ALLOW_MUTATIONS=true`. | ## Run it With the `grandstack` umbrella and its sibling repos cloned side by side into one folder: ```sh mkdir grand-stack && cd grand-stack # any folder name for repo in grandstack grandstack-server grandstack-web grandstack-agents; do git clone https://github.com/grand-stack/$repo; done cd grandstack docker compose -f compose.yaml -f compose.agents.yaml up --build ``` The MCP server (streamable HTTP) is then at `http://localhost:4100/mcp`. Or run it from a clone of this repo (Node 22 or later) against a stack that is already up: ```sh git clone https://github.com/grand-stack/grandstack-agents # skip if you cloned it above cd grandstack-agents npm ci npm run mcp:http # HTTP on :4100 npm run mcp:stdio # stdio, for clients that start the process themselves ``` Connect Claude Code: ```sh claude mcp add --transport http grandstack http://localhost:4100/mcp ``` More clients and setups are in [the MCP server guide](/docs/grandstack-agents/mcp-server/). ## Documentation | Read | To learn | | --- | --- | | [How it works](/docs/grandstack-agents/how-it-works/) | The pieces, a tool call end to end, the diagrams | | [The MCP server](/docs/grandstack-agents/mcp-server/) | Tools in detail, transports, client setup, debugging with curl, configuration | | [Direct GraphQL](/docs/grandstack-agents/direct-graphql/) | What an agent does without MCP, with copy-paste examples | | [The agent loop](/docs/grandstack-agents/agent-loop/) | The runnable example, the model adapter, running it with Claude | | [Adding auth](/docs/grandstack-agents/adding-auth/) | Using `grandstack-auth` with these tools, with verified example code | | [Schema design for agents](/docs/grandstack-agents/schema-design-for-agents/) | Why tailored type definitions beat introspection alone, and what to do if you cannot change them | | [Guardrails](/docs/grandstack-agents/guardrails/) | What is checked, what is not | | [Recipes](/docs/grandstack-agents/recipes/) | Add a tool, restrict operations, change limits | | [Testing](/docs/grandstack-agents/testing/) | Unit, MCP, live and container checks | | [Troubleshooting](/docs/grandstack-agents/troubleshooting/) | Errors and their fixes | Working with a coding agent? Start with [AGENTS.md](/docs/grandstack-agents/agents-md/). Last verified: grandstack-agents v0.1.2 with grandstack-server v0.7.1 and grandstack-auth v0.1.1, `@modelcontextprotocol/sdk` 1.32.1, Node 24, under Podman 6 on macOS. The live Claude run of the agent example needs an `ANTHROPIC_API_KEY` and has **not** been run by the authors; the loop is tested with a scripted model. ## Licence Apache License 2.0. See [LICENSE](https://github.com/grand-stack/grandstack-agents/blob/main/LICENSE) and [NOTICE](https://github.com/grand-stack/grandstack-agents/blob/main/NOTICE). The same licence applies to every GRANDstack repository and to [grandstack.ai](https://grandstack.ai). --- # grandstack-agents: How it works URL: https://www.grandstack.ai/docs/grandstack-agents/how-it-works/ ## The pieces | File | Role | | --- | --- | | `src/config.ts` | Reads the environment: `GRAPHQL_URL`, `ALLOW_MUTATIONS`, limits, port | | `src/graphql.ts` | The **executor**: checks a document, POSTs it, enforces timeout and size cap. Both paths use it | | `src/schema-tools.ts` | Reads the schema by introspection and renders a summary, one type, or everything | | `src/tools.ts` | The four tools, shared by the MCP server and the agent example | | `src/mcp.ts`, `src/stdio.ts`, `src/http.ts` | The MCP server and its two entry points | | `src/agent.ts` | The agent loop and the single `graphql` tool for direct use | | `examples/` | A Claude adapter and CLI (`direct/`), and the auth example (`with-auth/`) | ## Two paths, one executor ![Direct versus MCP](/diagrams/grandstack-agents/direct-vs-mcp.svg) 1. **An agent you write** holds the model, the loop and the tool call. It puts a schema summary in the system prompt. 2. **Any MCP client** needs no code from you: it lists the tools and calls them. 3. **The guarded executor** is where both paths meet. It parses the document, allows one operation, keeps queries and mutations apart, refuses mutations unless enabled, and applies a timeout and a size cap. 4. **`grandstack-server`** at `:4000` is open, with no auth. 5. **The MCP server** offers `get_schema`, `describe_type`, `run_query`, `run_mutation` over stdio or HTTP. ## One tool call, end to end ![A run_mutation call](/diagrams/grandstack-agents/mcp-tool-call.svg) 1. **parse**: a syntax error is returned with its line and column. 2. **lane**: `run_query` accepts only queries, `run_mutation` only mutations; subscriptions are not supported. 3. **gate**: `run_mutation` stops unless `ALLOW_MUTATIONS=true`. 4. **limits**: document size, a 15 s timeout, and results cut at 100 kB with a marker. 5. **A refusal comes back as text** with `isError: true`, so the agent can read it and change course. Nothing is sent to the server. Errors from the GraphQL server are returned the same way, with any partial data, so an agent can fix its statement. ## Why the schema is read progressively The generated GraphQL schema is about 34 KB. Putting it all in a prompt wastes context. `get_schema` returns about 4.6 KB: the root fields and the types the schema file defines, without the generated filter, sort and input types. `describe_type` fetches those one at a time when the agent needs them. The schema an agent sees is only as helpful as its type definitions. See [designing the type definitions for agents](/docs/grandstack-agents/schema-design-for-agents/). ## Stateless HTTP The HTTP server creates a new MCP server per request and keeps no sessions. That makes it trivial to run in a container and to call with curl. The cost is that it cannot push notifications; these tools do not need to. --- # grandstack-agents: Designing the type definitions for agents URL: https://www.grandstack.ai/docs/grandstack-agents/schema-design-for-agents/ An agent works with the schema it is given. The tools in this repo read it by introspection, so what the agent sees is whatever the type definitions say. A schema that is generated from a data model and left as it came is usable, but it is not the best an agent can get. One tailored to the questions you want answered does better. > **Where this comes from.** This is the experience of the project's maintainers from other testing, not something measured in this repository. There is no benchmark here, and the sample schema was not rewritten to compare. Treat it as guidance to try on your own schema and measure. ## Why introspection alone falls short Introspection reports structure: types, fields, arguments. An agent needs intent. A generated schema is a faithful picture of the database, which is rarely the shape of the questions people ask: - **Names follow the data, not the task.** A field called `acted` or a relationship type from the database tells an agent little about when to use it. - **Missing descriptions.** Without them the agent guesses what a field means, what its values look like, and which one to pick when two seem alike. - **Everything is exposed.** Generated filter, sort and input types are large. This repo's `get_schema` summary leaves them out for that reason (about 4.6 KB instead of 34 KB for the sample), but the agent still has to find the right one. - **The answer is several hops away.** If a common question needs a traversal, an aggregation and a sort, the agent must assemble it correctly every time. ## What a tailored schema does These are choices you make in the schema file. The sample schema already makes several of them: | Choice | Example in this stack | | --- | --- | | Describe every type and field, including units and allowed values, and say when to use it | Lint requires a description on everything the schema file defines; the generated [schema reference](/docs/grandstack-server/schema-reference/) lists them | | Store the answer to a common question instead of making the agent compute it | `Person.actedCount` and `directedCount` are stored, so "who has directed the most films" is one sorted query, refreshed by the server after every mutation | | Name things for the task | Fields and relationships are named for what a person would say (`actors`, `directors`, `genres`) | | Leave out what an agent should not use | `nestedOperations: []` and `@mutation(operations: ...)` limit which generated mutations exist | | Write your own Cypher where the generated Cypher is slow or does not fit (`@cypher`) | [Custom Cypher](#custom-cypher-with-cypher), run on the sample data | | Offer purpose-built operations for frequent questions | A fixed tool is the same idea one level up: see [add a fixed-purpose tool](/docs/grandstack-agents/recipes/#add-a-fixed-purpose-tool) | Beyond those, use [`@cypher`](#custom-cypher-with-cypher) for questions that are common and awkward or slow in the generated shape, and keep the description of each operation as specific as a tool description, because the agent reads it the same way. ## Custom Cypher with `@cypher` The generated Cypher is general. When it is slow for a question, or does not do what the question needs, write the Cypher yourself with the `@cypher` directive and give the result a name and a description an agent can find. There are two places to use it. **A computed field on a type.** Inside the statement, `this` is the node. `columnName` says which returned column is the field's value: ```graphql extend type Person { "How many different people this person has worked with on a film, as actor or director." collaboratorCount: Int! @cypher( statement: """ MATCH (this)-[:ACTED_IN|DIRECTED]->(:Movie)<-[:ACTED_IN|DIRECTED]-(other:Person) WHERE other <> this RETURN count(DISTINCT other) AS n """ columnName: "n" ) } ``` Asking `{ people(where: { name: { eq: "Tom Hanks" } }) { name collaboratorCount } }` returned `collaboratorCount` 41 on the sample data. **A custom query.** Arguments are available in the statement as parameters (`$limit`), and the field can return a type from the schema, so the agent can select fields on the result as usual: ```graphql type Query { "People who have directed the most films, most first. Use this for questions about top directors." topDirectors(limit: Int = 5): [Person!]! @cypher( statement: """ MATCH (p:Person)-[:DIRECTED]->(m:Movie) WITH p, count(m) AS n ORDER BY n DESC, p.name LIMIT $limit RETURN p AS person """ columnName: "person" ) } ``` `{ topDirectors(limit: 3) { name directedCount } }` returned Christopher Nolan 8, Steven Spielberg 7, Martin Scorsese 6 (ties are broken by name). Both were run on the sample stack by loading the definitions through the server's `SCHEMA_EXTRA_PATHS` setting (see the server's configuration doc) in a throwaway server instance; they are **not** part of the shipped schema. Two things we hit: - Define custom queries with `type Query { ... }` in the extra file. `extend type Query` made `@neo4j/graphql` 7.6.4 crash at startup with `Cannot read properties of undefined (reading 'kind')`, because the base schema file has no `Query` type of its own. (`extend type Person` works.) - The description is what an agent reads. Say when to use the field, as in `topDirectors` above. A custom query with a clear name and description is the schema-level version of a [fixed-purpose tool](/docs/grandstack-agents/recipes/#add-a-fixed-purpose-tool), and the agent finds it with `get_schema` without any change to the MCP server. Use it where it earns its place: a question that is common and slow or awkward in the generated shape, or a result the generated filters cannot express. Keep statements read-only and small, and review them like any query you would put in front of production: custom Cypher runs as you wrote it, so the generated limits and filters do not apply to it. We did not test `@cypher` on mutations, filtering or sorting by a `@cypher` field, or authorization rules on one, so check those on your own schema before relying on them. ## If you cannot change the schema You may not own the schema, or not be able to change it. Two options: 1. **Put the missing knowledge outside it.** Write the intent into the system prompt (`systemPrompt` in `src/agent.ts`), into fixed-purpose tools, or into a short guide the agent reads first, such as the umbrella's `llms.txt`. 2. **Ask an agent what type definitions would help.** Give it the introspected schema and a list of the questions you want answered, and ask what descriptions, names, stored fields or custom queries would make those questions easier. In the maintainers' testing, agents gave useful suggestions this way. Treat the output as proposals: review them, apply what is right, and check the result by asking the questions again. ## Models matter too Newer models do better with the same schema, as you would expect: they write more accurate queries, recover better from errors and cope with larger schemas. That is not a reason to skip the design work. A better model and a better schema add up, and the schema is the part you control and that keeps working when you change models. Re-test when you change model, using the same set of questions; `ANTHROPIC_MODEL` switches the model for the [agent loop example](/docs/grandstack-agents/agent-loop/#run-it-with-claude). ## A way to test your schema 1. Write 10 to 20 real questions, with the answers you expect. 2. Run them through an agent (the [agent loop](/docs/grandstack-agents/agent-loop/) or an MCP client), read-only. 3. Note which fail or take many calls, and look at the statements the agent wrote. A repeated wrong field or a long traversal points at a name, a missing description or a missing stored field. 4. Change the schema (or add a tool), and run the same questions again. --- # grandstack-agents: The MCP server URL: https://www.grandstack.ai/docs/grandstack-agents/mcp-server/ A small [Model Context Protocol](https://modelcontextprotocol.io) server that accepts GraphQL statements and runs them against any GraphQL endpoint. It knows nothing about movies: the schema is read by introspection, so it works with another GraphQL server too. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-agents`, then `cd grandstack-agents` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## Tools | Tool | Input | Result | | --- | --- | --- | | `get_schema` | `detail`: `summary` (default) or `full` | SDL text. The summary lists root query and mutation fields with arguments, and the types the schema defines with descriptions; generated filter, sort and input types are left out | | `describe_type` | `name`, for example `MovieWhere` | The definition of that type, input or enum. An unknown name returns suggestions | | `run_query` | `query`, optional `variables`, `operationName` | The JSON response as text. Mutations and subscriptions are refused | | `run_mutation` | same | The JSON response as text. Refused unless `ALLOW_MUTATIONS=true`; queries are refused | Failures (a syntax error, a GraphQL error, a refusal, a timeout) are returned as text with `isError: true`, so the agent can read and correct. ## Transports | | Command | Use when | | --- | --- | --- | | stdio | `npm run mcp:stdio` | The client starts the process: Claude Code, Claude Desktop | | Streamable HTTP | `npm run mcp:http`, or the `agents-mcp` container | A long-running server, a container, remote or shared use | The HTTP server is **stateless**: `POST /mcp` only. `GET /mcp` returns 405, `GET /healthz` returns `{"ok":true}`. It listens on `127.0.0.1` by default; the container sets `HOST=0.0.0.0` and publishes port 4100. It has no auth of its own: anyone who can reach port 4100 can run queries (and mutations, if enabled) as the server is configured. Do not expose it beyond your machine without putting auth in front. ## Configuration | Variable | Default | Meaning | | --- | --- | --- | | `GRAPHQL_URL` | `http://localhost:4000/graphql` | The endpoint to run statements against | | `ALLOW_MUTATIONS` | `false` | `true` enables `run_mutation` | | `GRAPHQL_HEADERS` | none | JSON object of extra headers, for example `{"authorization":"Bearer ..."}` | | `TIMEOUT_MS` | `15000` | Request timeout | | `MAX_RESULT_BYTES` | `100000` | Results longer than this are cut with a marker | | `MAX_DOCUMENT_BYTES` | `20000` | Longest statement accepted | | `PORT`, `HOST` | `4100`, `127.0.0.1` | HTTP only | With the umbrella, `AGENTS_ALLOW_MUTATIONS=true docker compose -f compose.yaml -f compose.agents.yaml up -d` turns writes on for the container. ## Connecting a client **Claude Code, HTTP** (server already running on :4100): ```sh claude mcp add --transport http grandstack http://localhost:4100/mcp ``` **Claude Code, stdio** (Claude Code starts the process; use absolute paths): ```sh claude mcp add grandstack -e GRAPHQL_URL=http://localhost:4000/graphql -- npx --prefix /path/to/grandstack-agents tsx /path/to/grandstack-agents/src/stdio.ts ``` **Claude Desktop** (`claude_desktop_config.json`): ```json { "mcpServers": { "grandstack": { "command": "npx", "args": ["--prefix", "/path/to/grandstack-agents", "tsx", "/path/to/grandstack-agents/src/stdio.ts"], "env": { "GRAPHQL_URL": "http://localhost:4000/graphql" } } } } ``` Add `"ALLOW_MUTATIONS": "true"` to `env` to let the agent write. To skip `tsx`, run `npm run build` once and use `node /path/to/grandstack-agents/dist/stdio.js`. > Verified: the HTTP server and the stdio entry point against the SDK client (see [testing](/docs/grandstack-agents/testing/)), and `claude mcp add` accepted both commands above. A live conversation in Claude Code or Claude Desktop was **not** run by the authors. **Your own app**, with the MCP SDK: ```ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "my-app", version: "1.0.0" }); await client.connect(new StreamableHTTPClientTransport(new URL("http://localhost:4100/mcp"))); const result = await client.callTool({ name: "run_query", arguments: { query: "{ moviesConnection { totalCount } }" } }); console.log(result.content); ``` ## Debugging with curl The HTTP server speaks JSON-RPC. Send both `Accept` types; the reply is a single server-sent event: ```sh curl -s localhost:4100/mcp -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_query","arguments":{"query":"{ moviesConnection { totalCount } }"}}}' ``` ``` event: message data: {"result":{"content":[{"type":"text","text":"{\"data\":{\"moviesConnection\":{\"totalCount\":152}}}"}],"isError":false},"jsonrpc":"2.0","id":1} ``` `"method":"tools/list"` lists the tools. The same call with `run_mutation` and a mutation returns `isError: true` and "Mutations are disabled" until the flag is set. --- # grandstack-agents: Direct GraphQL URL: https://www.grandstack.ai/docs/grandstack-agents/direct-graphql/ An agent does not need MCP to use GraphQL. It needs the endpoint, a way to learn the schema, and the discipline to fix its own errors. This page shows the loop with plain `curl`; [the agent loop](/docs/grandstack-agents/agent-loop/) wraps it in code. The endpoint here is `http://localhost:4000/graphql`, the open `grandstack-server`. ## 1. Learn the shape Read the descriptions first: every type and field in the server's schema has one ([schema guide](/docs/grandstack-server/schema-guide/) in the server repo, and `llms.txt` in the umbrella). Then ask the server: ```sh curl -s localhost:4000/graphql -H 'content-type: application/json' \ -d '{"query":"{ __schema { queryType { fields { name } } } }"}' ``` ```json {"data":{"__schema":{"queryType":{"fields":[{"name":"moviesConnection"},{"name":"movies"},{"name":"peopleConnection"},{"name":"people"},{"name":"genresConnection"},{"name":"genres"}]}}}} ``` The generated schema is large. Ask for one type at a time: ```graphql { __type(name: "MovieWhere") { inputFields { name type { name kind ofType { name } } } } } ``` ## 2. Query Filters use operators such as `eq`, `contains`, `gte`, `startsWith` (`contains` is case-sensitive). `...Connection` fields give `totalCount`. ```sh curl -s localhost:4000/graphql -H 'content-type: application/json' \ -d '{"query":"{ movies(where:{title:{contains:\"Matrix\"}}){ title released genres{name} } }"}' ``` ```json {"data":{"movies":[{"title":"The Matrix","released":1999,"genres":[{"name":"Action"},{"name":"Sci-Fi"}]},{"title":"The Matrix Reloaded","released":2003,"genres":[{"name":"Action"},{"name":"Sci-Fi"}]}]}} ``` With variables, and a count: ```sh curl -s localhost:4000/graphql -H 'content-type: application/json' \ -d '{"query":"query($y:Int){ moviesConnection(where:{released:{gte:$y}}){ totalCount } }","variables":{"y":2010}}' ``` ```json {"data":{"moviesConnection":{"totalCount":40}}} ``` Sorting takes a list of single-field objects, for example `sort: [{ released: ASC }]` (the exact enum is in `MovieSort`; ask for it with `describe_type` or `__type`). "Who has directed the most films": ```graphql { people(sort: [{ directedCount: DESC }], limit: 3) { name directedCount } } ``` returns Christopher Nolan 8, Steven Spielberg 7, Quentin Tarantino 6. The counts are stored on each person and refreshed by the server after every mutation. ## 3. Read the errors ```sh curl -s localhost:4000/graphql -H 'content-type: application/json' \ -d '{"query":"{ movies(where:{titel:\"x\"}){ title } }"}' ``` ```json {"errors":[{"message":"Field \"titel\" is not defined by type \"MovieWhere\". Did you mean \"title\"?","locations":[{"line":1,"column":17}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]} ``` GraphQL returns HTTP 200 with an `errors` array, so an agent must look at the body, not just the status. The message says what to fix. That is the loop: write, run, read the error, correct. ## 4. Mutate carefully ```graphql mutation($t: String!) { createMovies(input: [{ title: $t, released: 2030, actors: { connect: [{ where: { node: { name: { eq: "Tom Hanks" } } }, edge: { roles: ["Agent test"] } }] } }]) { movies { title } } } ``` Rules an agent must know (also in the [server mutation guide](/docs/grandstack-server/mutations/)): - Update scalars with `{ set: value }`. - Change relationships as a difference (connect what is new, disconnect what is gone), never "disconnect all then connect" in one mutation. - Delete a movie with `deleteMovies(where: ...)`. Always filter; `deleteMovies` with no filter deletes everything. Cleanup of the example: `mutation { deleteMovies(where: { title: { startsWith: "ZZ Agent" } }) { nodesDeleted } }`. On an open server nothing stops an agent from writing. That is why the MCP server and the example agent are read-only by default; see [guardrails](/docs/grandstack-agents/guardrails/). --- # grandstack-agents: The agent loop URL: https://www.grandstack.ai/docs/grandstack-agents/agent-loop/ `src/agent.ts` is the whole idea of an agent in about 60 lines: send the question and the tool list to a model; if the model asks for a tool, run it and send back the result; repeat until the model answers in text. There is no MCP in it. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-agents`, then `cd grandstack-agents` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ```ts const result = await runAgent({ question: "Which three directors have made the most films?", adapter: anthropicAdapter(), // the model, behind a small interface tools: [graphqlTool(config)], // one tool: send a GraphQL statement system: await systemPrompt(config), // how to work, plus a schema summary }); console.log(result.answer); ``` ## Pieces | | Where | Does | | --- | --- | --- | | `graphqlTool(config)` | `src/agent.ts` | The single `graphql` tool. Same guardrails as the MCP tools: parse first, one operation, no subscriptions, mutations only when `ALLOW_MUTATIONS=true` | | `systemPrompt(config)` | `src/agent.ts` | Instructions plus the schema summary (about 4.6 KB), so no tool call is spent learning the schema | | `runAgent(...)` | `src/agent.ts` | The loop. Stops when the model answers, or after `maxSteps` (default 8) | | `ModelAdapter` | `src/agent.ts` | `next({ system, messages, tools }) -> { content }`. Messages use the Anthropic content-block shape | | `anthropicAdapter()` | `examples/direct/adapter-anthropic.ts` | The adapter for Claude | To use another model provider, write an adapter that turns `messages` and `tools` into that provider's request and its reply back into `text` and `tool_use` blocks. Nothing else changes. ## Run it with Claude You need an Anthropic API key. The stack must be running (see the README). ```sh export ANTHROPIC_API_KEY=... # your key npm run agent -- "Which three directors have made the most films?" ``` The script prints each GraphQL statement and a short result to stderr, then the answer and a count of calls. Without a key it exits with a message. `ANTHROPIC_MODEL` sets the model (default `claude-sonnet-5-5`). `ALLOW_MUTATIONS=true` lets it change data; leave it off for questions. > **Not run live by the authors.** The loop, the tool and its guardrails are tested with a scripted model that makes two tool calls and answers ([testing](/docs/grandstack-agents/testing/)); the Claude adapter is type-checked but was not exercised, because that needs a key. If it misbehaves, the adapter at `examples/direct/adapter-anthropic.ts` is the place to look. ## Using the MCP tools in your own loop instead If you would rather give your agent the four MCP tools than the single `graphql` tool, `createTools(...)` in `src/tools.ts` returns them with handlers, and `inputSchemaOf(tool)` gives the JSON Schema a model API wants. They are the same objects the MCP server registers. --- # grandstack-agents: Adding auth URL: https://www.grandstack.ai/docs/grandstack-agents/adding-auth/ This repo needs no login: it talks to the open `grandstack-server`. When you put `grandstack-auth` in front of the server, the same tools work with one change: they send a token. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-agents`, then `cd grandstack-agents` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ![The same tools with auth](/diagrams/grandstack-agents/agent-with-auth.svg) 1. **The token provider** logs in to Keycloak, keeps the token, and renews it shortly before it expires. It is passed to the tools as `headers`, a function called for every request. 2. **The gateway** (`:4000` in the auth setup) validates the token and checks what the user's role may do. 3. **alice (role `user`)** can read. A catalogue mutation is refused by the gateway, and the refusal text reaches the agent in the tool result, so it can tell the user why. 4. **bob (role `editor`)** passes the gateway and the server accepts it. 5. **Keycloak** here is the development realm from `grandstack-auth`. Nothing in `grandstack-server`, `grandstack-web` or `grandstack-auth` changes, and the tools themselves are unchanged. ## The idea - Point `GRAPHQL_URL` at the gateway (it is at `http://localhost:4000/graphql` in the auth setup, so the default URL already works). - Give the executor a way to get a token. `headers` accepts a fixed object or a function that returns one (`createTools({ config, headers })`), so a function can fetch and refresh tokens. - The agent then has exactly the permissions of that user's role. ## The example `examples/with-auth/token-provider.ts` logs in with the password grant using the realm's `grandstack-smoke` client and caches the token until 30 seconds before it expires (concurrent calls share one login): ```ts const tokenProvider = createTokenProvider({ realmUrl: "http://localhost:8081/realms/grandstack", clientId: "grandstack-smoke", username: "alice", password: "alice-pass", }); const server = createMcpServer(createTools({ config: loadConfig(), headers: tokenProvider })); ``` `examples/with-auth/server.ts` is that, wired to stdio. Register it with a client like the plain server: ```sh AUTH_USER=alice npx tsx examples/with-auth/server.ts ``` Or in Claude Code: `claude mcp add grandstack-alice -e AUTH_USER=alice -e ALLOW_MUTATIONS=true -- npx --prefix /path/to/grandstack-agents tsx /path/to/grandstack-agents/examples/with-auth/server.ts`. The same works for the direct approach: `graphqlTool(config, tokenProvider)`. ## Run the check Start the auth stack (`docker compose -f compose.yaml -f compose.auth.yaml up --build` in `grandstack`), then: ```sh npm run auth:check ``` It reports 9 passing checks: an anonymous agent can read but is refused writes ("Unauthenticated"); alice can read, is refused `createMovies` (`Forbidden: "createMovies" needs the "catalog:write" permission`), and can create a review as herself; bob can create a movie and delete it again; and the stdio example server, authenticated as alice, reads but cannot create movies. ## Practices - **Least privilege.** Give an agent the weakest role that does the job. A `user` token cannot change the catalogue even if the agent tries. - **Read-only is not a substitute.** `ALLOW_MUTATIONS=false` only affects this MCP server. A direct caller of the server gets nothing from it; the gateway's roles do the real work. - **Token lifetime.** The development realm issues 5-minute tokens; the provider renews before expiry. A production client would also handle a revoked token. - **Keep tokens out of prompts and logs.** The tools never put the token in a result, and errors name the user, not the token. Do not add it to system prompts. - **Not for production.** The password grant and `grandstack-smoke` client are for the demo realm. Production agents should use a client-credentials grant (a service identity) or a user-delegated flow, with a client created for the agent. See [swapping providers](/docs/grandstack-auth/swapping-providers/) in `grandstack-auth`. - **Memory later.** Agent memory, the next phase, is where auth becomes part of the design rather than an add-on. --- # grandstack-agents: Guardrails URL: https://www.grandstack.ai/docs/grandstack-agents/guardrails/ What the tools check before anything reaches the GraphQL server, and what they do not. ## What is checked | Check | Behaviour | | --- | --- | | Parse | A syntax error is returned with its line and column. Nothing is sent | | One operation | A document with several operations needs `operationName`; otherwise it is refused | | Lane | `run_query` takes only queries, `run_mutation` only mutations; subscriptions are refused | | Mutations off | `run_mutation` returns an explanation unless `ALLOW_MUTATIONS=true` (the direct `graphql` tool does the same) | | Document size | Longer than `MAX_DOCUMENT_BYTES` (20000) is refused | | Timeout | A request longer than `TIMEOUT_MS` (15 s) ends with a message | | Result size | Longer than `MAX_RESULT_BYTES` (100 kB) is cut and marked as truncated, so an agent does not drown in rows | | Unreachable or non-JSON | Reported as a tool error, never thrown | Each is covered by a test (see [testing](/docs/grandstack-agents/testing/)). The path of a call, with the four checks, is drawn in [how it works](/docs/grandstack-agents/how-it-works/#one-tool-call-end-to-end). ## What is not protected Be honest about this when you give an agent these tools: - **The server is open.** Port 4000 accepts anyone. `ALLOW_MUTATIONS` limits the MCP path and the example agent, not the server. Anything else on your machine can still send a mutation. - **A read-only flag is not access control.** It stops an agent *using this server* from writing. It does not identify the agent or limit what it can read. - **Queries can be expensive.** Nesting and large lists are allowed. The result cap protects the agent's context, not the database. Use `limit` in queries, and consider depth and cost limits at a gateway for anything shared. - **Agents can be wrong.** A permitted mutation may do the wrong thing, for example `deleteMovies` without a filter. Enable mutations only for sessions where you accept that, and prefer a disposable database. - **The HTTP endpoint has no auth.** Keep it on localhost. Real control comes from [auth](/docs/grandstack-agents/adding-auth/): a token per agent, a role with the least permissions that work, rules at the gateway and in the server. --- # grandstack-agents: Recipes URL: https://www.grandstack.ai/docs/grandstack-agents/recipes/ Both recipes were run against the live stack before being written here. ## Add a fixed-purpose tool A generic `run_query` is flexible; a fixed tool is cheaper and safer for a question you expect often. A tool is a name, a description, an `inputShape` (a plain object of zod types, **not** `z.object(...)`; MCP reads it directly) and a handler (`src/tools.ts`). `execute` (the guarded executor) and `guard` (turns thrown errors into `isError` text, as the built-in tools do) are local to `createTools`, so define the tool inside it, before the `return [`, and append it to the returned array: ```ts // inside createTools, before `return [` const topDirectors: Tool = { name: "top_directors", description: "The directors with the most films, most first.", inputShape: { limit: z.number().int().min(1).max(20).optional().describe("How many (default 5).") }, handler: (args) => guard(async () => { const result = await execute({ query: "query($n: Int!) { people(sort: [{ directedCount: DESC }], limit: $n) { name directedCount } }", variables: { n: (args.limit as number | undefined) ?? 5 }, }); return { text: result.text, isError: !result.ok }; }), }; // ... and add `topDirectors` to the array that `return [` starts ``` Against the live stack, `topDirectors.handler({ limit: 2 })` returns `{"data":{"people":[{"name":"Christopher Nolan","directedCount":8},{"name":"Steven Spielberg","directedCount":7}]}}` (checked with a throwaway script calling `createTools` and the handler). The MCP server and `inputSchemaOf` pick the tool up with no other change. Then update the tests: - `test/tools.test.ts` and `test/mcp.test.ts` list the tool names, so add yours to those expectations. - To test the handler, build the tools with a fake executor: `createTools({ config, executor })`, where `executor` is `async ({ query, variables }) => ({ ok: true, status: 200, data, text: JSON.stringify({ data }), truncated: false })`. Check the `variables` it received and the `text` returned. The stub server in `test/stub-server.ts` has a tiny schema (no `where` or `sort`), so a fake executor is the simple route; extend the stub only if you need a real round trip. ## Allow only some mutations `ALLOW_MUTATIONS` is all or nothing. To permit only named mutations, wrap `run_mutation`. Put this in a new file, `src/only-allowed.ts`: ```ts import { parse, Kind, type OperationDefinitionNode } from "graphql"; import type { Tool } from "./tools.js"; export const ALLOWED_MUTATIONS = new Set(["updateMovies"]); export function onlyAllowed(tool: Tool): Tool { return { ...tool, handler: async (args) => { let definitions; try { definitions = parse(String(args.query)).definitions; } catch { return tool.handler(args); } // a syntax error gets the executor's own message for (const def of definitions) { if (def.kind !== Kind.OPERATION_DEFINITION) continue; for (const sel of (def as OperationDefinitionNode).selectionSet.selections) { if (sel.kind !== Kind.FIELD) return { text: "Fragments at the top level of a mutation are not allowed here.", isError: true }; if (!ALLOWED_MUTATIONS.has(sel.name.value)) return { text: `The mutation "${sel.name.value}" is not allowed here. Allowed: ${[...ALLOWED_MUTATIONS].join(", ")}.`, isError: true }; } } return tool.handler(args); }, }; } ``` Then wrap the tools **everywhere they are created**: `createTools` is called in `src/stdio.ts`, in `src/http.ts` (once per request, inside `createHttpServer`, using `options`) and in `examples/with-auth/server.ts`. Patching only one leaves the others unrestricted. For stdio: ```ts const tools = createTools({ config }).map((t) => (t.name === "run_mutation" ? onlyAllowed(t) : t)); ``` A refused call returns, for example, `The mutation "deleteMovies" is not allowed here. Allowed: updateMovies.` with `isError: true`. It checks only the top-level fields of each operation. It still needs `ALLOW_MUTATIONS=true`, and it is a convenience for agents: only [auth](/docs/grandstack-agents/adding-auth/) restricts what the server accepts. Existing tests use `deleteMovies` as their "allowed mutation" example (`test/mcp.test.ts`), and the stub has no `updateMovies`, so change those tests to a mutation you allow and add it to `test/stub-server.ts`. Add a test that a refused mutation never reaches the executor. ## Make the schema easier for an agent See [designing the type definitions for agents](/docs/grandstack-agents/schema-design-for-agents/): descriptions, stored answers, names for the task, and asking an agent what to improve. ## Change limits Set `TIMEOUT_MS`, `MAX_RESULT_BYTES`, `MAX_DOCUMENT_BYTES` in the environment; see [the MCP server](/docs/grandstack-agents/mcp-server/#configuration). ## Point at a different GraphQL server Set `GRAPHQL_URL`. The tools work from introspection and know nothing about movies; only the example prompts mention them. ## Use the tools from your own agent See [the agent loop](/docs/grandstack-agents/agent-loop/#using-the-mcp-tools-in-your-own-loop-instead). --- # grandstack-agents: Testing URL: https://www.grandstack.ai/docs/grandstack-agents/testing/ | Command | Needs | What it proves | | --- | --- | --- | | `npm test` | nothing | the unit and integration tests, below (42 today) | | `npm run typecheck` | nothing | TypeScript compiles for source, examples, scripts and tests | | `npm run live` | the open stack running | 12 checks against the real server (below) | | `npm run container` | the `agents-mcp` container running (`compose.agents.yaml`) | 3 checks through `http://localhost:4100/mcp` (`MCP_URL` to change); with `EXPECT_MUTATIONS=true` it checks that writes are enabled instead | | `npm run auth:check` | the auth stack running | 9 checks of the [auth example](/docs/grandstack-agents/adding-auth/) | | `npm run docs:check` | nothing | links, anchors, image alt text, SVG safety, diagram legends | > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-agents`, then `cd grandstack-agents` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## Unit and integration tests (`npm test`) They run against `test/stub-server.ts`, a real `graphql-js` server over HTTP, so they need no Docker. - `graphql.test.ts`: classification (query, mutation, subscription, several operations, syntax errors), size limit, timeout, truncation, unreachable server, non-JSON replies, static and function headers. - `config.test.ts`: defaults, validation messages. - `tools.test.ts`: the four tools, lanes, mutations on and off, schema summary and `describe_type`. - `mcp.test.ts`: the SDK client against the server over **stdio** and **streamable HTTP**: tool listing, a query, a refused mutation, an allowed one. - `agent.test.ts`: the agent loop with a scripted model that makes two tool calls and answers; the `graphql` tool's guardrails. - `token-provider.test.ts`: caching, renewal before expiry, shared concurrent logins, error messages. ## Live checks (`npm run live`) Against the real stack: the schema summary is much smaller than the full schema; top directors are Nolan 8, Spielberg 7, Scorsese 6; a typo returns "Did you mean"; mutations are refused by default; with the flag a `ZZ Agent Movie` is created and removed, and the stored counts return to normal; and the agent loop with a scripted model answers from real data. ## What is not tested The Claude adapter (`examples/direct/adapter-anthropic.ts`) and a live conversation in Claude Code or Claude Desktop need a key or a client session that the authors did not run. The tools, the loop and both transports are tested without a model. --- # grandstack-agents: Troubleshooting URL: https://www.grandstack.ai/docs/grandstack-agents/troubleshooting/ | Symptom | Cause and fix | | --- | --- | | `Not Acceptable: Client must accept both application/json and text/event-stream` | A raw HTTP call to `/mcp` without the `Accept` header. Send `-H 'accept: application/json, text/event-stream'` ([curl example](/docs/grandstack-agents/mcp-server/#debugging-with-curl)) | | `405 Method Not Allowed` on `GET /mcp` | The server is stateless and accepts `POST /mcp` only. Use `GET /healthz` to check it is up | | `GraphQL request failed: could not reach http://localhost:... (fetch failed)` | The GraphQL server is not running or `GRAPHQL_URL` is wrong. In a container, `localhost` is the container: the compose file uses `http://server:4000/graphql` | | `Mutations are disabled. This server is read-only by default...` | By design. Start with `ALLOW_MUTATIONS=true` (compose: `AGENTS_ALLOW_MUTATIONS=true`). Only the exact value `true` enables it; `1` or `yes` leave it off | | A mutation works with the flag but the agent still cannot write through the gateway | The auth setup: the user's role lacks the permission. See [adding auth](/docs/grandstack-agents/adding-auth/) | | `The document has 2 operations (...). Send one, or pass operationName` | Send a single operation or set `operationName` | | Results end with a truncation marker | The result exceeded `MAX_RESULT_BYTES`. Ask for fewer fields or use `limit` | | `Could not get a token for alice: ...` | The auth example cannot log in: the auth stack is not up, `REALM_URL` is wrong, or the user or password is wrong | | Port 4100 or 4000 already in use | Another process holds it. Stop it or change `PORT`; check `lsof -i :4100`. A Neo4j of your own on 7687 can also clash with the stack | | `npm run agent` exits asking for `ANTHROPIC_API_KEY` | Set the variable; the example calls a Claude model | | A client shows no tools | Check the server starts (`npm run mcp:stdio` should wait silently; `curl localhost:4100/healthz` for HTTP) and that paths in the client config are absolute | --- # grandstack-agents: AGENTS.md: guide for coding agents URL: https://www.grandstack.ai/docs/grandstack-agents/agents-md/ How agents work with GraphQL: an MCP server that runs GraphQL statements, and a direct agent-loop example. It uses the open `grandstack-server` (no auth); `docs/adding-auth.md` shows the auth variant. ## Commands ```sh npm ci npm test # unit and integration tests, no Docker needed npm run typecheck npm run docs:check # links, anchors, alt text, SVGs, diagram legends npm run mcp:http # MCP over HTTP on :4100 (needs the stack for real use) npm run mcp:stdio npm run live # needs the open stack up ``` The stack (`grandstack` umbrella): `docker compose -f compose.yaml -f compose.agents.yaml up --build`. ## Where things are - `src/graphql.ts` the guarded executor (parse, classify, timeout, size cap); every request goes through it. - `src/tools.ts` the four tools; `src/mcp.ts` registers them; `src/agent.ts` has the loop and the single `graphql` tool. - `docs/` explains all of it; `docs/diagrams/` has annotated SVGs (the checker verifies legends). ## Rules - Keep mutations off by default (`ALLOW_MUTATIONS`). Do not weaken that or the lane checks without a test. - Tool errors are returned as text with `isError`, not thrown. - No auth in this repo's default path. Auth lives in `examples/with-auth` and `grandstack-auth`. - Pin `@modelcontextprotocol/sdk` and use v1-style imports. `graphql` must stay 16.x. - Do not claim in docs what you did not run. The live Claude run is not exercised by tests; keep saying so until it is. - Add a test for every new tool or guardrail (`test/tools.test.ts`, `test/mcp.test.ts`). - New code files need the copyright header (two lines: `Copyright (c) "Neo4j"` and `Neo4j Sweden AB [http://neo4j.com]`, in the file's comment syntax). Run `node ../grandstack/scripts/add-headers.mjs` (or `node ../.github/scripts/headers.mjs --root .` for one repository) to add it; `--check` lists what is missing. Generated files are exempt. - Releases use the **release-cut** workflow (Actions tab), which sets the version in every `package.json`, tags and publishes. Do not bump versions or push tags by hand; see `grandstack/docs/releasing.md`. --- # grandstack-federation: grandstack-federation: overview URL: https://www.grandstack.ai/docs/grandstack-federation/ GraphQL federation for GRANDstack. The Neo4j server becomes a **subgraph**, a small **Wikipedia subgraph** is added, and a **gateway** composes them, so one query returns movie and person data from Neo4j together with a Wikipedia summary, image and page link. ![Federation overview](/diagrams/grandstack-federation/federation-overview.svg) 1. Clients (`grandstack-web`, agents, curl) send one query to the gateway. 2. The gateway (Hive Gateway) serves the composed supergraph on port 4000 and plans each query. 3. `supergraph.graphql` is composed from the two subgraphs by `npm run compose` and committed. 4. The Neo4j subgraph is `grandstack-server` with `FEDERATION=true`; Movie and Person are keyed on `wikipediaTitle`. 5. The Wikipedia subgraph adds a `wikipedia` field to Movie and Person and calls the public Wikipedia API. 6. `seed/wikipedia-links.cypher` stores each film's and person's Wikipedia page at server start. ```graphql { movies(where: { title: { eq: "Heat" } }) { title released wikipedia { description extract pageUrl thumbnailUrl } } } ``` returns the film from Neo4j and `"1995 crime drama film by Michael Mann"` from Wikipedia. A film or person with no link (for example *Madonna*) gives `wikipedia: null`, with no error. > **Needs internet.** The Wikipedia part calls the live public Wikipedia API. Content, descriptions and rate limits are Wikipedia's, and its text is available under CC BY-SA 4.0 (show the `pageUrl` when you display it). The stack is open, with no auth; see [auth and agents](/docs/grandstack-federation/how-it-works/#auth-and-agents). ## Run it Clone `grandstack`, `grandstack-server`, `grandstack-web` and this repo side by side into one folder, then start the stack from the `grandstack` folder: ```sh mkdir grand-stack && cd grand-stack # any folder name for repo in grandstack grandstack-server grandstack-web grandstack-federation; do git clone https://github.com/grand-stack/$repo; done cd grandstack docker compose -f compose.yaml -f compose.federation.yaml up --build ``` | Open | What | | --- | --- | | http://localhost:4000/graphql | The gateway: the one endpoint for everything (the web app uses it too) | | http://localhost:3000 | `grandstack-web`, unchanged, now talking to the gateway | | http://localhost:4001/graphql | The Neo4j subgraph, for development | | http://localhost:4010/graphql | The Wikipedia subgraph, for development | The first start of the gateway needs a `gateway/supergraph.graphql`; the committed one works as it is. After changing either subgraph's schema, run `npm run compose` (see [composition](/docs/grandstack-federation/composition/)). With the stack up, run `npm ci` and then `npm run smoke` inside the `grandstack-federation` folder. ## Documentation | Read | To learn | | --- | --- | | [How it works](/docs/grandstack-federation/how-it-works/) | The pieces, one federated query step by step, auth and agents | | [The subgraphs](/docs/grandstack-federation/subgraphs/) | The `FEDERATION` setting, the `@key`, the Wikipedia subgraph, caching, errors | | [Linking films and people to Wikipedia](/docs/grandstack-federation/linking/) | The link builder, matching rules, coverage, what is not matched | | [Composition and the gateway](/docs/grandstack-federation/composition/) | `npm run compose`, the supergraph, addresses, when to recompose | | [What we tried](/docs/grandstack-federation/prototype-findings/) | Mesh's OpenAPI handler, version pins, and why the Wikipedia subgraph is hand-written | | [Recipes](/docs/grandstack-federation/recipes/) | Link another entity, add another subgraph | | [Exercise: show Wikipedia in grandstack-web](/docs/grandstack-federation/exercise-show-wikipedia-in-grandstack-web/) | A follow-up task for a developer or coding agent | | [Testing](/docs/grandstack-federation/testing/) | Unit tests, the smoke script | | [Troubleshooting](/docs/grandstack-federation/troubleshooting/) | Errors we hit and their fixes | Working with a coding agent? Start with [AGENTS.md](/docs/grandstack-federation/agents-md/). Last verified: grandstack-federation v0.1.1 with grandstack-server v0.9.0, grandstack-web v0.5.1, Hive Gateway 2.15.2, Mesh compose CLI 1.8.4, `@apollo/subgraph` 2.14.4, Neo4j 2026.10 community, under Podman 6 on macOS. ## Licence Apache License 2.0. See [LICENSE](https://github.com/grand-stack/grandstack-federation/blob/main/LICENSE) and [NOTICE](https://github.com/grand-stack/grandstack-federation/blob/main/NOTICE). The same licence applies to every GRANDstack repository and to [grandstack.ai](https://grandstack.ai). --- # grandstack-federation: How it works URL: https://www.grandstack.ai/docs/grandstack-federation/how-it-works/ ## The pieces | Where | What | | --- | --- | | `grandstack-server` with `FEDERATION=true` | The **Neo4j subgraph**. Same schema and data as before, served with `getSubgraphSchema()` so it also answers `_service` and `_entities` | | `schema/federation.graphql` | Extra schema for that server: the federation `@link`, `@key` on Movie and Person, and the `wikipediaTitle` and `wikidataId` fields | | `seed/wikipedia-links.cypher` | Generated links, run at every server start | | `wikipedia/` | The **Wikipedia subgraph**: adds `wikipedia` to Movie and Person | | `gateway/` | The composed `supergraph.graphql`, `mesh.config.ts` that makes it, and the gateway's config and Dockerfile | | `scripts/` | `resolve-links.ts` (builds the links), `matching.ts` (the matching rules), `smoke.ts` | ## The stack ![Federation overview](/diagrams/grandstack-federation/federation-overview.svg) 1. **Clients** call one endpoint and need to know nothing about subgraphs. 2. **The gateway** reads the supergraph, splits each query into parts for the subgraphs, and merges the answers. 3. **The supergraph** is a file describing both subgraphs' schemas and which subgraph owns which field. 4. **The Neo4j subgraph** owns Movie and Person. Both are *entities* with the key `wikipediaTitle`. 5. **The Wikipedia subgraph** extends the same two types by key, adding one field to each. 6. **The links file** gives nodes their key. ## One query, step by step ![A federated query](/diagrams/grandstack-federation/federated-query.svg) 1. The client sends `{ movies { wikipedia } }`-style queries to the gateway. 2. The gateway asks the Neo4j subgraph for the movies and their `wikipediaTitle`. 3. It sends **only the rows that have a key** to the Wikipedia subgraph as entity representations (`_entities`). A film with no link is skipped, so its `wikipedia` is simply `null`. 4. The Wikipedia subgraph answers from its in-memory cache (one hour), or calls Wikipedia's summary endpoint. 5. It returns the fields, or `null` when Wikipedia has no such page. 6. The gateway returns one merged result. A page that does not exist is just `null`. If Wikipedia is slow, rate limiting or unreachable, the Wikipedia subgraph answers with a GraphQL error (code `WIKIPEDIA_UNAVAILABLE`, checked directly against the subgraph), and the field is `null`. If the Wikipedia subgraph itself is down, the gateway returns `wikipedia: null` with the error `Unexpected error.` (checked by stopping the container). In both cases the Neo4j fields are still returned. ## What federation changes for what you already have - The data model, the seed and the default stack are unchanged. Federation is opt-in (`compose.federation.yaml`). - `grandstack-web` is unchanged: it calls port 4000, which is now the gateway. Its 78-check browser run passes through the gateway. - The Neo4j subgraph moves to port 4001 so the gateway can have 4000. - The supergraph exposes everything the server did, plus `wikipedia`, `wikipediaTitle`, `wikidataId` and `WikipediaSummary`. ## Auth and agents - **Auth is not combined here.** This stack is open, like the default one. Putting `grandstack-auth` in front of a federated graph is a separate task: the gateway would validate tokens and forward the `Authorization` header to the subgraphs. That is not built or tested. - **Agents see both subgraphs.** Point `grandstack-agents` at the gateway (`GRAPHQL_URL=http://localhost:4000/graphql`) and its tools work unchanged: `get_schema` now includes `WikipediaSummary` and the `wikipedia` fields (the summary grew from about 4.6 KB to about 6.4 KB), and `run_query` returned the Wikipedia description for *Heat* in a check. Describe cross-subgraph fields well: an agent can only use what the descriptions tell it, and a field that says "null when not linked" saves it a wrong guess. --- # grandstack-federation: The subgraphs URL: https://www.grandstack.ai/docs/grandstack-federation/subgraphs/ ## The Neo4j subgraph `grandstack-server` becomes a subgraph with one setting: ```sh FEDERATION=true SCHEMA_EXTRA_PATHS=/app/schema/federation.graphql ``` `FEDERATION=true` makes the server call `getSubgraphSchema()` instead of `getSchema()` ([the library's federation guide](https://neo4j.com/docs/graphql/current/integrations/apollo-federation/)). The schema must opt in with a federation `@link`, which is why the extra file is needed: ```graphql extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"]) extend type Movie @key(fields: "wikipediaTitle") { wikipediaTitle: String wikidataId: String } ``` (The real file, `schema/federation.graphql`, has descriptions and does the same for `Person`.) `@key` makes the type an entity: another subgraph can extend it, and the gateway can ask this one for it by key. Checked on the running subgraph: - `{ _service { sdl } }` returns the schema with the `@key` directives. - `_entities(representations: [{ __typename: "Movie", wikipediaTitle: "Inception" }])` returns the film, and `null` for a key that matches nothing. - Everything else (`movies`, mutations, the stored counts) works as before. The key and the Wikidata id are ordinary properties on the nodes. They are writable through the generated mutations, so a film can be linked by hand: ```graphql mutation { updatePeople(where: { name: { eq: "Madonna" } }, update: { wikipediaTitle: { set: "Madonna" } }) { people { name wikipedia { description } } } } ``` (The [recipes](/docs/grandstack-federation/recipes/#link-one-more-film-or-person) show it and how to undo it; the smoke script does the same for a film.) The links file only fills in a link that is not set yet, so a link you set by hand is kept when the server restarts. **Version pin.** `@neo4j/graphql` 7.6.4 brings `@apollo/subgraph`, and version 2.15.1 of it crashes with `doc.definitions is not iterable`. The server pins 2.14.4 with `overrides` in its `package.json`. See [what we tried](/docs/grandstack-federation/prototype-findings/). ## The Wikipedia subgraph `wikipedia/` is a small federated GraphQL server (`@apollo/subgraph` and GraphQL Yoga). Its schema: ```graphql type Movie @key(fields: "wikipediaTitle") { wikipediaTitle: String! wikipedia: WikipediaSummary } type Person @key(fields: "wikipediaTitle") { wikipediaTitle: String! wikipedia: WikipediaSummary } type WikipediaSummary { title description extract thumbnailUrl imageUrl pageUrl wikidataId } type Query { wikipediaSummary(title: String!): WikipediaSummary } ``` The gateway asks it for `Movie` and `Person` by key only; it never sees the other Neo4j fields. `wikipediaSummary(title:)` is a direct lookup for testing, for example `{ wikipediaSummary(title: "Inception (film)") { title pageUrl } }`. How a lookup behaves (`wikipedia/summary.ts`, covered by unit tests with a fake `fetch`): | Case | Result | | --- | --- | | Page found | A `WikipediaSummary` (title, description, extract, thumbnail, image, page URL, Wikidata id) | | HTTP 404 | `null` | | A disambiguation page | `null`: it lists meanings, it is not the article | | Rate limit (429), server error or timeout | A GraphQL error with code `WIKIPEDIA_UNAVAILABLE`; the field is `null`, other fields are unaffected | | Same title again within the cache time | Served from memory (default one hour); concurrent lookups share one request | | Empty title | `null`, no request | ### Settings | Variable | Default | Meaning | | --- | --- | --- | | `PORT` | `4010` | HTTP port | | `WIKIPEDIA_BASE_URL` | `https://en.wikipedia.org/api/rest_v1` | The REST API (change for another language edition, or a stub in tests) | | `WIKIPEDIA_USER_AGENT` | `grandstack-federation/0.1 (GraphQL federation demo)` | Sent with every request. Wikimedia asks clients to identify themselves; set this to something with your contact details if you run it for real | | `TIMEOUT_MS` | `8000` | Per request | | `CACHE_TTL_SECONDS` | `3600` | How long a result (including "no page") is kept | The cache is per process and in memory: it is gone when the container restarts, and each replica has its own. --- # grandstack-federation: What we tried URL: https://www.grandstack.ai/docs/grandstack-federation/prototype-findings/ The project's brief listed two open questions for this phase. These are the findings from the prototype, run on 2026-10-22, in the order we hit them. ## 1. Does `@neo4j/graphql` v7 do federation? Yes. The library's [federation guide](https://neo4j.com/docs/graphql/current/integrations/apollo-federation/) describes `getSubgraphSchema()`, a federation `@link` extension and `@key` on `@node` types, and it works with GraphQL Yoga, not only Apollo Server (the guide's example host). `_service { sdl }` returns the schema and `_entities` resolves `Movie` and `Person` by key; an unknown key gives `null`. **But a dependency breaks it.** `@neo4j/graphql` 7.6.4 depends on `@apollo/subgraph` `^2.2.3`. Version 2.15.1 of that package, which a fresh install picks up, fails at startup with `TypeError: doc.definitions is not iterable`: it wraps the module object the library passes in as if it were a schema document. Version 2.14.4 works. `grandstack-server` pins it with `overrides` in `package.json`. (We tried 2.14.4 only; 2.9.11 was not confirmed.) Re-check when either package updates. The library's guide mentions only `@key` and federation v2.0. We used only those; `@shareable`, `@external` and `@requires` were not tried. ## 2. Can Mesh's OpenAPI handler make the Wikipedia subgraph? Mostly. With `@omnigraph/openapi` 0.112.7 and `@graphql-mesh/compose-cli` 1.8.4: - Wikipedia publishes an OpenAPI 3.0 spec (`/api/rest_v1/?spec`, 46 paths). We trimmed it to the summary path and its schemas. - Mesh generated a subgraph from it: a query field returning a union of the summary and a problem type (so a 404 is a normal result), with the REST call executed inside the gateway. No separate service needed. - A `@resolveTo` field added with `additionalTypeDefs` extended `Movie` with `wikipedia`, keyed on `wikipediaTitle`. - Hive Gateway 2.15.2 served the composed supergraph and a single query returned *Inception* from Neo4j with its Wikipedia extract and image. - A custom `User-Agent` header (Wikimedia asks for one) set through `operationHeaders` was carried into the supergraph's transport settings, and the calls succeeded. **What went wrong: a film with no link.** The generated field's `title` argument is required. With a null key the gateway called Wikipedia anyway and the field failed with `DOWNSTREAM_SERVICE_ERROR` ("Variable $title of non-null type String! must not be null"). Making the argument optional avoided the error, but then a null was sent as the text `null`, and Wikipedia has a page called *Null*: the film got that disambiguation page's description. Wrong data with no error is worse than an error. A new film created through the web app has no link, so this would have happened in normal use. ## 3. The decision A small hand-written subgraph (`wikipedia/`) instead. Federation's own entity mechanism only asks the subgraph about entities that have a key, so a film with no link is skipped and returns `null`, and we control the User-Agent, timeout, caching and errors. Mesh is still used, for composing (`npm run compose`). Hive Gateway serves the result unchanged. If you want the Mesh OpenAPI route anyway, the cost is guarding against null keys yourself, for example by giving every film a default link that Wikipedia answers with a 404. ## 4. Smaller findings - Mesh's `loadGraphQLHTTPSubgraph` takes `endpoint` for the live schema; its `source` option expects a schema document, not a GraphQL endpoint (we got `Syntax Error: Expected Name, found String "errors"` when we passed one). - The supergraph stores the subgraph addresses used while composing; `transportEntries` in the gateway config replaces them at run time. - Wikimedia rate limits its REST summary endpoint for uncached pages (HTTP 429 with a `Retry-After`). The Action API took 20 titles per request, which is why the link builder uses it. --- # grandstack-federation: Linking films and people to Wikipedia URL: https://www.grandstack.ai/docs/grandstack-federation/linking/ Federation needs a key: for each film and person, the title of its English Wikipedia page. Looking titles up at request time is unreliable (we saw *Heat*, *Alien*, *Parasite* and *Gladiator* land on the wrong pages), so the links are built once, by a script, checked by rules, and committed. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-federation`, then `cd grandstack-federation` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ![The link flow](/diagrams/grandstack-federation/link-flow.svg) 1. **The GraphQL API** supplies every film (title, year) and person (name). 2. **`npm run links`** (`scripts/resolve-links.ts`) tries candidate page titles for each: `Heat (1995 film)`, `Heat (film)`, `Heat`; for people `Name`, `Name (actor)`, `Name (actress)`, `Name (director)`, `Name (filmmaker)`. It tries them in rounds, so each round is a few batched requests. 3. **The Wikipedia Action API** answers 20 titles per request (description, intro text, Wikidata id, disambiguation flag), following redirects. (The REST summary endpoint, which the subgraph uses at run time, is rate limited hard for pages it has not cached; one page at a time took an hour. Batching takes about a minute.) 4. **The matching rules** (`scripts/matching.ts`, unit tested with recorded responses) accept a page only if it passes: for a film, a normal page whose title says "(YYYY film)" with the right year, or whose description says it is a film and mentions the release year (give or take one); for a person, a normal page whose description names a screen occupation (actor, actress, director, filmmaker, producer, screenwriter, comedian, cinematographer, stuntman, animator, sound designer; plurals too). Disambiguation pages, missing pages and namesakes with other jobs are rejected. 5. **`seed/wikipedia-links.cypher`** is the committed result: two `UNWIND` statements that set `wikipediaTitle` and `wikidataId`. 6. **The server runs it at every start, after the seed** (`SETUP_CYPHER_PATHS`), so a fresh database gets its links on the first start. It only sets a link that is not set yet (`WHERE m.wikipediaTitle IS NULL`), so restarts are harmless and hand-set links are kept. ## Coverage Run on 2026-10-22 against the 152-film, 464-person sample data: | | Linked | Not linked | | --- | --- | --- | | Films | 152 of 152 | none | | People | 461 of 464 | Carrie Henn, Madonna, John Legend | Why the three are missing: the rules are strict on purpose (a wrong link is worse than none). *Carrie Henn* has no page of her own; Wikipedia redirects her name to the film *Aliens*, which the rules correctly refuse. *Madonna* and *John Legend* have pages, but they are described as singers, an occupation the rules do not accept (adding it would also accept namesakes with other jobs, such as the presenter Chris Evans). Unlinked entries return `wikipedia: null`. To link one, see [recipes](/docs/grandstack-federation/recipes/#link-one-more-film-or-person). Things the rules did on purpose that you may notice: a duo that shares one page links both people to it (*Lana* and *Lilly Wachowski* to *The Wachowskis*, *Anthony* and *Joe Russo* to *Russo brothers*); and two films (*The Pianist*, *Gravity*) are accepted by the page title, "(2002 film)" and "(2013 film)", because their one-line descriptions do not say "film". **Checked by eye:** the cases where the page title differs from ours were reviewed (for example *Se7en* to *Seven (1995 film)*, *Zoe Saldana* to *Zoe Saldaña*, *Chris Evans* to *Chris Evans (actor)*), and 14 random people were checked against their descriptions. That is a sample, not a proof: some links among the other several hundred could still be wrong. Report one and fix it by hand-editing the entry or adding a rule. ## Running it again ```sh npm run links # needs the stack running (the GraphQL API) and internet ``` It reads the films and people from `GRAPHQL_URL` (default `http://localhost:4000/graphql`, the gateway in the federation setup or the plain server in the default one; either works). Responses are cached in `scripts/.cache/` (not committed), so an interrupted run resumes. If Wikipedia answers 429 the script waits as long as the server asks and retries. Set `WIKIPEDIA_USER_AGENT` to identify yourself. Review `git diff seed/wikipedia-links.cypher` before committing: Wikipedia changes, and so can the matches. ## Licence Text and images from Wikipedia are available under CC BY-SA 4.0 and the licences stated on each page. The `WikipediaSummary` description says so and includes `pageUrl`; show a link to the article wherever you display this content. --- # grandstack-federation: Composition and the gateway URL: https://www.grandstack.ai/docs/grandstack-federation/composition/ **Composition** turns the subgraphs' schemas into one file, the *supergraph*, that says what the combined API looks like and which subgraph owns each field. The **gateway** serves that file. > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-federation`, then `cd grandstack-federation` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## Composing With the stack up (the subgraphs must be reachable): ```sh npm run compose # reads the schemas from :4001 and :4010, writes gateway/supergraph.graphql ``` This runs Mesh's compose CLI (`@graphql-mesh/compose-cli`) with `gateway/mesh.config.ts`, which lists the two subgraphs. It detects a federation subgraph by its `_service` field and reads its schema from it. `gateway/supergraph.graphql` is committed, so a fresh clone runs without composing. **Recompose whenever a subgraph's schema changes**: a new field in `wikipedia/schema.ts`, a change to `schema/federation.graphql` or to the server schema. The gateway watches the file and reloads it, but only a rebuilt image (`docker compose ... up --build gateway`) carries a new file into the container. ## Addresses The supergraph stores each subgraph's address. Composing from your machine stores `http://localhost:4001/graphql` and `http://localhost:4010/graphql`, which are wrong inside the compose network. `gateway/gateway.config.ts` replaces them at run time: ```ts transportEntries: { Neo4j: { location: process.env.NEO4J_URL ?? "http://server:4000/graphql" }, Wikipedia: { location: process.env.WIKIPEDIA_URL ?? "http://wikipedia:4010/graphql" }, } ``` To compose against other addresses, set `NEO4J_URL` and `WIKIPEDIA_URL` when running `npm run compose` (they are then stored in the supergraph, so recompose with the defaults before you commit it; see the [recipe](/docs/grandstack-federation/recipes/#try-it-beside-the-running-stack-first)); to run the gateway somewhere else, set the same variables on the gateway. ## The gateway Hive Gateway runs in supergraph mode (`gateway/Dockerfile`, image `ghcr.io/graphql-hive/gateway:2.15.2`) on port 4000 with CORS for `http://localhost:3000` (`CORS_ORIGIN` to change). To run a second gateway on your machine against the published subgraph ports (handy while developing a subgraph): `NEO4J_URL=http://localhost:4001/graphql WIKIPEDIA_URL=http://localhost:4010/graphql npm run gateway`, which listens on port 4300 (`npm run gateway -- --port 4301` for another) and uses the same config. ## Why not Mesh's OpenAPI source for Wikipedia? We built that first and it worked for linked films, but it handled a film with no link badly. See [what we tried](/docs/grandstack-federation/prototype-findings/). --- # grandstack-federation: Recipes URL: https://www.grandstack.ai/docs/grandstack-federation/recipes/ ## Link one more film or person > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-federation`, then `cd grandstack-federation` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). **By hand, for one entry** (works immediately, through the gateway or the subgraph on :4001). Madonna is one of the three people the builder does not link: ```graphql mutation { updatePeople(where: { name: { eq: "Madonna" } }, update: { wikipediaTitle: { set: "Madonna" } }) { people { name wikipedia { description } } } } ``` returns `"American singer and songwriter (born 1958)"`. For a film use `updateMovies(where: { title: { eq: "..." } }, ...)`. Use the page title exactly as Wikipedia shows it (spaces are fine). A hand-set link survives restarts. To remove one again: `mutation { updatePeople(where: { name: { eq: "Madonna" } }, update: { wikipediaTitle: { set: null } }) { people { name wikipediaTitle } } }`. To list what is not linked yet: `{ people(where: { wikipediaTitle: { eq: null } }) { name } }` (the same for `movies`). All films in the sample data are linked; to see an unlinked film, create one (`createMovies`, as the smoke script does), and its `wikipedia` is `null`. The query fields are `movies` and `people` (the server's usual generated ones), and a person's film counts are `actedCount` and `directedCount`; see the server's [schema reference](/docs/grandstack-server/schema-reference/). **For many entries, or to keep it in the repo:** add the entry to `seed/wikipedia-links.cypher` in the same shape as the others, or change `scripts/matching.ts` so the builder accepts it, add a test with a recorded response in `test/fixtures/summaries.json`, and run `npm run links`. **For a new dataset** (say, books): run the stack on it, change `scripts/resolve-links.ts` (the query, the candidates and the match function) and `schema/federation.graphql` (the types and key), and rerun. ## Add a field from Wikipedia's response Wikipedia's summary has more than we expose (for example `timestamp`, `lang`, `coordinates`). To add one, say `lastEdited`: 1. `wikipedia/summary.ts`: add it to `WikipediaSummary` and `RawSummary`, and map it in `fetchOne`. 2. `wikipedia/schema.ts`: add `lastEdited: String` to the `WikipediaSummary` type, with a description. 3. Tests: in `test/summary.test.ts` add it to the sample page (with a distinctive value; Wikipedia's `timestamp` is an ISO 8601 string, so `String` is the right type) and to the expected result; in `test/schema.test.ts` add it to the typed `inception` fixture. Run `npm test` **and** `npm run typecheck`: the second catches the fixture, the first does not. 4. Rebuild and restart the subgraph, then `npm run compose` and rebuild the gateway so the supergraph knows the field (see [composition](/docs/grandstack-federation/composition/)). ### Try it beside the running stack first To test without touching the stack's containers, run your changed subgraph and a gateway on other ports (the three commands were run for this recipe): ```sh PORT=4011 npm run wikipedia # your subgraph; the stack's is on 4010 NEO4J_URL=http://localhost:4001/graphql WIKIPEDIA_URL=http://localhost:4011/graphql npm run compose NEO4J_URL=http://localhost:4001/graphql WIKIPEDIA_URL=http://localhost:4011/graphql npm run gateway -- --port 4301 ``` Then query `localhost:4301`, for example `{ movies(where: { title: { eq: "Heat" } }) { wikipedia { description lastEdited } } }`. A later `--port` overrides the script's 4300. **Before you commit**, recompose with the default addresses (`npm run compose` with no variables, the stack's own subgraphs running): composing with other addresses stores them in `gateway/supergraph.graphql`, which is committed. ## Add another subgraph Any GraphQL server that can extend `Movie` or `Person` by key can join. Outline (not run here): 1. Write the subgraph with `type Movie @key(fields: "wikipediaTitle") { wikipediaTitle: String! yourField: ... }` (as in `wikipedia/schema.ts`), using `@apollo/subgraph` (keep 2.14.4 for the Neo4j one; the other subgraph is yours to choose). 2. Add it to `gateway/mesh.config.ts` and as a service in `compose.federation.yaml`; add its address to `transportEntries` in `gateway/gateway.config.ts`. 3. `npm run compose`, rebuild the gateway. A different key (say `wikidataId`) works the same way: add `@key(fields: "wikidataId")` to the Neo4j subgraph's types as well and use it in the new subgraph. --- # grandstack-federation: Exercise: show Wikipedia in grandstack-web URL: https://www.grandstack.ai/docs/grandstack-federation/exercise-show-wikipedia-in-grandstack-web/ A follow-up task for a developer or a coding agent. `grandstack-web` is deliberately unchanged by this repo; with the federation stack running it already talks to the gateway, so it can ask for the new fields. The task is to show them. ## The goal On a film's page and a person's page, show the Wikipedia **description**, the first paragraph (**extract**) and a **link to the article**, with a "Source: Wikipedia (CC BY-SA 4.0)" credit. When there is no Wikipedia data (`wikipedia: null`), show nothing, with no error message. The image is optional: the web app already draws generated posters and avatars. ## What you need - The federation stack running: `docker compose -f compose.yaml -f compose.federation.yaml up --build` in `grandstack`. - The query to add. It was run against the gateway: ```graphql { movies(where: { title: { eq: "Heat" } }) { title released wikipedia { description extract pageUrl thumbnailUrl } } } ``` It returns `"1995 crime drama film by Michael Mann"` and the article text for *Heat*. The same `wikipedia { ... }` selection works on `people`. ## Steps 1. Read `grandstack-web/docs/customising.md` and `docs/recipes.md`, then find the movie page and person page queries (under `src/`, with their `.graphql` documents) and the codegen setup. 2. Add the `wikipedia { description extract pageUrl }` selection to both queries. Run codegen against the running gateway (`GRAPHQL_URL=http://localhost:4000/graphql`, as the web docs describe) so the generated types include it. 3. Render it in the pages, handling `null`. Keep the layout working at phone width (the web app's browser checks test for horizontal overflow). 4. Check it: open a linked film (*Inception*), a linked person (*Christopher Nolan*) and an unlinked person (*Madonna*) in a browser, and run the web app's `npm run e2e`. ## Notes - Wikipedia is a live dependency: expect `null` plus an error if it is slow or rate limiting. The Neo4j fields still arrive. Decide how the page should behave, and test it by stopping the `wikipedia` container. - Without the federation stack (the plain `docker compose up`), the server has no `wikipedia` field and the new query fails validation. A real change to the web app would need to decide whether it requires federation, or tolerate both. - **No reference solution is shipped** for this exercise, unlike the auth exercise. The query above is verified; the web changes are yours. --- # grandstack-federation: Testing URL: https://www.grandstack.ai/docs/grandstack-federation/testing/ | Command | Needs | What it proves | | --- | --- | --- | | `npm test` | nothing (no network, no Docker) | 22 unit tests, below | | `npm run typecheck` | nothing | TypeScript compiles | | `npm run smoke` | the federation stack running, and internet | 11 checks through the gateway and the subgraphs | | `npm run docs:check` | nothing | links, anchors, image alt text, SVG safety, diagram legends | | `npm run links` | the stack running, and internet | rebuilds `seed/wikipedia-links.cypher` (not a test; see [linking](/docs/grandstack-federation/linking/)) | > **Where to run this.** Commands on this page that start with `npm`, `node` or `docker build` run inside a clone of this repository: `git clone https://github.com/grand-stack/grandstack-federation`, then `cd grandstack-federation` (Node 22 or later; run `npm ci` once). Commands that need the running stack use the `grandstack` repository's compose files: see the [quickstart](https://grandstack.ai/quickstart/). ## Unit tests (`npm test`) - `matching.test.ts`: the link-matching rules against recorded Wikipedia summaries (`test/fixtures/summaries.json`): right page, a disambiguation page, a namesake in another job, a film of the same name from another year, a film accepted by its "(YYYY film)" title, a duo's shared page, a redirect to a film. - `summary.test.ts`: the subgraph's lookup with a fake `fetch`: mapping, the `User-Agent`, title encoding, 404 and disambiguation give `null`, 429 and network failure give an error, the cache and shared in-flight requests, an empty title. - `schema.test.ts`: the subgraph schema through Yoga in memory: `_service`, entity resolution by key (including an unknown key), the direct lookup. - `config.test.ts`: defaults and validation. ## The smoke script (`npm run smoke`) Against the running stack: a film returns Neo4j fields and its Wikipedia summary in one query, with the Wikidata id; a person likewise; an unlinked person (*Madonna*) gives `null` and no error; a Neo4j-only query works through the gateway; a list of eight films resolves in one request; a mutation through the gateway creates a throwaway film (`ZZ Federation Test`), which starts unlinked, is linked by setting `wikipediaTitle`, and is removed again; the Neo4j subgraph publishes `@key` on Movie and Person; the Wikipedia subgraph answers directly and gives `null` for a page that does not exist. Also run when this phase was built: `grandstack-web`'s browser checks (`npm run e2e`) through the gateway, with no problems reported, and `grandstack-agents`' tools against the gateway (`get_schema` shows `WikipediaSummary`; `run_query` returns a Wikipedia description). ## What is not tested Auth with federation, Wikipedia rate limiting or outages through the gateway under load (the error paths are unit tested and checked once by hand), and a Wikipedia that changes its pages: the smoke script expects *Inception*'s page to say "2010 film". --- # grandstack-federation: Troubleshooting URL: https://www.grandstack.ai/docs/grandstack-federation/troubleshooting/ | Symptom | Cause and fix | | --- | --- | | `doc.definitions is not iterable` when the server starts with `FEDERATION=true` | `@apollo/subgraph` 2.15.1 with `@neo4j/graphql` 7.6.4. `grandstack-server` pins 2.14.4 (`overrides`); keep it. See [what we tried](/docs/grandstack-federation/prototype-findings/) | | `typeDefs must contain \`@link\` schema extension...` | The server has `FEDERATION=true` but not `schema/federation.graphql`. Set `SCHEMA_EXTRA_PATHS` as in `compose.federation.yaml` | | The gateway container fails to build: `COPY supergraph.graphql ... no such file` | `gateway/supergraph.graphql` is missing. Restore it from git, or compose it (`npm run compose` with the subgraphs running) | | `npm run compose` fails with `Syntax Error: Expected Name, found String "errors"` | A subgraph was given as `source` instead of `endpoint` in `gateway/mesh.config.ts`. Use `endpoint` | | `npm run compose` cannot reach a subgraph | Start the stack first. The subgraphs must answer on `localhost:4001` and `localhost:4010`, or set `NEO4J_URL` and `WIKIPEDIA_URL` | | Subgraph calls fail inside the compose network and the error mentions `localhost` | A supergraph composed on your machine holds localhost addresses, which only work from your machine. `gateway.config.ts` replaces them with the service names, so check that it is in the gateway image (rebuild it). Not seen in practice; this is the expected cause | | A new field is missing at the gateway | The supergraph is out of date. `npm run compose`, then rebuild the gateway image | | `wikipedia` is `null` for a film or person | It is not linked (`{ wikipediaTitle }` is null), or Wikipedia has no such page. Link it by hand ([recipes](/docs/grandstack-federation/recipes/)) | | `wikipedia` is `null` with `WIKIPEDIA_UNAVAILABLE` | The Wikipedia API answered with an error or timed out (often a rate limit, HTTP 429). Retry later; results are cached for an hour. Set `WIKIPEDIA_USER_AGENT` to identify yourself | | `wikipedia` is `null` with `Unexpected error.` | The Wikipedia subgraph is down. `docker compose ... logs wikipedia`; its health is `http://localhost:4010/healthz` | | `npm run links` waits with `HTTP 429` | Wikipedia is rate limiting. The script waits as long as the server asks and retries; it keeps a cache in `scripts/.cache/`, so run it again if it gives up | | Port 4000 is taken | The default stack, the auth stack or the agents setup is still running. Stop it first (`docker compose ... down`); the federation setup replaces them, it does not run beside them | | `docker` commands fail on macOS | `docker` may be Podman: `podman machine start`, and set `DOCKER_HOST` to the socket it prints | --- # grandstack-federation: AGENTS.md: guide for coding agents URL: https://www.grandstack.ai/docs/grandstack-federation/agents-md/ Federation for GRANDstack: the Neo4j subgraph (`grandstack-server` with `FEDERATION=true`), a Wikipedia subgraph (`wikipedia/`), and a Hive Gateway serving the composed supergraph. Open stack, no auth. ## Commands ```sh npm ci npm test # unit tests (22 today), no network npm run typecheck npm run docs:check # links, anchors, alt text, SVGs, diagram legends npm run wikipedia # the Wikipedia subgraph on :4010 (PORT to change) npm run gateway # a local gateway on :4300 (see docs/composition.md) npm run compose # recompose gateway/supergraph.graphql (subgraphs must be running) npm run smoke # needs the federation stack running and internet npm run links # rebuild seed/wikipedia-links.cypher (stack and internet) ``` The stack: `docker compose -f compose.yaml -f compose.federation.yaml up --build` in `grandstack`. Ports: gateway 4000, Neo4j subgraph 4001, Wikipedia subgraph 4010. ## Where things are - `wikipedia/` the subgraph: `summary.ts` (lookup, cache, errors), `schema.ts` (SDL and resolvers), `config.ts`, `index.ts`. - `schema/federation.graphql` extra schema mounted into the server; `seed/wikipedia-links.cypher` is GENERATED (do not edit; use `npm run links` or the recipes). - `scripts/matching.ts` the rules for accepting a Wikipedia page; `scripts/resolve-links.ts` the builder; `scripts/smoke.ts`. - `gateway/` `mesh.config.ts` (composition), `gateway.config.ts` (addresses, CORS), `supergraph.graphql` (GENERATED, committed), `Dockerfile`. ## Rules - Recompose (`npm run compose`) and rebuild the gateway whenever a subgraph schema changes; commit the new supergraph. - Keep `@apollo/subgraph` at 2.14.4 (the server pins it too); 2.15.1 breaks `@neo4j/graphql` 7.6.4. - A wrong Wikipedia link is worse than none: do not loosen `matching.ts` without a test with a recorded response. - Wikipedia is live and rate limited; unit tests must not call it (use a fake `fetch`). - Do not claim in docs what you did not run. Auth with federation is not built or tested; keep saying so. - Keep the diagram legends and coverage numbers in the docs true when the data or links change. - New code files need the copyright header (two lines: `Copyright (c) "Neo4j"` and `Neo4j Sweden AB [http://neo4j.com]`, in the file's comment syntax). Run `node ../grandstack/scripts/add-headers.mjs` (or `node ../.github/scripts/headers.mjs --root .` for one repository) to add it; `--check` lists what is missing. Generated files are exempt. - Releases use the **release-cut** workflow (Actions tab), which sets the version in every `package.json`, tags and publishes. Do not bump versions or push tags by hand; see `grandstack/docs/releasing.md`.