From 1189f25e992b8caf3f80301bc43d2b26187dd333 Mon Sep 17 00:00:00 2001 From: Ronja Date: Sat, 17 Oct 2026 10:33:52 +0200 Subject: [PATCH 1/6] feat: set up Apollo Client in the frontend The app is wrapped in ApolloProvider. The cache keeps all the pages of one search in one list and merges 'load more' by cursor. The authorization link adds the access token, and refreshes the session once and repeats the request when the token has expired. Refreshing is done from one place at a time, also between tabs. describeError turns API and network errors into messages. Vite sends /graphql and /images to the backend in development. --- README.md | 38 +++++ docs/AI_USAGE.md | 15 ++ docs/API.md | 9 ++ package-lock.json | 145 ++++++++++++++++- package.json | 5 +- src/lib/apollo/api.ts | 93 +++++++++++ src/lib/apollo/authLink.test.ts | 169 ++++++++++++++++++++ src/lib/apollo/authLink.ts | 52 ++++++ src/lib/apollo/cache.test.ts | 269 ++++++++++++++++++++++++++++++++ src/lib/apollo/cache.ts | 49 ++++++ src/lib/apollo/client.ts | 87 +++++++++++ src/lib/apollo/errors.test.ts | 89 +++++++++++ src/lib/apollo/errors.ts | 94 +++++++++++ src/lib/apollo/session.test.ts | 167 ++++++++++++++++++++ src/lib/apollo/session.ts | 113 ++++++++++++++ src/main.tsx | 18 ++- src/vite-env.d.ts | 6 + vite.config.ts | 9 ++ 18 files changed, 1419 insertions(+), 8 deletions(-) create mode 100644 src/lib/apollo/api.ts create mode 100644 src/lib/apollo/authLink.test.ts create mode 100644 src/lib/apollo/authLink.ts create mode 100644 src/lib/apollo/cache.test.ts create mode 100644 src/lib/apollo/cache.ts create mode 100644 src/lib/apollo/client.ts create mode 100644 src/lib/apollo/errors.test.ts create mode 100644 src/lib/apollo/errors.ts create mode 100644 src/lib/apollo/session.test.ts create mode 100644 src/lib/apollo/session.ts create mode 100644 src/vite-env.d.ts diff --git a/README.md b/README.md index 7ada51f..f0b4774 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,44 @@ Other commands: `npm ci` installs both the frontend and the backend, because the backend is an npm workspace. The database commands are described in the next section. +### Calling the API from the frontend + +The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 3001) and the frontend (`npm run dev`). In development Vite sends `/graphql` and `/images` to the backend, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; the default is `/graphql`. + +```tsx +import { gql } from '@apollo/client' +import { useQuery } from '@apollo/client/react' + +const SEARCH = gql` + query Search($search: String, $first: Int, $after: String) { + searchCatalog(search: $search, first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + description + } + } + } + } +` + +const { data, fetchMore } = useQuery(SEARCH, { + variables: { search, first: 20 }, +}) +// "Load more": the next page is added to the list in the cache. +fetchMore({ variables: { after: data.searchCatalog.pageInfo.endCursor } }) +``` + +- **Load more with `fetchMore`.** The cache keeps all the pages of one search in one list (the key is the search, filter and sort, not `first` or `after`). A query with `after` that is sent with `useQuery` or `client.query` is answered from the cache. `fetchMore` always asks the server, so use it for the next page. +- **`totalCount` only with the first page** (`totalCount @include(if: $firstPage)`): the server counts all matches only when the query asks for it, and the cache keeps the count when later pages do not have it. +- **The login.** `login(googleToken)`, `logout()` and `restoreSession()` are in `src/lib/apollo/client.ts`. The access token is kept in memory, the refresh token in `localStorage`. A request that answers `UNAUTHENTICATED` because the access token has expired refreshes the session once and is repeated. Refreshing is done from one place at a time (`navigator.locks`), as the server requires. How it works and why is in "Identifying the user" in [docs/API.md](docs/API.md). The Google login button itself is not built yet. +- **Errors.** `describeError(error)` (`src/lib/apollo/errors.ts`) turns an error from the API or the network into a message for the user, and says whether trying again can help. + ## Database (local setup) The backend uses PostgreSQL 18. Everyone installs it directly on their own machine, without Docker, and everyone uses version 18 so that migrations behave the same for all of us. diff --git a/docs/AI_USAGE.md b/docs/AI_USAGE.md index 49e8d28..589c086 100644 --- a/docs/AI_USAGE.md +++ b/docs/AI_USAGE.md @@ -184,3 +184,18 @@ keyset condition into separate queries. They also moved the variants hint to its own issue. Claude made the changes, checked the query plans with `EXPLAIN ANALYZE`, added tests for each point and updated the docs. + +## Apollo Client in the frontend (`src/lib/apollo/`) + +- **Tool:** Claude Code +- **What the team did:** Wrote issue #75: set up Apollo Client so the pages can use the + API, with a cache for the search, the token handling from the login issue and one + place for error messages, without changing the pages. The team decided that the + refresh token is returned in the response and kept in `localStorage`. +- **What Claude did:** Wrote the client, the cache policy for `searchCatalog`, the + authorization link (one refresh and one repeat on an expired token, one refresh at a + time between tabs), the session, the error messages, the Vite proxy and the tests. + Checked it against the running backend: the first page, "load more" and the errors. + That check showed that a plain query with `after` is answered from the cache because + `after` is not part of the key, so the pages must be loaded with `fetchMore`. This is + now in the README and `docs/API.md`. diff --git a/docs/API.md b/docs/API.md index 3013d85..8cc3f6a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -225,6 +225,15 @@ These are the choices for the implementation. They are proposals until the backe - **Schema first.** We write `schema.graphql` by hand and the resolvers follow it. The file is easy to read for someone who reviews the project, and the schema is the contract between the frontend and the backend. The alternative, generating the schema from TypeScript code (code first), hides the contract in the code. - **GraphQL Code Generator** makes the TypeScript types for the resolvers (backend) and for the queries (frontend) from the same schema file. A change in the schema then shows up as a type error in both places. +### The client in the frontend + +Apollo Client is set up in `src/lib/apollo/` (issue #75). See "Calling the API from the frontend" in the README for how to use it. + +- **`cache.ts`.** `searchCatalog` is a Relay connection. The key of the field is the search, filter and sort, so all the pages of one search are one list. The merge function puts a page with `after` after the pages already there, skips a page that arrives twice (by cursor), starts over when the first page (no `after`) arrives again, and keeps `totalCount` when a later page does not have it. `possibleTypes` lets a fragment on `CatalogItem` match `Element` and `Vault`. Elements and vaults are stored once by `__typename` and `id`, also when they are in several lists. Because `after` is not in the key, a plain query with `after` is a cache hit; `fetchMore` is the way to load the next page. +- **`authLink.ts`, `session.ts`.** Adds `Authorization: Bearer `, and on `UNAUTHENTICATED` refreshes the session once and repeats the request once. The login operations (`Login`, `RefreshSession`, `Logout`) never start a refresh. Parts of the page that refresh at the same moment share one request, and tabs follow each other through `navigator.locks`, with the refresh token read inside the lock. A refresh token that the server refuses ends the login; a network error keeps it for a later try. The access token is kept in memory, and the refresh token in `localStorage` (see the decision in "Identifying the user"). +- **`api.ts`.** The `login`, `refreshSession` and `logout` calls are plain `fetch` requests, not Apollo operations, because the refresh is called by the link itself. +- **`errors.ts`.** One place for the messages of the error codes. + ### Search without raw SQL **The backend never uses raw SQL.** No `$queryRaw`, `$executeRaw`, their `Unsafe` versions or `$queryRawTyped` (Prisma's TypedSQL). Every query goes through Prisma Client, which sends all values as parameters. This removes SQL injection by the way the code is built, and not by careful escaping. A lint rule (`no-restricted-properties` on those names, in `eslint.config.js`) makes the build fail if someone adds a raw query anywhere in the backend, including the tests. Indexes are declared in `schema.prisma` with `@@index`, and the migrations are generated by Prisma, with a few additions written by hand in SQL: CHECK constraints and the collation of `catalog_words.word`, which Prisma cannot express. These are migrations, not queries in the backend, so they do not break the rule. diff --git a/package-lock.json b/package-lock.json index 4f8d90f..fda29c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,15 @@ "backend" ], "dependencies": { + "@apollo/client": "^4.3.4", "@base-ui/react": "^1.9.0", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", + "graphql": "^16.14.2", "lucide-react": "^1.58.0", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "rxjs": "^7.8.2" }, "devDependencies": { "@babel/core": "^7.29.7", @@ -64,6 +67,43 @@ "node": ">=24" } }, + "node_modules/@apollo/client": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.4.tgz", + "integrity": "sha512-knXw5+GtT5zjrpqxG2Pnor34Xmjd5qcpI34slI8GKV88r1vc5LPCve6ImfcpRsBIy1aHBjTRFTHIt2YRDcxfHA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1456,6 +1496,15 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@hono/node-server": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", @@ -3788,6 +3837,54 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -6012,6 +6109,30 @@ "devOptional": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7307,6 +7428,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8330,6 +8463,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-regex2": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", @@ -8993,7 +9135,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { diff --git a/package.json b/package.json index 3999596..24f9a60 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,15 @@ "db:studio": "npm run prisma:studio -w backend" }, "dependencies": { + "@apollo/client": "^4.3.4", "@base-ui/react": "^1.9.0", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", + "graphql": "^16.14.2", "lucide-react": "^1.58.0", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "rxjs": "^7.8.2" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/src/lib/apollo/api.ts b/src/lib/apollo/api.ts new file mode 100644 index 0000000..820ed54 --- /dev/null +++ b/src/lib/apollo/api.ts @@ -0,0 +1,93 @@ +// Plain requests to the API for the login, the refresh and the logout. They do +// not go through Apollo Client: the authorization link calls the refresh, so +// the refresh itself must not run through the link. + +export type AuthPayload = { + accessToken: string + refreshToken: string + coach: { id: string; name: string; email: string } +} + +/** The server answered with an error. `code` is `extensions.code` (see docs/API.md). */ +export class ApiError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.name = 'ApiError' + this.code = code + } +} + +type Fetch = typeof fetch + +async function call( + url: string, + query: string, + variables: Record, + fetchFn: Fetch, +): Promise { + const response = await fetchFn(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }) + if (!response.ok) { + throw new ApiError( + 'NETWORK_ERROR', + `The server answered ${response.status}.`, + ) + } + const body = (await response.json()) as { + data?: T + errors?: { message: string; extensions?: { code?: string } }[] + } + const first = body.errors?.[0] + if (first) { + throw new ApiError(first.extensions?.code ?? 'UNKNOWN', first.message) + } + return body.data as T +} + +const PAYLOAD = 'accessToken refreshToken coach { id name email }' + +export async function loginRequest( + url: string, + googleToken: string, + fetchFn: Fetch = fetch, +): Promise { + const data = await call<{ login: AuthPayload }>( + url, + `mutation Login($googleToken: String!) { login(googleToken: $googleToken) { ${PAYLOAD} } }`, + { googleToken }, + fetchFn, + ) + return data.login +} + +export async function refreshRequest( + url: string, + refreshToken: string, + fetchFn: Fetch = fetch, +): Promise { + const data = await call<{ refreshSession: AuthPayload }>( + url, + `mutation RefreshSession($refreshToken: String!) { refreshSession(refreshToken: $refreshToken) { ${PAYLOAD} } }`, + { refreshToken }, + fetchFn, + ) + return data.refreshSession +} + +export async function logoutRequest( + url: string, + refreshToken: string, + fetchFn: Fetch = fetch, +): Promise { + await call<{ logout: boolean }>( + url, + 'mutation Logout($refreshToken: String!) { logout(refreshToken: $refreshToken) }', + { refreshToken }, + fetchFn, + ) +} diff --git a/src/lib/apollo/authLink.test.ts b/src/lib/apollo/authLink.test.ts new file mode 100644 index 0000000..9d65ab1 --- /dev/null +++ b/src/lib/apollo/authLink.test.ts @@ -0,0 +1,169 @@ +import { ApolloLink } from '@apollo/client/link' +import { gql } from '@apollo/client' +import { firstValueFrom, of } from 'rxjs' +import { type Mock, describe, expect, it, vi } from 'vitest' +import type { AuthPayload } from './api' +import { createAuthLink } from './authLink' +import { createSession, type TokenStorage } from './session' + +const ME = gql` + query Me { + me { + id + } + } +` +const LOGIN = gql` + mutation Login($googleToken: String!) { + login(googleToken: $googleToken) { + accessToken + } + } +` + +const unauthenticated = { + data: null, + errors: [ + { + message: 'You must be logged in.', + extensions: { code: 'UNAUTHENTICATED' }, + }, + ], +} +const ok = { data: { me: { id: 'c1' } } } + +const payload = (n: number): AuthPayload => ({ + accessToken: `access-${n}`, + refreshToken: `refresh-${n}`, + coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' }, +}) + +function memoryStorage(): TokenStorage { + const values = new Map() + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + removeItem: (key) => void values.delete(key), + } +} + +/** + * A server that only accepts the access token "access-2", and records the + * Authorization header of every request. + */ +type RefreshRequest = Mock<(refreshToken: string) => Promise> + +function setup(options: { refreshRequest?: RefreshRequest } = {}) { + const seen: (string | undefined)[] = [] + const server = new ApolloLink((operation) => { + const headers = operation.getContext()['headers'] as + Record | undefined + seen.push(headers?.['authorization']) + return of( + headers?.['authorization'] === 'Bearer access-2' ? ok : unauthenticated, + ) + }) + const refreshRequest = + options.refreshRequest ?? + vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(payload(2)) + const session = createSession({ storage: memoryStorage(), refreshRequest }) + const link = ApolloLink.from([createAuthLink(session), server]) + const run = (query = ME) => + firstValueFrom(ApolloLink.execute(link, { query }, { client: {} as never })) + return { session, refreshRequest, seen, run } +} + +describe('the authorization link', () => { + it('adds the access token to the request, and nothing when there is none', async () => { + const { session, seen, run } = setup() + await run() + session.setTokens(payload(2)) + await run() + expect(seen).toEqual([undefined, 'Bearer access-2']) + }) + + it('refreshes once and repeats the request when the access token has expired', async () => { + const { session, refreshRequest, seen, run } = setup() + session.setTokens(payload(1)) // access-1 is expired for this server + + const result = await run() + expect(result).toEqual(ok) + expect(refreshRequest).toHaveBeenCalledTimes(1) + expect(refreshRequest).toHaveBeenCalledWith('refresh-1') + expect(seen).toEqual(['Bearer access-1', 'Bearer access-2']) + }) + + it('refreshes once when several requests expire at the same moment', async () => { + const { session, refreshRequest, run } = setup() + session.setTokens(payload(1)) + + const results = await Promise.all([run(), run(), run()]) + expect(results).toEqual([ok, ok, ok]) + expect(refreshRequest).toHaveBeenCalledTimes(1) + }) + + it('passes on the error, and forgets the login, when the refresh token is refused', async () => { + const { ApiError } = await import('./api') + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockRejectedValue(new ApiError('UNAUTHENTICATED', 'Log in again.')) + const { session, seen, run } = setup({ refreshRequest }) + session.setTokens(payload(1)) + + expect(await run()).toEqual(unauthenticated) + expect(seen).toHaveLength(1) // not repeated + expect(session.hasRefreshToken()).toBe(false) + }) + + it('does not try more than once', async () => { + // The refresh gives a token that the server also refuses. + const { session, refreshRequest, seen, run } = setup({ + refreshRequest: vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(payload(3)), + }) + session.setTokens(payload(1)) + + expect(await run()).toEqual(unauthenticated) + expect(refreshRequest).toHaveBeenCalledTimes(1) + expect(seen).toEqual(['Bearer access-1', 'Bearer access-3']) + }) + + it('does not refresh without a refresh token', async () => { + const { refreshRequest, seen, run } = setup() + expect(await run()).toEqual(unauthenticated) + expect(refreshRequest).not.toHaveBeenCalled() + expect(seen).toHaveLength(1) + }) + + it('does not refresh for the login operations themselves', async () => { + const { session, refreshRequest, run } = setup() + session.setTokens(payload(1)) + await run(LOGIN) + expect(refreshRequest).not.toHaveBeenCalled() + }) + + it('leaves other errors alone', async () => { + const error = { + data: null, + errors: [ + { message: 'Bad input', extensions: { code: 'BAD_USER_INPUT' } }, + ], + } + const session = createSession({ + storage: memoryStorage(), + refreshRequest: vi.fn(), + }) + session.setTokens(payload(1)) + const link = ApolloLink.from([ + createAuthLink(session), + new ApolloLink(() => of(error)), + ]) + const result = await firstValueFrom( + ApolloLink.execute(link, { query: ME }, { client: {} as never }), + ) + expect(result).toEqual(error) + }) +}) diff --git a/src/lib/apollo/authLink.ts b/src/lib/apollo/authLink.ts new file mode 100644 index 0000000..6cdadfb --- /dev/null +++ b/src/lib/apollo/authLink.ts @@ -0,0 +1,52 @@ +// The authorization link: it adds `Authorization: Bearer ` to +// every request. When the server answers UNAUTHENTICATED because the access +// token has expired, it refreshes the session once and repeats the request. +// If the refresh fails, the answer of the first try is passed on, and the page +// shows that the coach must log in. + +import { ApolloLink } from '@apollo/client/link' +import { from, mergeMap, of } from 'rxjs' +import type { Session } from './session' + +/** The operations of the login itself must never start a refresh. */ +const AUTH_OPERATIONS = new Set(['Login', 'RefreshSession', 'Logout']) + +const isUnauthenticated = (result: ApolloLink.Result): boolean => + 'errors' in result && + (result.errors ?? []).some( + (error) => error.extensions?.['code'] === 'UNAUTHENTICATED', + ) + +export function createAuthLink(session: Session): ApolloLink { + return new ApolloLink((operation, forward) => { + const addToken = () => { + const token = session.getAccessToken() + if (!token) return + operation.setContext( + ({ headers }: { headers?: Record }) => ({ + headers: { ...headers, authorization: `Bearer ${token}` }, + }), + ) + } + addToken() + + return forward(operation).pipe( + mergeMap((result) => { + if ( + !isUnauthenticated(result) || + AUTH_OPERATIONS.has(operation.operationName ?? '') || + !session.hasRefreshToken() + ) { + return of(result) + } + return from(session.refresh()).pipe( + mergeMap((refreshed) => { + if (!refreshed) return of(result) + addToken() + return forward(operation) // the second try is not checked again + }), + ) + }), + ) + }) +} diff --git a/src/lib/apollo/cache.test.ts b/src/lib/apollo/cache.test.ts new file mode 100644 index 0000000..b979b79 --- /dev/null +++ b/src/lib/apollo/cache.test.ts @@ -0,0 +1,269 @@ +import { gql } from '@apollo/client' +import { describe, expect, it } from 'vitest' +import { createCache } from './cache' + +const SEARCH = gql` + query Search( + $search: String + $filter: CatalogFilter + $first: Int + $after: String + ) { + searchCatalog( + search: $search + filter: $filter + first: $first + after: $after + ) { + totalCount + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + __typename + id + description + } + } + } + } +` +// The same query without totalCount: it is only asked for with the first page. +const SEARCH_NEXT = gql` + query Search( + $search: String + $filter: CatalogFilter + $first: Int + $after: String + ) { + searchCatalog( + search: $search + filter: $filter + first: $first + after: $after + ) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + __typename + id + description + } + } + } + } +` + +const edge = (n: number, type: 'Element' | 'Vault' = 'Element') => ({ + __typename: 'CatalogEdge', + cursor: `c${n}`, + node: { __typename: type, id: `UB-1.${n}-a`, description: `Element ${n}` }, +}) +const connection = ( + edges: ReturnType[], + hasNextPage: boolean, + totalCount?: number, +) => ({ + searchCatalog: { + __typename: 'CatalogConnection', + ...(totalCount === undefined ? {} : { totalCount }), + pageInfo: { + __typename: 'PageInfo', + hasNextPage, + endCursor: edges.at(-1)!.cursor, + }, + edges, + }, +}) + +type Read = { + searchCatalog: { + totalCount: number + edges: { cursor: string }[] + pageInfo: { hasNextPage: boolean; endCursor: string } + } +} | null + +describe('the cache for searchCatalog', () => { + it('puts the next page after the first, so "load more" gives one list', () => { + const cache = createCache() + const variables = { search: 'kip', first: 2 } + cache.writeQuery({ + query: SEARCH, + variables, + data: connection([edge(1), edge(2)], true, 5), + }) + cache.writeQuery({ + query: SEARCH_NEXT, + variables: { ...variables, after: 'c2' }, + data: connection([edge(3), edge(4)], true), + }) + cache.writeQuery({ + query: SEARCH_NEXT, + variables: { ...variables, after: 'c4' }, + data: connection([edge(5)], false), + }) + + const read = cache.readQuery({ query: SEARCH, variables })! + expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual([ + 'c1', + 'c2', + 'c3', + 'c4', + 'c5', + ]) + expect(read.searchCatalog.pageInfo).toEqual({ + __typename: 'PageInfo', + hasNextPage: false, + endCursor: 'c5', + }) + expect(read.searchCatalog.totalCount).toBe(5) // from the first page + }) + + it('adds nothing when the same page arrives twice', () => { + const cache = createCache() + const variables = { search: 'kip', first: 2 } + cache.writeQuery({ + query: SEARCH, + variables, + data: connection([edge(1), edge(2)], true, 4), + }) + const next = { + query: SEARCH_NEXT, + variables: { ...variables, after: 'c2' }, + data: connection([edge(3), edge(4)], false), + } + cache.writeQuery(next) + cache.writeQuery(next) + + const read = cache.readQuery({ query: SEARCH, variables })! + expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual([ + 'c1', + 'c2', + 'c3', + 'c4', + ]) + }) + + it('starts over when the first page of the same search arrives again', () => { + const cache = createCache() + const variables = { search: 'kip', first: 2 } + cache.writeQuery({ + query: SEARCH, + variables, + data: connection([edge(1), edge(2)], true, 4), + }) + cache.writeQuery({ + query: SEARCH_NEXT, + variables: { ...variables, after: 'c2' }, + data: connection([edge(3), edge(4)], false), + }) + // A reload of the first page (the data may have changed). + cache.writeQuery({ + query: SEARCH, + variables, + data: connection([edge(1), edge(2)], true, 9), + }) + + const read = cache.readQuery({ query: SEARCH, variables })! + expect(read.searchCatalog.edges.map((e) => e.cursor)).toEqual(['c1', 'c2']) + expect(read.searchCatalog.totalCount).toBe(9) + }) + + it('keeps a list for each search, filter and sort', () => { + const cache = createCache() + cache.writeQuery({ + query: SEARCH, + variables: { search: 'kip', first: 2 }, + data: connection([edge(1), edge(2)], false, 2), + }) + cache.writeQuery({ + query: SEARCH, + variables: { search: 'hang', first: 2 }, + data: connection([edge(7)], false, 1), + }) + cache.writeQuery({ + query: SEARCH, + variables: { search: 'kip', filter: { apparatusId: 'UB' }, first: 2 }, + data: connection([edge(9)], false, 1), + }) + + const cursors = (variables: object) => + cache + .readQuery({ query: SEARCH, variables })! + .searchCatalog.edges.map((e) => e.cursor) + expect(cursors({ search: 'kip', first: 2 })).toEqual(['c1', 'c2']) + expect(cursors({ search: 'hang', first: 2 })).toEqual(['c7']) + expect( + cursors({ search: 'kip', filter: { apparatusId: 'UB' }, first: 2 }), + ).toEqual(['c9']) + // The page size is not part of the search. + expect(cursors({ search: 'kip', first: 50 })).toEqual(['c1', 'c2']) + }) + + it('stores an element once, also when it is in two lists', () => { + const cache = createCache() + cache.writeQuery({ + query: SEARCH, + variables: { search: 'a', first: 2 }, + data: connection([edge(1), edge(2)], false, 2), + }) + cache.writeQuery({ + query: SEARCH, + variables: { search: 'b', first: 2 }, + data: connection([edge(2)], false, 1), + }) + + const elements = Object.keys(cache.extract()).filter((key) => + key.startsWith('Element:'), + ) + expect(elements.sort()).toEqual(['Element:UB-1.1-a', 'Element:UB-1.2-a']) + }) + + it('lets a fragment on the CatalogItem interface match an element and a vault', () => { + const cache = createCache() + const query = gql` + query Items { + catalogItems @client { + ...ItemFields + } + } + fragment ItemFields on CatalogItem { + id + description + } + ` + void query + cache.writeFragment({ + id: 'Vault:VT-4.1-a', + fragment: gql` + fragment V on Vault { + id + description + } + `, + data: { __typename: 'Vault', id: 'VT-4.1-a', description: 'Handspring' }, + }) + const read = cache.readFragment({ + id: 'Vault:VT-4.1-a', + fragment: gql` + fragment I on CatalogItem { + id + description + } + `, + }) + expect(read).toEqual({ + __typename: 'Vault', + id: 'VT-4.1-a', + description: 'Handspring', + }) + }) +}) diff --git a/src/lib/apollo/cache.ts b/src/lib/apollo/cache.ts new file mode 100644 index 0000000..3e98823 --- /dev/null +++ b/src/lib/apollo/cache.ts @@ -0,0 +1,49 @@ +// The cache of Apollo Client. `searchCatalog` is a Relay connection +// (edges, cursor, pageInfo). The cache keeps all the pages of one search in one +// list: "load more" (the same query with `after`) adds the next page to it, and +// a new search, filter or sort starts a new list. + +import { InMemoryCache, type TypePolicies } from '@apollo/client' + +type Edge = { cursor?: string } +type Connection = { + edges?: readonly Edge[] + totalCount?: number | null + pageInfo?: unknown + [key: string]: unknown +} + +export const typePolicies: TypePolicies = { + Query: { + fields: { + searchCatalog: { + // `first` and `after` are left out on purpose: they are the page, not the search. + keyArgs: ['search', 'filter', 'sort'], + merge( + existing: Connection | undefined, + incoming: Connection, + { args }, + ): Connection { + // The first page of a search (no cursor) replaces the list. + if (!existing || !args?.['after']) return incoming + const known = new Set(existing.edges?.map((edge) => edge.cursor)) + const added = (incoming.edges ?? []).filter( + (edge) => !known.has(edge.cursor), // the same page twice adds nothing + ) + return { + ...incoming, + edges: [...(existing.edges ?? []), ...added], + // totalCount is only asked for with the first page. + totalCount: incoming.totalCount ?? existing.totalCount, + } + }, + }, + }, + }, +} + +/** CatalogItem is an interface: a fragment on it also matches Element and Vault. */ +export const possibleTypes = { CatalogItem: ['Element', 'Vault'] } + +export const createCache = () => + new InMemoryCache({ typePolicies, possibleTypes }) diff --git a/src/lib/apollo/client.ts b/src/lib/apollo/client.ts new file mode 100644 index 0000000..bbbb352 --- /dev/null +++ b/src/lib/apollo/client.ts @@ -0,0 +1,87 @@ +// The Apollo Client of the app, and the functions for the session. The address +// of the API is VITE_API_URL (default "/graphql"). In development Vite sends +// "/graphql" to the backend on port 3001 (vite.config.ts), so the browser sees +// one address and there is no CORS to set up. + +import { ApolloClient } from '@apollo/client' +import { HttpLink } from '@apollo/client/link/http' +import { ApolloLink } from '@apollo/client/link' +import { + type AuthPayload, + loginRequest, + logoutRequest, + refreshRequest, +} from './api' +import { createAuthLink } from './authLink' +import { createCache } from './cache' +import { type Session, type TokenStorage, createSession } from './session' + +export const API_URL: string = import.meta.env.VITE_API_URL ?? '/graphql' + +export function createApolloClient(options: { + uri: string + session: Session + fetch?: typeof fetch +}) { + return new ApolloClient({ + cache: createCache(), + link: ApolloLink.from([ + createAuthLink(options.session), + new HttpLink({ + uri: options.uri, + ...(options.fetch ? { fetch: options.fetch } : {}), + }), + ]), + }) +} + +function browserStorage(): TokenStorage | null { + try { + return window.localStorage + } catch { + return null // blocked by the browser settings + } +} + +export const session: Session = createSession({ + storage: browserStorage(), + refreshRequest: (refreshToken) => refreshRequest(API_URL, refreshToken), + locks: typeof navigator === 'undefined' ? null : (navigator.locks ?? null), +}) + +export const apolloClient = createApolloClient({ uri: API_URL, session }) + +/** + * Logs in with the token from Sign in with Google. Everything the client has + * cached is cleared, so nothing from before the login is shown. + */ +export async function login( + googleToken: string, +): Promise { + const payload = await loginRequest(API_URL, googleToken) + session.setTokens(payload) + await apolloClient.clearStore() + return payload.coach +} + +/** Ends the login on the server and forgets the tokens and everything cached. */ +export async function logout(): Promise { + const refreshToken = session.getRefreshToken() + session.clear() + await apolloClient.clearStore() + if (refreshToken) { + try { + await logoutRequest(API_URL, refreshToken) + } catch { + // The tokens are gone here either way. The refresh token on the server + // expires by itself after 14 days. + } + } +} + +/** + * On page load: if the coach logged in earlier, get an access token with the + * refresh token. Returns whether the coach is logged in. + */ +export const restoreSession = (): Promise => + session.hasRefreshToken() ? session.refresh() : Promise.resolve(false) diff --git a/src/lib/apollo/errors.test.ts b/src/lib/apollo/errors.test.ts new file mode 100644 index 0000000..8f234af --- /dev/null +++ b/src/lib/apollo/errors.test.ts @@ -0,0 +1,89 @@ +import { CombinedGraphQLErrors } from '@apollo/client/errors' +import { describe, expect, it } from 'vitest' +import { ApiError } from './api' +import { describeError } from './errors' + +const fromServer = (code: string, message: string) => + new CombinedGraphQLErrors({ + data: null, + errors: [{ message, extensions: { code } }], + }) + +describe('describeError', () => { + it('shows the message of the server when it says what to change', () => { + for (const code of [ + 'BAD_USER_INPUT', + 'NAME_TAKEN', + 'LIMIT_REACHED', + 'INVALID_ITEMS', + ]) { + const described = describeError( + fromServer(code, 'The name must be 1-70 characters.'), + ) + expect(described).toEqual({ + code, + message: 'The name must be 1-70 characters.', + retryable: false, + }) + } + }) + + it('uses its own message for the other codes, and never shows server details', () => { + expect( + describeError(fromServer('UNAUTHENTICATED', 'You must be logged in.')), + ).toEqual({ + code: 'UNAUTHENTICATED', + message: 'Log in to continue.', + retryable: false, + }) + expect( + describeError(fromServer('NOT_FOUND', 'The gymnast was not found.')) + .message, + ).toBe('We could not find that.') + const internal = describeError( + fromServer('INTERNAL_SERVER_ERROR', 'password=secret'), + ) + expect(internal.retryable).toBe(true) + expect(JSON.stringify(internal)).not.toContain('secret') + expect(describeError(fromServer('SOMETHING_NEW', 'x')).retryable).toBe(true) + }) + + it('understands an ApiError from the login functions', () => { + expect( + describeError( + new ApiError('UNAUTHENTICATED', 'The Google token is not valid.'), + ).code, + ).toBe('UNAUTHENTICATED') + expect( + describeError(new ApiError('NETWORK_ERROR', 'The server answered 502.')), + ).toMatchObject({ + code: 'NETWORK_ERROR', + retryable: true, + }) + }) + + it('says that the server cannot be reached when fetch fails', () => { + const described = describeError(new TypeError('Failed to fetch')) + expect(described.code).toBe('NETWORK_ERROR') + expect(described.retryable).toBe(true) + }) + + it('handles a server error page (for example 502 from a proxy)', () => { + const error = Object.assign(new Error('Response not successful'), { + statusCode: 502, + }) + expect(describeError(error)).toMatchObject({ + code: 'SERVER_ERROR', + retryable: true, + }) + }) + + it('handles anything else without throwing', () => { + for (const value of [null, undefined, 'text', 42, {}, new Error('x')]) { + expect(describeError(value)).toMatchObject({ + code: 'UNKNOWN', + retryable: true, + }) + } + }) +}) diff --git a/src/lib/apollo/errors.ts b/src/lib/apollo/errors.ts new file mode 100644 index 0000000..82e729e --- /dev/null +++ b/src/lib/apollo/errors.ts @@ -0,0 +1,94 @@ +// One place that turns an error from the API or the network into a message that +// a page can show. The codes are the ones in "Error handling" in docs/API.md. + +export type DescribedError = { + /** The `extensions.code` from the server, or NETWORK_ERROR / UNKNOWN. */ + code: string + /** A message for the user. */ + message: string + /** Whether trying again can help. */ + retryable: boolean +} + +type GraphQLErrorLike = { + message?: string + extensions?: Record +} + +/** The GraphQL errors in an Apollo error (CombinedGraphQLErrors), an ApiError or a plain object. */ +function graphQLErrors(error: unknown): GraphQLErrorLike[] { + const errors = (error as { errors?: unknown } | null)?.errors + return Array.isArray(errors) ? (errors as GraphQLErrorLike[]) : [] +} + +/** + * Codes where the message from the server is meant for the user, because it + * says what to change ("The name of a gymnast must be 1-70 characters."). + */ +const SERVER_MESSAGE = new Set([ + 'BAD_USER_INPUT', + 'NAME_TAKEN', + 'LIMIT_REACHED', + 'INVALID_ITEMS', +]) + +const FIXED: Record = { + UNAUTHENTICATED: { message: 'Log in to continue.', retryable: false }, + NOT_FOUND: { message: 'We could not find that.', retryable: false }, + NOT_IMPLEMENTED: { + message: 'This is not available yet.', + retryable: false, + }, + GRAPHQL_VALIDATION_FAILED: { + message: 'The app sent a request that the server does not understand.', + retryable: false, + }, + INTERNAL_SERVER_ERROR: { + message: 'Something went wrong on our side. Try again.', + retryable: true, + }, +} + +const NETWORK = { + code: 'NETWORK_ERROR', + message: 'Could not reach the server. Check your connection and try again.', + retryable: true, +} + +export function describeError(error: unknown): DescribedError { + const first = graphQLErrors(error)[0] + const code = + first?.extensions?.['code'] ?? (error as { code?: unknown } | null)?.code + if (typeof code === 'string' && code !== 'NETWORK_ERROR') { + if (SERVER_MESSAGE.has(code)) { + const message = + first?.message ?? (error as { message?: string }).message ?? '' + return { code, message, retryable: false } + } + const fixed = FIXED[code] + if (fixed) return { code, ...fixed } + return { + code, + message: 'Something went wrong. Try again.', + retryable: true, + } + } + // No code from the server: the request did not get an answer (a TypeError + // from fetch, a broken connection), or the server answered with a page that + // is not GraphQL (a proxy error, 502). + if (error instanceof TypeError || code === 'NETWORK_ERROR') return NETWORK + if ( + typeof (error as { statusCode?: unknown } | null)?.statusCode === 'number' + ) { + return { + code: 'SERVER_ERROR', + message: 'The server could not answer. Try again.', + retryable: true, + } + } + return { + code: 'UNKNOWN', + message: 'Something went wrong. Try again.', + retryable: true, + } +} diff --git a/src/lib/apollo/session.test.ts b/src/lib/apollo/session.test.ts new file mode 100644 index 0000000..cf97e13 --- /dev/null +++ b/src/lib/apollo/session.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError, type AuthPayload } from './api' +import { REFRESH_TOKEN_KEY, createSession, type TokenStorage } from './session' + +const payload = (n: number): AuthPayload => ({ + accessToken: `access-${n}`, + refreshToken: `refresh-${n}`, + coach: { id: 'c1', name: 'Anna', email: 'anna@example.com' }, +}) + +function memoryStorage(): TokenStorage & { values: Map } { + const values = new Map() + return { + values, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + removeItem: (key) => void values.delete(key), + } +} + +describe('the session', () => { + it('keeps the access token in memory and the refresh token in the storage', () => { + const storage = memoryStorage() + const session = createSession({ storage, refreshRequest: vi.fn() }) + expect(session.getAccessToken()).toBeNull() + expect(session.hasRefreshToken()).toBe(false) + + session.setTokens(payload(1)) + expect(session.getAccessToken()).toBe('access-1') + expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-1') + expect([...storage.values.values()]).not.toContain('access-1') + + // A new tab has the refresh token but no access token. + const other = createSession({ storage, refreshRequest: vi.fn() }) + expect(other.getAccessToken()).toBeNull() + expect(other.hasRefreshToken()).toBe(true) + + session.clear() + expect(session.getAccessToken()).toBeNull() + expect(storage.values.size).toBe(0) + }) + + it('still works when the storage is missing or throws', () => { + const broken: TokenStorage = { + getItem: () => { + throw new Error('blocked') + }, + setItem: () => { + throw new Error('blocked') + }, + removeItem: () => { + throw new Error('blocked') + }, + } + for (const storage of [null, broken]) { + const session = createSession({ storage, refreshRequest: vi.fn() }) + session.setTokens(payload(1)) + expect(session.getAccessToken()).toBe('access-1') + expect(session.getRefreshToken()).toBe('refresh-1') + session.clear() + expect(session.hasRefreshToken()).toBe(false) + } + }) + + describe('refresh', () => { + it('uses the stored refresh token and keeps the new tokens', async () => { + const storage = memoryStorage() + storage.setItem(REFRESH_TOKEN_KEY, 'refresh-1') + const refreshRequest = vi.fn().mockResolvedValue(payload(2)) + const session = createSession({ storage, refreshRequest }) + + expect(await session.refresh()).toBe(true) + expect(refreshRequest).toHaveBeenCalledWith('refresh-1') + expect(session.getAccessToken()).toBe('access-2') + // The new refresh token replaces the old one, which no longer works. + expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-2') + }) + + it('does nothing without a refresh token', async () => { + const refreshRequest = vi.fn() + const session = createSession({ + storage: memoryStorage(), + refreshRequest, + }) + expect(await session.refresh()).toBe(false) + expect(refreshRequest).not.toHaveBeenCalled() + }) + + it('forgets the login when the server refuses the refresh token', async () => { + const storage = memoryStorage() + const session = createSession({ + storage, + refreshRequest: vi + .fn() + .mockRejectedValue(new ApiError('UNAUTHENTICATED', 'Log in again.')), + }) + session.setTokens(payload(1)) + + expect(await session.refresh()).toBe(false) + expect(session.getAccessToken()).toBeNull() + expect(storage.values.size).toBe(0) + }) + + it('keeps the refresh token when the server cannot be reached, so a later try can work', async () => { + const storage = memoryStorage() + const refreshRequest = vi + .fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockRejectedValueOnce(new ApiError('INTERNAL_SERVER_ERROR', 'Oops')) + .mockResolvedValueOnce(payload(2)) + const session = createSession({ storage, refreshRequest }) + session.setTokens(payload(1)) + + expect(await session.refresh()).toBe(false) + expect(await session.refresh()).toBe(false) + expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-1') + expect(await session.refresh()).toBe(true) + expect(storage.values.get(REFRESH_TOKEN_KEY)).toBe('refresh-2') + }) + + it('sends one request when several parts of the page refresh at the same moment', async () => { + let resolve!: (value: AuthPayload) => void + const refreshRequest = vi.fn( + () => new Promise((r) => (resolve = r)), + ) + const session = createSession({ + storage: memoryStorage(), + refreshRequest, + }) + session.setTokens(payload(1)) + + const all = Promise.all([ + session.refresh(), + session.refresh(), + session.refresh(), + ]) + resolve(payload(2)) + expect(await all).toEqual([true, true, true]) + expect(refreshRequest).toHaveBeenCalledTimes(1) + + // A later refresh is a new request. + refreshRequest.mockResolvedValueOnce(payload(3) as never) + expect(await session.refresh()).toBe(true) + expect(refreshRequest).toHaveBeenCalledTimes(2) + }) + + it('goes through the lock between tabs, and reads the token inside it', async () => { + const storage = memoryStorage() + const names: string[] = [] + // Another tab refreshes while this tab waits for the lock. + const locks = { + request: ((name: string, callback: () => Promise) => { + names.push(name) + storage.setItem(REFRESH_TOKEN_KEY, 'refresh-from-other-tab') + return callback() + }) as unknown as LockManager['request'], + } + const refreshRequest = vi.fn().mockResolvedValue(payload(5)) + const session = createSession({ storage, refreshRequest, locks }) + session.setTokens(payload(1)) + + expect(await session.refresh()).toBe(true) + expect(names).toEqual(['cop-refresh-session']) + expect(refreshRequest).toHaveBeenCalledWith('refresh-from-other-tab') + }) + }) +}) diff --git a/src/lib/apollo/session.ts b/src/lib/apollo/session.ts new file mode 100644 index 0000000..99c8bc3 --- /dev/null +++ b/src/lib/apollo/session.ts @@ -0,0 +1,113 @@ +// The tokens of the logged-in coach (see "Identifying the user" in docs/API.md). +// - The access token (15 minutes) is kept in memory only. +// - The refresh token (14 days) is kept in localStorage, so a reload does not +// log the coach out. The server replaces it at every use, so the new one is +// stored before anything else happens. +// - Refreshing is done from one place at a time. Two tabs that use the same +// refresh token at the same moment look like a stolen token to the server, +// and it ends the login. Within a tab the calls share one request, and +// between tabs the Web Locks API (`navigator.locks`) makes them follow each +// other. The refresh token is read inside the lock, so a tab that waited +// uses the newest one. + +import { ApiError, type AuthPayload } from './api' + +export const REFRESH_TOKEN_KEY = 'cop.refreshToken' +const LOCK_NAME = 'cop-refresh-session' + +export type TokenStorage = Pick +type Locks = Pick + +export type Session = { + getAccessToken: () => string | null + hasRefreshToken: () => boolean + getRefreshToken: () => string | null + /** Stores the tokens of a login or a refresh. */ + setTokens: ( + payload: Pick, + ) => void + /** Forgets both tokens (logout, or a refresh token that the server refuses). */ + clear: () => void + /** + * Gets new tokens with the stored refresh token. True when the coach is + * logged in afterwards. False when there is no refresh token, when the server + * refuses it (the tokens are cleared), or when the server cannot be reached + * (the refresh token is kept, so a later try can work). + */ + refresh: () => Promise +} + +export function createSession(options: { + storage?: TokenStorage | null + refreshRequest: (refreshToken: string) => Promise + locks?: Locks | null +}): Session { + const { storage = null, locks = null, refreshRequest } = options + let accessToken: string | null = null + let inFlight: Promise | null = null + + // Storage can be missing or throw (private windows, blocked site data). + const read = (): string | null => { + try { + return storage?.getItem(REFRESH_TOKEN_KEY) ?? null + } catch { + return null + } + } + let memoryRefreshToken: string | null = null // when the storage does not work + const getRefreshToken = () => read() ?? memoryRefreshToken + + const setTokens: Session['setTokens'] = (payload) => { + accessToken = payload.accessToken + memoryRefreshToken = payload.refreshToken + try { + storage?.setItem(REFRESH_TOKEN_KEY, payload.refreshToken) + } catch { + // The memory copy keeps this tab logged in. + } + } + + const clear = () => { + accessToken = null + memoryRefreshToken = null + try { + storage?.removeItem(REFRESH_TOKEN_KEY) + } catch { + // Nothing more to do. + } + } + + const refreshOnce = async (): Promise => { + const token = getRefreshToken() // read inside the lock + if (!token) return false + try { + setTokens(await refreshRequest(token)) + return true + } catch (error) { + // The server says the login is over: forget it. Any other error (no + // network, a server error) keeps the token for a later try. + if (error instanceof ApiError && error.code === 'UNAUTHENTICATED') { + clear() + } + return false + } + } + + const refresh = () => { + inFlight ??= ( + locks ? locks.request(LOCK_NAME, refreshOnce) : refreshOnce() + ).finally(() => { + inFlight = null + }) + return inFlight + } + + return { + getAccessToken: () => accessToken, + hasRefreshToken: () => getRefreshToken() !== null, + getRefreshToken, + setTokens, + clear, + refresh, + } +} diff --git a/src/main.tsx b/src/main.tsx index 17d5695..e9509e9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,20 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { ApolloProvider } from '@apollo/client/react' import './index.css' import './styles/variables.css' import './styles/global.css' import App from './App.tsx' +import { apolloClient, restoreSession } from './lib/apollo/client' -createRoot(document.getElementById('root')!).render( - - - , -) +// A coach who logged in earlier gets a new access token before the first +// request. Without a saved login this answers at once. +void restoreSession().finally(() => { + createRoot(document.getElementById('root')!).render( + + + + + , + ) +}) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..d4e233d --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +interface ImportMetaEnv { + /** The address of the GraphQL API. Default "/graphql" (Vite sends it to the backend in development). */ + readonly VITE_API_URL?: string +} diff --git a/vite.config.ts b/vite.config.ts index b0164d2..a586000 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,15 @@ export default defineConfig({ babel({ presets: [reactCompilerPreset()] }), tailwindcss(), ], + server: { + // In development the browser calls the backend through Vite, so the app + // and the API have the same address and CORS is not needed. The backend + // listens on port 3001 (npm run backend). + proxy: { + '/graphql': 'http://localhost:3001', + '/images': 'http://localhost:3001', + }, + }, resolve: { // "@/components/ui/button" means "src/components/ui/button". alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, From 7f4bc52aef979dd00bbf70072d93f5ac74c11950 Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 21 Oct 2026 13:51:07 +0200 Subject: [PATCH 2/6] fix: refresh the session before a request when the access token has expired --- src/lib/apollo/authLink.test.ts | 23 +++++++++ src/lib/apollo/authLink.ts | 48 ++++++++++------- src/lib/apollo/session.test.ts | 92 +++++++++++++++++++++++++++++++++ src/lib/apollo/session.ts | 41 +++++++++++++++ 4 files changed, 184 insertions(+), 20 deletions(-) diff --git a/src/lib/apollo/authLink.test.ts b/src/lib/apollo/authLink.test.ts index 9d65ab1..276871a 100644 --- a/src/lib/apollo/authLink.test.ts +++ b/src/lib/apollo/authLink.test.ts @@ -84,6 +84,29 @@ describe('the authorization link', () => { expect(seen).toEqual([undefined, 'Bearer access-2']) }) + it('refreshes before the first request when there is a refresh token but no access token', async () => { + const storage = memoryStorage() + storage.setItem('cop.refreshToken', 'refresh-1') + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(payload(2)) + const seen: (string | undefined)[] = [] + const server = new ApolloLink((operation) => { + const headers = operation.getContext()['headers'] as + Record | undefined + seen.push(headers?.['authorization']) + return of(ok) + }) + const session = createSession({ storage, refreshRequest }) + const link = ApolloLink.from([createAuthLink(session), server]) + + await firstValueFrom( + ApolloLink.execute(link, { query: ME }, { client: {} as never }), + ) + expect(refreshRequest).toHaveBeenCalledTimes(1) + expect(seen).toEqual(['Bearer access-2']) // no failed first try + }) + it('refreshes once and repeats the request when the access token has expired', async () => { const { session, refreshRequest, seen, run } = setup() session.setTokens(payload(1)) // access-1 is expired for this server diff --git a/src/lib/apollo/authLink.ts b/src/lib/apollo/authLink.ts index 6cdadfb..92eb02e 100644 --- a/src/lib/apollo/authLink.ts +++ b/src/lib/apollo/authLink.ts @@ -1,5 +1,8 @@ // The authorization link: it adds `Authorization: Bearer ` to -// every request. When the server answers UNAUTHENTICATED because the access +// every request, and renews the session first when the access token has +// expired or is about to (an expired token makes `me` answer null and not an +// error, so waiting for an error would show the coach as logged out). When the +// server answers UNAUTHENTICATED because the access // token has expired, it refreshes the session once and repeats the request. // If the refresh fails, the answer of the first try is passed on, and the page // shows that the coach must log in. @@ -19,6 +22,7 @@ const isUnauthenticated = (result: ApolloLink.Result): boolean => export function createAuthLink(session: Session): ApolloLink { return new ApolloLink((operation, forward) => { + const isAuthOperation = AUTH_OPERATIONS.has(operation.operationName ?? '') const addToken = () => { const token = session.getAccessToken() if (!token) return @@ -28,25 +32,29 @@ export function createAuthLink(session: Session): ApolloLink { }), ) } - addToken() + const send = () => { + addToken() + return forward(operation).pipe( + mergeMap((result) => { + if ( + !isUnauthenticated(result) || + isAuthOperation || + !session.hasRefreshToken() + ) { + return of(result) + } + return from(session.refresh()).pipe( + mergeMap((refreshed) => { + if (!refreshed) return of(result) + addToken() + return forward(operation) // the second try is not checked again + }), + ) + }), + ) + } - return forward(operation).pipe( - mergeMap((result) => { - if ( - !isUnauthenticated(result) || - AUTH_OPERATIONS.has(operation.operationName ?? '') || - !session.hasRefreshToken() - ) { - return of(result) - } - return from(session.refresh()).pipe( - mergeMap((refreshed) => { - if (!refreshed) return of(result) - addToken() - return forward(operation) // the second try is not checked again - }), - ) - }), - ) + if (isAuthOperation) return send() + return from(session.ensureFresh()).pipe(mergeMap(send)) }) } diff --git a/src/lib/apollo/session.test.ts b/src/lib/apollo/session.test.ts index cf97e13..4b75b84 100644 --- a/src/lib/apollo/session.test.ts +++ b/src/lib/apollo/session.test.ts @@ -18,6 +18,16 @@ function memoryStorage(): TokenStorage & { values: Map } { } } +const base64url = (value: object) => + btoa(JSON.stringify(value)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +/** A token that looks like the server's: three parts, with `exp` in seconds. */ +const jwt = (expSeconds: number) => + `${base64url({ alg: 'HS256' })}.${base64url({ exp: expSeconds })}.signature` + describe('the session', () => { it('keeps the access token in memory and the refresh token in the storage', () => { const storage = memoryStorage() @@ -165,3 +175,85 @@ describe('the session', () => { }) }) }) + +describe('ensureFresh', () => { + const NOW = Date.parse('2026-10-21T12:00:00Z') + const inSeconds = (n: number) => NOW / 1000 + n + + function setup(accessExpiresInSeconds: number | null) { + const refreshed = { ...payload(2), accessToken: jwt(inSeconds(900)) } + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(refreshed) + const session = createSession({ + storage: memoryStorage(), + refreshRequest, + now: () => NOW, + }) + if (accessExpiresInSeconds !== null) { + session.setTokens({ + accessToken: jwt(inSeconds(accessExpiresInSeconds)), + refreshToken: 'refresh-1', + }) + } + return { session, refreshRequest } + } + + it('does nothing without a refresh token', async () => { + const { session, refreshRequest } = setup(null) + await session.ensureFresh() + expect(refreshRequest).not.toHaveBeenCalled() + }) + + it('refreshes when there is a refresh token but no access token (a new tab)', async () => { + const storage = memoryStorage() + createSession({ storage, refreshRequest: vi.fn() }).setTokens({ + accessToken: jwt(inSeconds(900)), + refreshToken: 'refresh-1', + }) + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockResolvedValue(payload(2)) + const newTab = createSession({ storage, refreshRequest, now: () => NOW }) + expect(newTab.getAccessToken()).toBeNull() + + await newTab.ensureFresh() + expect(refreshRequest).toHaveBeenCalledWith('refresh-1') + expect(newTab.getAccessToken()).toBe('access-2') + }) + + it('does not refresh while the access token is valid for longer than the margin', async () => { + const { session, refreshRequest } = setup(600) + await session.ensureFresh() + expect(refreshRequest).not.toHaveBeenCalled() + }) + + it('refreshes when the access token has expired or is about to', async () => { + for (const seconds of [-60, 0, 20]) { + const { session, refreshRequest } = setup(seconds) + await session.ensureFresh() + expect(refreshRequest).toHaveBeenCalledTimes(1) + expect(refreshRequest).toHaveBeenCalledWith('refresh-1') + expect(session.getRefreshToken()).toBe('refresh-2') + } + }) + + it('keeps the refresh token when the request times out', async () => { + const timeout = new DOMException('The operation timed out.', 'TimeoutError') + const refreshRequest = vi + .fn<(refreshToken: string) => Promise>() + .mockRejectedValue(timeout) + const session = createSession({ + storage: memoryStorage(), + refreshRequest, + now: () => NOW, + }) + session.setTokens({ + accessToken: jwt(inSeconds(-1)), + refreshToken: 'refresh-1', + }) + + await expect(session.refresh()).resolves.toBe(false) + expect(session.getRefreshToken()).toBe('refresh-1') + }) +}) diff --git a/src/lib/apollo/session.ts b/src/lib/apollo/session.ts index 99c8bc3..749ba39 100644 --- a/src/lib/apollo/session.ts +++ b/src/lib/apollo/session.ts @@ -15,6 +15,25 @@ import { ApiError, type AuthPayload } from './api' export const REFRESH_TOKEN_KEY = 'cop.refreshToken' const LOCK_NAME = 'cop-refresh-session' +/** A token that expires within this time is renewed before the request. */ +export const EXPIRY_MARGIN_MS = 30 * 1000 + +/** + * When the access token expires, in milliseconds, read from its `exp` claim. + * Only the client's own planning uses this; the server decides if the token + * is valid. Null for a token that cannot be read. + */ +function expiryOf(token: string): number | null { + try { + const part = token.split('.')[1] + if (!part) return null + const claims = JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) + return typeof claims.exp === 'number' ? claims.exp * 1000 : null + } catch { + return null + } +} + export type TokenStorage = Pick type Locks = Pick @@ -35,15 +54,26 @@ export type Session = { * (the refresh token is kept, so a later try can work). */ refresh: () => Promise + /** + * Refreshes before a request when the coach has a refresh token and the + * access token is missing or about to expire. An expired access token does + * not make the server fail the request: `me` answers null, so the page would + * show the coach as logged out. Does nothing without a refresh token. + */ + ensureFresh: () => Promise } export function createSession(options: { storage?: TokenStorage | null refreshRequest: (refreshToken: string) => Promise locks?: Locks | null + /** The time in milliseconds. The tests set it. */ + now?: () => number }): Session { const { storage = null, locks = null, refreshRequest } = options + const now = options.now ?? Date.now let accessToken: string | null = null + let accessExpiresAt: number | null = null let inFlight: Promise | null = null // Storage can be missing or throw (private windows, blocked site data). @@ -59,6 +89,7 @@ export function createSession(options: { const setTokens: Session['setTokens'] = (payload) => { accessToken = payload.accessToken + accessExpiresAt = expiryOf(payload.accessToken) memoryRefreshToken = payload.refreshToken try { storage?.setItem(REFRESH_TOKEN_KEY, payload.refreshToken) @@ -69,6 +100,7 @@ export function createSession(options: { const clear = () => { accessToken = null + accessExpiresAt = null memoryRefreshToken = null try { storage?.removeItem(REFRESH_TOKEN_KEY) @@ -102,6 +134,14 @@ export function createSession(options: { return inFlight } + const ensureFresh = async () => { + if (getRefreshToken() === null) return + const expired = + accessToken === null || + (accessExpiresAt !== null && now() >= accessExpiresAt - EXPIRY_MARGIN_MS) + if (expired) await refresh() + } + return { getAccessToken: () => accessToken, hasRefreshToken: () => getRefreshToken() !== null, @@ -109,5 +149,6 @@ export function createSession(options: { setTokens, clear, refresh, + ensureFresh, } } From ed84434fe290623b77cd9336c00680ae0a2de3ab Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 21 Oct 2026 13:51:07 +0200 Subject: [PATCH 3/6] fix: time out the login, refresh and logout requests after 5 seconds --- src/lib/apollo/api.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/lib/apollo/api.ts | 9 +++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/lib/apollo/api.test.ts diff --git a/src/lib/apollo/api.test.ts b/src/lib/apollo/api.test.ts new file mode 100644 index 0000000..06ecdcf --- /dev/null +++ b/src/lib/apollo/api.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError, REQUEST_TIMEOUT_MS, refreshRequest } from './api' + +const answer = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status }) + +describe('the requests to the API', () => { + it('give every request a timeout, so a slow server does not leave a white page', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue( + answer({ data: { refreshSession: { accessToken: 'a' } } }), + ) + const timeout = vi.spyOn(AbortSignal, 'timeout') + + await refreshRequest('/graphql', 'refresh-1', fetchFn) + + expect(timeout).toHaveBeenCalledWith(REQUEST_TIMEOUT_MS) + expect(fetchFn.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) + timeout.mockRestore() + }) + + it('pass on the code of an error from the server', async () => { + const fetchFn = vi.fn().mockImplementation(async () => + answer({ + errors: [ + { message: 'Log in again.', extensions: { code: 'UNAUTHENTICATED' } }, + ], + }), + ) + await expect( + refreshRequest('/graphql', 'refresh-1', fetchFn), + ).rejects.toMatchObject({ code: 'UNAUTHENTICATED' }) + await expect( + refreshRequest('/graphql', 'refresh-1', fetchFn), + ).rejects.toBeInstanceOf(ApiError) + }) +}) diff --git a/src/lib/apollo/api.ts b/src/lib/apollo/api.ts index 820ed54..fb0620e 100644 --- a/src/lib/apollo/api.ts +++ b/src/lib/apollo/api.ts @@ -21,6 +21,14 @@ export class ApiError extends Error { type Fetch = typeof fetch +/** + * How long the login, refresh and logout requests may take. Without a limit a + * slow server leaves a white page, because the app waits for the refresh + * before it is shown (see main.tsx). A timeout is not an answer from the + * server, so the session keeps the refresh token and a later try can work. + */ +export const REQUEST_TIMEOUT_MS = 5000 + async function call( url: string, query: string, @@ -31,6 +39,7 @@ async function call( method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) if (!response.ok) { throw new ApiError( From 869a714ec9c2882b1cf6203ac6765769cd87286f Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 21 Oct 2026 13:52:36 +0200 Subject: [PATCH 4/6] docs: explain the refresh before requests and the request timeout --- docs/API.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index ed4fa35..180a2c9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -215,7 +215,8 @@ The access token is short, because the client keeps it and a token that leaks ca **What the client does:** - It keeps the access token in memory. It keeps the refresh token where it survives a reload, and sends it nowhere except to `refreshSession` and `logout`. -- On page load, if `me` is `null` and the client has a refresh token, it calls `refreshSession` once. When a request answers `UNAUTHENTICATED` because the access token has expired, it calls `refreshSession` once and repeats the request. If that fails, the coach logs in with Google again. +- **It refreshes before the request, not only after an error.** If the client has a refresh token and the access token is missing (a reload, a new tab) or expires within 30 seconds, it calls `refreshSession` first. It reads `exp` from the token only to plan this; the server still decides what is valid. The reason is that an expired access token does not make the server fail the request: `me` answers `null`, so a page that asks for `me` would show the coach as logged out after 15 minutes, although the refresh token still works. Waiting for an `UNAUTHENTICATED` error cannot catch that. Checking the expiry before the request and retrying after an error is the usual way to build an Apollo auth link, and the retry stays as a fallback for the cases the check misses (a clock that is wrong, a login that was ended on the server). When a request answers `UNAUTHENTICATED`, the client refreshes once and repeats the request. If that fails, the coach logs in with Google again. +- **The login, refresh and logout requests time out after 5 seconds** (`REQUEST_TIMEOUT_MS` in `src/lib/apollo/api.ts`, with `AbortSignal.timeout`). `main.tsx` waits for the refresh before it shows the app, so a slow server would otherwise leave a white page. A timeout is not an answer from the server, so the client keeps the refresh token and the next request tries again. Showing the app first with a loading state would also work, but it needs a state in every page that reads `me`, and the timeout is one line in one place. - **It must refresh from one place at a time.** Two tabs that use the same refresh token at the same moment look like a stolen token, and the login ends. Use one refresh at a time per browser (for example `navigator.locks`), and store the new refresh token before the next request. **Decision for now: the tokens are returned in the response body, not in a cookie.** The refresh token is therefore readable by scripts on the page, so a cross-site scripting hole would leak it. Rotation and the reuse check limit what a stolen token is worth, but they do not remove the risk. An httpOnly, Secure, SameSite cookie for the refresh token is safer, and works when the app and the API are on the same origin (Apache proxy on the virtual machine, the Vite proxy in development). It needs `credentials` in CORS and a change in `login`, `refreshSession` and `logout`, and can be added later without changing the rest. From 5ee72e682b665795d6525223e9e6f7741ad7e91a Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 21 Oct 2026 13:52:36 +0200 Subject: [PATCH 5/6] docs: describe the Apache proxy for the API on the VM --- docs/VM_SETUP.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/VM_SETUP.md b/docs/VM_SETUP.md index eb548a7..7bb18de 100644 --- a/docs/VM_SETUP.md +++ b/docs/VM_SETUP.md @@ -98,3 +98,50 @@ DATABASE_URL="postgresql://wag_migrator:YOUR_SECURE_MIGRATOR_PASSWORD@localhost: This command executes Prisma migrations to create all database tables and populates the database with the initial gymnastics catalog data. _Note: The initial setup and database seeding have been successfully executed on the production VM._ + +--- + +## 5. Connect the frontend to the backend + +The frontend is served by Apache under `/project2/`, and the backend listens on port 3001. The browser must reach the backend on the same address as the frontend, so Apache sends `/project2/graphql` and `/project2/images` to the backend. This also means there is no CORS to set up. + +_Status: this configuration follows the Apache documentation for `mod_proxy`, but it has **not been tried on the VM yet**. Check it with the `curl` command below._ + +1. Enable the proxy modules and restart Apache: + + ```bash + sudo a2enmod proxy proxy_http + sudo systemctl restart apache2 + ``` + +2. In the `VirtualHost` that serves `/project2/`, add (the path and the address in each pair either both end in a slash or both do not): + + ```apache + ProxyRequests Off + ProxyPass /project2/graphql http://localhost:3001/graphql + ProxyPassReverse /project2/graphql http://localhost:3001/graphql + ProxyPass /project2/images http://localhost:3001/images + ProxyPassReverse /project2/images http://localhost:3001/images + ``` + + `ProxyRequests Off` is the default and must stay off: with it on, Apache would work as an open proxy for anyone. + +3. Build the frontend with the API address, and put `IMAGE_BASE_URL` in `backend/.env` (the drawings are then fetched through the same address): + + ```bash + VITE_API_URL=/project2/graphql npm run build + ``` + + ```env + IMAGE_BASE_URL=http://it2810-XX.idi.ntnu.no/project2/images + ``` + +4. Check that the backend answers through Apache: + + ```bash + curl -s -X POST -H 'content-type: application/json' -d '{"query":"{ __typename }"}' http://it2810-XX.idi.ntnu.no/project2/graphql + ``` + + The answer should be `{"data":{"__typename":"Query"}}`. + +The VM serves plain http, so Sign in with Google does not work there (Google only accepts http for `localhost`), and the tokens would travel without encryption. See "Login" in the README. From 21ad013738476e7bd95d6236084e217c6da7d5c9 Mon Sep 17 00:00:00 2001 From: Ronja Date: Wed, 21 Oct 2026 13:52:36 +0200 Subject: [PATCH 6/6] docs: document the VM API address and the bundle size decision --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c4a19d..e6f3044 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,9 @@ Other commands: ### Calling the API from the frontend -The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 3001) and the frontend (`npm run dev`). In development Vite sends `/graphql` and `/images` to the backend, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; the default is `/graphql`. +The frontend uses [Apollo Client](https://www.apollographql.com/docs/react) (`src/lib/apollo/`), and `src/main.tsx` wraps the app in `ApolloProvider`. Start the backend (`npm run backend`, port 3001) and the frontend (`npm run dev`). In development Vite sends `/graphql` and `/images` to the backend, so the browser sees one address and there is no CORS to set up. Set `VITE_API_URL` (for example in `.env.local`) to call another address; the default is `/graphql`. The app is built for the virtual machine with `VITE_API_URL=/project2/graphql npm run build`, because it is served under `/project2/` and Apache must send that address to the backend (see "Connect the frontend to the backend" in [docs/VM_SETUP.md](docs/VM_SETUP.md)). + +**Bundle size.** `npm run build` gives one JavaScript file of 665 kB (177 kB compressed), and Vite warns about files over 500 kB. About 160 kB of it is Apollo Client. We leave it for now: the app has two pages, so route-level splitting (`React.lazy`) would give little, and raising the warning limit only hides the number. Check it again when the pages grow. The options then are `React.lazy` for the larger pages and `rollup-plugin-visualizer` to see what is big. ```tsx import { gql } from '@apollo/client'