Identity: sign-in, session and seat resolution for a Game Master.
Deliberately NOT under game.protocols. Everything in that package is a
pure event handler with a manifest, dispatched from MoM through
/v1/gm/handle and returning :updated-state. This is the opposite shape: HTTP
routes served to a browser, proxying MoM, holding no game state and
advertising no handlers. It belongs with the runtime concerns (config,
dispatch, heartbeat) rather than the game protocols.
The flow, end to end:
A seat is a game membership owned by the signed-in human, matched by
owner-id or owner-email. Most authenticated reads are seat-scoped, so
require-selected-seat is the usual entry point for a route.
Game-specific behaviour is injected through an options map rather than
baked in — see session-context.
Identity: sign-in, session and seat resolution for a Game Master.
Deliberately NOT under `game.protocols`. Everything in that package is a
pure event handler with a manifest, dispatched from MoM through
/v1/gm/handle and returning :updated-state. This is the opposite shape: HTTP
routes served to a *browser*, proxying MoM, holding no game state and
advertising no handlers. It belongs with the runtime concerns (config,
dispatch, heartbeat) rather than the game protocols.
The flow, end to end:
1. the browser GETs /auth.json from the GM it is talking to
2. it redirects to the hosted auth app with ?config=<that url>
3. the auth app signs the user in and redirects to :redirect_uri
(/auth/callback on this GM)
4. the GM bounces to the app with the token on the query string
5. the browser stores the token and sends it as a Bearer header
A *seat* is a game membership owned by the signed-in human, matched by
owner-id or owner-email. Most authenticated reads are seat-scoped, so
`require-selected-seat` is the usual entry point for a route.
Game-specific behaviour is injected through an options map rather than
baked in — see `session-context`.(auth-config-response request)(auth-config-response request {:keys [app-base-url]})The config document the browser hands to the hosted auth app.
The callback URLs are derived from the incoming request, so a GM reached on any hostname sends the user back to that same hostname. When the app is served from a different origin than the GM (a static marketing site talking to a GM on another subdomain), pass :app-base-url and the post-auth redirect targets that origin instead.
The config document the browser hands to the hosted auth app. The callback URLs are derived from the *incoming request*, so a GM reached on any hostname sends the user back to that same hostname. When the app is served from a different origin than the GM (a static marketing site talking to a GM on another subdomain), pass :app-base-url and the post-auth redirect targets that origin instead.
(auth-header headers)The caller's Authorization header, however the servlet cased it.
The caller's Authorization header, however the servlet cased it.
(auth-response request)(claim-membership-response request game-name)(claim-membership-response request game-name mom-base)Enrol the signed-in caller under a name THEY choose, via MoM's /ensure-membership.
ensure-audience generates a handle — aud-8253156e0ee641749af98a0e4a13 — and MoM has no rename, deliberately: player-name is the join key across protocol state and the ledger account key <game-id>/<player-name>. So enrolment is the only moment a person can get a name they would want as a public byline, and a GM that auto-enrols them silently spends it.
The caller's own token is forwarded because MoM takes the owner from the verified token and refuses a service credential — the GM cannot assert a human identity here, which is the property that makes self-chosen names safe.
MoM's status and body pass through untouched. :player-name-taken and :invalid-player-name are both things the person can act on, so flattening them into a generic failure would leave the UI unable to say why.
Enrol the signed-in caller under a name THEY choose, via MoM's /ensure-membership. ensure-audience generates a handle — aud-8253156e0ee641749af98a0e4a13 — and MoM has no rename, deliberately: player-name is the join key across protocol state and the ledger account key <game-id>/<player-name>. So enrolment is the only moment a person can get a name they would want as a public byline, and a GM that auto-enrols them silently spends it. The caller's own token is forwarded because MoM takes the owner from the verified token and refuses a service credential — the GM cannot assert a human identity here, which is the property that makes self-chosen names safe. MoM's status and body pass through untouched. :player-name-taken and :invalid-player-name are both things the person can act on, so flattening them into a generic failure would leave the UI unable to say why.
The auth app runs on its own origin (auth.intergraph.ai) and calls back to the GM, so these routes are genuinely cross-origin. Permissive by necessity, and safe because /auth/login proxies credentials to MoM and returns only what MoM returns — there is no ambient session for a hostile origin to ride.
The auth app runs on its own origin (auth.intergraph.ai) and calls back to the GM, so these routes are genuinely cross-origin. Permissive by necessity, and safe because /auth/login proxies credentials to MoM and returns only what MoM returns — there is no ambient session for a hostile origin to ride.
(cors-preflight-response)204 for an OPTIONS preflight. Load-bearing: without it the browser never sends the POST at all, so the failure shows up as a network error with no corresponding request in the server log.
204 for an OPTIONS preflight. Load-bearing: without it the browser never sends the POST at all, so the failure shows up as a network error with no corresponding request in the server log.
(ensure-audience-membership request game-name)(ensure-audience-membership request game-name mom-base)Idempotently give the signed-in caller a seat, via MoM's /ensure-audience.
Passed to session-context as :ensure-membership-fn. A GM that omits it
never enrols anyone: the visitor signs in, gets a Clerk session, returns to
the site — and has no membership, so every seat-gated surface treats them as
a stranger. AoM shipped that way and its member list stayed empty.
The caller's OWN token is forwarded, deliberately. MoM takes the owner from the verified token and refuses a service credential outright, so the GM's credential cannot stand in for a human here.
A transport failure returns 502 rather than throwing, because this runs inside session assembly — an escaping exception would fail the whole page rather than one optional enrolment.
Idempotently give the signed-in caller a seat, via MoM's /ensure-audience. Passed to `session-context` as :ensure-membership-fn. A GM that omits it never enrols anyone: the visitor signs in, gets a Clerk session, returns to the site — and has no membership, so every seat-gated surface treats them as a stranger. AoM shipped that way and its member list stayed empty. The caller's OWN token is forwarded, deliberately. MoM takes the owner from the verified token and refuses a service credential outright, so the GM's credential cannot stand in for a human here. A transport failure returns 502 rather than throwing, because this runs inside session assembly — an escaping exception would fail the whole page rather than one optional enrolment.
(fetch-game-memberships request game-name)The MoM call behind seat resolution. Public because it is the natural seam to stub when testing seat matching without a live MoM.
The MoM call behind seat resolution. Public because it is the natural seam to stub when testing seat matching without a live MoM.
(fetch-owned-seats request identity game-name)Every seat in the game this identity owns, plus diagnostics explaining an empty result — the difference between 'game missing', 'no memberships' and 'memberships exist but none are linked to you' is the whole debugging story when a user cannot see their seat.
Every seat in the game this identity owns, plus diagnostics explaining an empty result — the difference between 'game missing', 'no memberships' and 'memberships exist but none are linked to you' is the whole debugging story when a user cannot see their seat.
(load-auth-config)Reads auth.json from the classpath. Each game supplies its own, carrying app name, logo, theme and the auth app's base URL.
Reads auth.json from the classpath. Each game supplies its own, carrying app name, logo, theme and the auth app's base URL.
(login-response request)(login-response request mom-base)Proxy a sign-in attempt to MoM's /auth/login.
The hosted auth app POSTs to <app-origin>/auth/login on the GM, never to MoM directly — it only knows the URLs in /auth.json. A GM without this route answers 404, and the app reports "Failed to fetch (status=network)", which reads like CORS or DNS rather than a missing endpoint.
Lives here rather than in each game because nothing about it is game-specific: it forwards a body and returns MoM's answer verbatim. the-js had a private copy in src/game/handlers.clj and AoM was scaffolded without one — the third piece of the browser auth surface AoM turned out to be missing, after the Clerk publishable key and the sign-in link itself.
MoM's status is passed through untouched so a caller can still tell a bad credential (401) from a dead MoM (502).
Proxy a sign-in attempt to MoM's /auth/login. The hosted auth app POSTs to <app-origin>/auth/login on the GM, never to MoM directly — it only knows the URLs in /auth.json. A GM without this route answers 404, and the app reports "Failed to fetch (status=network)", which reads like CORS or DNS rather than a missing endpoint. Lives here rather than in each game because nothing about it is game-specific: it forwards a body and returns MoM's answer verbatim. the-js had a private copy in src/game/handlers.clj and AoM was scaffolded without one — the third piece of the browser auth surface AoM turned out to be missing, after the Clerk publishable key and the sign-in link itself. MoM's status is passed through untouched so a caller can still tell a bad credential (401) from a dead MoM (502).
(mom-url)(mom-url payload)MoM base URL. Accepts an optional dispatch payload so a GM handler can honour a per-request :system :mom-url override.
MoM base URL. Accepts an optional dispatch payload so a GM handler can honour a per-request :system :mom-url override.
(normalize-seat entry)Flatten one membership entry into a seat. Returns nil without a player-name, since a seat with no name cannot be selected or matched.
Flatten one membership entry into a seat. Returns nil without a player-name, since a seat with no name cannot be selected or matched.
(owns-seat? identity seat)(parse-roles data)(proxy-me mom-base token)Verify a caller's token against MoM and return their identity.
Identity comes from MoM's /admin/whoami, not from the token. This matters:
MoM resolves an OIDC token to an internal user UUID during verification,
and that UUID is what :membership/owner-id stores. The token itself carries
only the provider's subject (Clerk's sub), so a seat lookup keyed on it
matches nothing — a person with a seat looks like a stranger, and any
first-visit self-enrolment then mints them a duplicate membership.
Roles still come from the token's claims: whoami reports identity, not authorization.
Falls back to the old token-derived identity when whoami is unavailable (404 on a MoM predating it, or 403 for a non-human credential), so a Game Master is not hard-coupled to a particular MoM version. A non-2xx from the fallback verify is passed straight through, so callers can still tell a dead token (401) from a dead MoM (502).
Verify a caller's token against MoM and return their identity. Identity comes from MoM's /admin/whoami, not from the token. This matters: MoM resolves an OIDC token to an *internal user UUID* during verification, and that UUID is what :membership/owner-id stores. The token itself carries only the provider's subject (Clerk's `sub`), so a seat lookup keyed on it matches nothing — a person with a seat looks like a stranger, and any first-visit self-enrolment then mints them a duplicate membership. Roles still come from the token's claims: whoami reports identity, not authorization. Falls back to the old token-derived identity when whoami is unavailable (404 on a MoM predating it, or 403 for a non-human credential), so a Game Master is not hard-coupled to a particular MoM version. A non-2xx from the fallback verify is passed straight through, so callers can still tell a dead token (401) from a dead MoM (502).
(request-base-url request)This GM's externally-visible base URL. Honours x-forwarded-proto so the URLs handed to the auth app are https behind a load balancer.
This GM's externally-visible base URL. Honours x-forwarded-proto so the URLs handed to the auth app are https behind a load balancer.
(request-player-name request)Which seat the caller says they are acting as. Header, query param and body are all accepted because different clients set it differently.
Which seat the caller says they are acting as. Header, query param and body are all accepted because different clients set it differently.
(request-user-auth request req)Forward the caller's token rather than the GM's service credentials. Used wherever MoM must re-verify the human, not trust this backend.
Forward the *caller's* token rather than the GM's service credentials. Used wherever MoM must re-verify the human, not trust this backend.
(require-authenticated-session request)(require-authenticated-session request opts)(require-game-context request)(require-game-context request opts)(require-selected-seat request)(require-selected-seat request opts)(safe-local-ui-filename? filename)(seat-match-reason identity seat)Why this identity owns this seat, or nil. Kept as a reason rather than a boolean because the diagnostics distinguish an id match from an email one.
Why this identity owns this seat, or nil. Kept as a reason rather than a boolean because the diagnostics distinguish an id match from an email one.
(seat-role-set seat)(seat-summary seat)The client-facing shape of a seat.
The client-facing shape of a seat.
(seats-with-any-role? seats roles)Whether any seat carries one of roles. Games use this for role-gated
surfaces; which roles matter is a game decision, so it is passed in.
Whether any seat carries one of `roles`. Games use this for role-gated surfaces; which roles matter is a game decision, so it is passed in.
(selected-seat request seats)(session-context request)(session-context request
{:keys [game-id game-name gated-roles ensure-membership-fn
select-sole-seat?]})Resolve the caller into {authenticated, identity, roles, seats, selected-seat}.
Options let a game inject its own behaviour without forking this:
:game-id / :game-name the current game (usually the runtime atoms) :gated-roles roles that unlock a game-specific surface; the result appears as :gated-eligible :ensure-membership-fn (fn [request game-name] -> ring response) called only when an authenticated user owns no seat, to self-provision one. Omit to leave such a user seatless — which is the right default, since auto-enrolling everyone who signs in is a policy choice, not a mechanism.
Resolve the caller into {authenticated, identity, roles, seats, selected-seat}.
Options let a game inject its own behaviour without forking this:
:game-id / :game-name the current game (usually the runtime atoms)
:gated-roles roles that unlock a game-specific surface; the
result appears as :gated-eligible
:ensure-membership-fn (fn [request game-name] -> ring response) called
only when an authenticated user owns no seat, to
self-provision one. Omit to leave such a user
seatless — which is the right default, since
auto-enrolling everyone who signs in is a policy
choice, not a mechanism.(session-response-body ctx)(session-response-body {:keys [authenticated identity roles game-id game-name
seats selected-seat seat-diagnostics
audience-provisioned default-seat-selected
auth-status]}
extra)The /session payload. extra is merged last so a game can add its own
fields without this namespace knowing about them.
The /session payload. `extra` is merged last so a game can add its own fields without this namespace knowing about them.
(submit-service-event game-name event-type player-name roles event-data)Posts an event to MoM using the GM's service credentials, impersonating
player-name with roles (vector of strings). Used where the GM must act on
behalf of a known seat with no user session — webhook handlers, and the
anonymous visitor routes, whose caller has no seat at all.
Posts an event to MoM using the GM's *service* credentials, impersonating `player-name` with `roles` (vector of strings). Used where the GM must act on behalf of a known seat with no user session — webhook handlers, and the anonymous visitor routes, whose caller has no seat at all.
(ui-file-response filename)Serve a static document from resources/ui. No-store because a SPA carries its own state and a cached shell strands users on an old build.
Serve a static document from resources/ui. No-store because a SPA carries its own state and a cached shell strands users on an old build.
(ui-local-file-response filename)Serve an image bundled under resources/ui/files. Filename is pattern-checked rather than path-resolved, so it cannot escape the directory.
Serve an image bundled under resources/ui/files. Filename is pattern-checked rather than path-resolved, so it cannot escape the directory.
(ui-redirect-with-query request)(ui-redirect-with-query request {:keys [app-base-url]})Bounce back to the app, preserving the query string the auth app appended (that is how the token reaches the browser).
Defaults to a relative redirect to this origin's root, which is what a GM serving its own SPA wants. Pass :app-base-url only when the app lives on a different origin — deliberately not defaulted from APP_BASE_URL, because that variable is already set for GMs that serve their own UI and silently turning their redirect absolute would be a behaviour change.
Bounce back to the app, preserving the query string the auth app appended (that is how the token reaches the browser). Defaults to a *relative* redirect to this origin's root, which is what a GM serving its own SPA wants. Pass :app-base-url only when the app lives on a different origin — deliberately not defaulted from APP_BASE_URL, because that variable is already set for GMs that serve their own UI and silently turning their redirect absolute would be a behaviour change.
(user-identity body)(with-auth req)Attach the GM's own service credentials to an outbound MoM request.
Attach the GM's own service credentials to an outbound MoM request.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |