From 0140115b614cb19ad2f7aba3ab8560f9a5966c7b Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Wed, 7 Oct 2026 12:50:18 -0600 Subject: [PATCH 1/2] feat: #10 simulated user identity: User type and the X-User-Id contract Spec work for the simulated user. No runtime yet; this is the contract the client and the server will both implement. schema.graphql Adds `type User { id, displayName }` and `Review.user: User!`. Reviews rendered without an author were a gap: the brief asks that user-generated data is presented, and an unattributed review does not present it. Checked that no root field and no user-scoped field takes a userId argument, which is the point of routing identity through the context instead. AGENTS.md rule 5 Was wrong: "sent with every mutation". myReview, isInCrate and myCrate are queries, so they need the identity too. Rewritten to cover the header, the per-request context, anonymous fallback, and why displayName is derived server-side rather than sent by the client. docs/architecture.md decision 5 Even had the decision right ("sends it with each request") but not the transport. Adds why a header and not a GraphQL variable, cookie or Authorization header, the missing/invalid behaviour table, and the consequence that displayName is not client-supplied. docs/data-model.md Adds the user to the user-generated data section and points at decision 5. SDL validated with buildSchema: Review and User parse, and every user-scoped field takes zero arguments. Co-Authored-By: opencode --- AGENTS.md | 15 ++++++++++++--- docs/architecture.md | 29 ++++++++++++++++++++++++++++- docs/data-model.md | 12 ++++++++++-- schema.graphql | 6 ++++++ 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca3aee8..d1269dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,18 @@ requirements work - do not casually change them. weak spot is server-side N+1; do not call the database one row at a time from a field resolver. 4. **User input is never interpolated into SQL.** Use parameterised queries. -5. **Users are simulated** - a UUID in `localStorage`, sent with every - mutation. No authentication. This is allowed by the course and is enough to - persist reviews server-side. +5. **Users are simulated.** A UUID in `localStorage`, sent as an `X-User-Id` + header on **every** request, not just mutations. `myReview`, `isInCrate` and + `myCrate` are queries and need it just as much. The server reads the header + once into the GraphQL context, so no field in `schema.graphql` takes a + `userId` argument. No authentication. + - Missing or malformed id means **anonymous**: reads degrade to `null` or + `false`, mutations that need identity fail with a clear error. + - The context is **per request**. Hoisting the user id into a module-level + variable or a shared cache leaks one user's data into another. + - `displayName` is derived server-side from the UUID when the user row is + first created. It is not client-supplied, so a review cannot be attributed + to someone else's name. 6. **Plain CSS.** No CSS framework, no component library. Smaller bundle for bærekraft, full control over focus rings and contrast for tilgjengelighet. 7. **Accessibility is not optional.** Every input needs a label or diff --git a/docs/architecture.md b/docs/architecture.md index 4692d8a..3c349e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,8 +81,35 @@ simulated users. it with each request. The server reads it, so the `my...` fields in the schema need no user argument. +**Transport.** One HTTP header, `X-User-Id`, set once by an Apollo link and +read once into the GraphQL context. Not a GraphQL variable: `myReview`, +`isInCrate` and `myCrate` would each need a `userId` argument, which would +thread `$userId` through every operation and make identity look like data when +it is transport. Not a cookie, because cookies imply sessions and CSRF and the +brief explicitly wants simulated users rather than login. Not an +`Authorization` header either, because nothing here is authenticated and +calling it that would be misleading. + +It goes on **every** request, not only mutations. Those three fields are +queries. + +**Missing or invalid.** Absent or malformed means anonymous. + +| State | `myReview` | `isInCrate` | `myCrate` | Mutations | +| ----------------- | ---------- | ----------- | ---------------- | ------------------------ | +| No header | `null` | `false` | empty connection | error: no client id | +| Malformed | `null` | `false` | empty connection | error: invalid client id | +| Valid, no row yet | `null` | `false` | empty connection | succeed, create the row | +| Valid, row exists | the review | as stored | the crate | succeed | + +Reads never fail because of identity state, so a bad header cannot take the +search page down. Writes require it and say so rather than attributing the +change to nobody. + **Consequences:** Much less to build. There is no security, since anyone can -send another user's id, and clearing storage gives a new user. +send another user's id, and clearing storage gives a new user. `displayName` is +derived from the UUID when the row is first created rather than sent by the +client, so a review cannot be attributed to a name the sender did not earn. ## 6. Plain CSS, Apollo Client cache, URL as search state diff --git a/docs/data-model.md b/docs/data-model.md index 6bc4cd3..0e5b32d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -18,16 +18,24 @@ The two many-to-many relationships will use join tables in PostgreSQL in M2. ## User-generated data +- A **user** is a simulated identity: a UUID the client keeps in `localStorage` + and sends as an `X-User-Id` header on every request. There is no login. The + server derives a `displayName` from the UUID when it first creates the row, + so the name is not client-supplied and a review cannot be attributed to + someone else. - A **review** belongs to one album and one simulated user. A user can have one review per album, so saving another review updates the existing one. Album - details can also return the current user's review to prefill an edit form. + details can also return the current user's review to prefill an edit form, + and each review carries the `User` who wrote it. - A **crate item** belongs to one simulated user and one album. It represents the user's personal wantlist. The schema has separate add and remove actions so repeated requests cannot accidentally switch the album back to the wrong state. The user identity and persistence of reviews and crate items are M2 work. The -schema already describes the operations the client will need. +schema already describes the operations the client will need. See +[`docs/architecture.md`](./architecture.md) decision 5 for how the identity +travels and what happens when it is missing. ## Search results diff --git a/schema.graphql b/schema.graphql index f8fc7f5..d7d1719 100644 --- a/schema.graphql +++ b/schema.graphql @@ -44,8 +44,14 @@ type Genre { name: String! } +type User { + id: ID! + displayName: String! +} + type Review { id: ID! + user: User! rating: Int! comment: String! createdAt: DateTime! From 56813d11d6e11e5227f360cb9e20c54e8f56c9c0 Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Mon, 12 Oct 2026 13:34:57 -0600 Subject: [PATCH 2/2] docs: #10 state precisely what displayName does and does not prevent robertky pointed out the wording overclaimed. Deriving displayName server-side stops the client choosing an arbitrary name string. It does not stop impersonation, because X-User-Id is client-supplied and anyone can send someone else's UUID and write as them. That is fine for simulated users, which is what the brief allows, but the docs should not say otherwise. Three places claimed a review "cannot be attributed to someone else", which is the wrong guarantee. Now stated as: the UUID separates visitors, it does not authenticate them. Deriving the name stops the name being chosen by the client, not the identity being claimed. Reported by robertky. Co-Authored-By: opencode --- AGENTS.md | 5 +++-- docs/architecture.md | 11 +++++++---- docs/data-model.md | 5 +++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d1269dc..6e93ec6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,9 @@ requirements work - do not casually change them. - The context is **per request**. Hoisting the user id into a module-level variable or a shared cache leaks one user's data into another. - `displayName` is derived server-side from the UUID when the user row is - first created. It is not client-supplied, so a review cannot be attributed - to someone else's name. + first created. That stops the client picking an arbitrary name, but it is + not an identity guarantee: the client can still send any UUID and write as + that user. See `docs/architecture.md` decision 5. 6. **Plain CSS.** No CSS framework, no component library. Smaller bundle for bærekraft, full control over focus rings and contrast for tilgjengelighet. 7. **Accessibility is not optional.** Every input needs a label or diff --git a/docs/architecture.md b/docs/architecture.md index 3c349e6..959dc7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,10 +106,13 @@ Reads never fail because of identity state, so a bad header cannot take the search page down. Writes require it and say so rather than attributing the change to nobody. -**Consequences:** Much less to build. There is no security, since anyone can -send another user's id, and clearing storage gives a new user. `displayName` is -derived from the UUID when the row is first created rather than sent by the -client, so a review cannot be attributed to a name the sender did not earn. +**Consequences:** Much less to build, and no security. Sending someone else's +UUID in `X-User-Id` writes as that user, so this separates visitors rather than +authenticating them. Deriving `displayName` from the UUID stops the client +choosing an arbitrary name string and stops a review claiming a display name +the sender did not hold, but it does **not** stop impersonation: the UUID is +the whole identity and it is client-supplied. Clearing storage gives a new +user. ## 6. Plain CSS, Apollo Client cache, URL as search state diff --git a/docs/data-model.md b/docs/data-model.md index 0e5b32d..3708cb2 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -21,8 +21,9 @@ The two many-to-many relationships will use join tables in PostgreSQL in M2. - A **user** is a simulated identity: a UUID the client keeps in `localStorage` and sends as an `X-User-Id` header on every request. There is no login. The server derives a `displayName` from the UUID when it first creates the row, - so the name is not client-supplied and a review cannot be attributed to - someone else. + so the client cannot choose an arbitrary name. It is still not + authentication, because the client can send any UUID. See + [`docs/architecture.md`](./architecture.md) decision 5. - A **review** belongs to one album and one simulated user. A user can have one review per album, so saving another review updates the existing one. Album details can also return the current user's review to prefill an edit form,