From 560b7b1d109e4d397f0204fe21ee36bd661c5191 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:17:07 -0400 Subject: [PATCH 01/12] Use staging games host outside production and accept a token The external_game route now reads its host from one config value. Production uses endsideoutgames.netlify.app. Every other environment uses staging--endsideout-games.netlify.app. The route also accepts an optional token and adds it to the URL as a GET parameter. Co-Authored-By: Claude Sonnet 5.5 --- config/initializers/external_games.rb | 8 ++++++++ config/routes.rb | 8 ++++++-- test/integration/external_game_route_test.rb | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 config/initializers/external_games.rb create mode 100644 test/integration/external_game_route_test.rb diff --git a/config/initializers/external_games.rb b/config/initializers/external_games.rb new file mode 100644 index 0000000..fabaccc --- /dev/null +++ b/config/initializers/external_games.rb @@ -0,0 +1,8 @@ +# Host that serves the EndsideOut games. The games are embedded in an iframe and +# call back into the game_attempts API, so this host is also the CORS allowlist. +Rails.application.config.x.external_games.host = + if Rails.env.production? + "endsideoutgames.netlify.app" + else + "staging--endsideout-games.netlify.app" + end diff --git a/config/routes.rb b/config/routes.rb index ba10797..ffe155b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,8 +1,12 @@ require "uri" Rails.application.routes.draw do - direct :external_game do |slug| - URI::HTTPS.build(host: "endsideoutgames.netlify.app", path: "/games/#{slug}").to_s + direct :external_game do |slug, options| + URI::HTTPS.build( + host: Rails.configuration.x.external_games.host, + path: "/games/#{slug}", + query: options&.slice(:token)&.to_query.presence + ).to_s end namespace :admin do diff --git a/test/integration/external_game_route_test.rb b/test/integration/external_game_route_test.rb new file mode 100644 index 0000000..24ce2f7 --- /dev/null +++ b/test/integration/external_game_route_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class ExternalGameRouteTest < ActionDispatch::IntegrationTest + test "points at the staging games host outside production" do + assert_equal "staging--endsideout-games.netlify.app", Rails.configuration.x.external_games.host + assert_equal "https://staging--endsideout-games.netlify.app/games/game-1", external_game_url("game-1") + end + + test "adds the token as a query parameter" do + assert_equal "https://staging--endsideout-games.netlify.app/games/game-1?token=abc%3D%3D", + external_game_url("game-1", token: "abc==") + end + + private + + def external_game_url(...) = Rails.application.routes.url_helpers.external_game_url(...) +end From fe02eff6eb2c994b619c85b7f937068d52e1cc72 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:17:13 -0400 Subject: [PATCH 02/12] Let games on the games host call the game attempts API Games run on another origin and have no Rails session, so the start and finish endpoints now skip CSRF protection. The signed token is the credential. The endpoints send CORS headers only for the games host and answer OPTIONS preflight requests. A missing or changed token now returns a 422 JSON error with the message "Invalid token" instead of a 500. Co-Authored-By: Claude Sonnet 5.5 --- app/controllers/game_attempts_controller.rb | 21 ++++++ config/routes.rb | 2 + .../game_attempts_controller_test.rb | 71 +++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/app/controllers/game_attempts_controller.rb b/app/controllers/game_attempts_controller.rb index 03b0fff..3b2f559 100644 --- a/app/controllers/game_attempts_controller.rb +++ b/app/controllers/game_attempts_controller.rb @@ -1,5 +1,13 @@ class GameAttemptsController < ApplicationController + # Games run on another origin and have no Rails session. The signed token is the credential. + skip_forgery_protection + before_action :game_attempt_from_token, only: %i[start finish] + after_action :set_cors_headers + + rescue_from ActiveSupport::MessageVerifier::InvalidSignature do + render json: { status: "error", message: "Invalid token" }, status: :unprocessable_entity + end def start @game_attempt.start! @@ -18,6 +26,10 @@ def finish end end + def preflight + head :no_content + end + private def game_attempt_from_token @game_attempt = GameAttempt.find_by(token: params[:token]) @@ -35,4 +47,13 @@ def create_game_attempt_params def game_attempt_params params.expect(game_attempt: [ :outcome, :score ]) end + + def set_cors_headers + response.headers["Vary"] = [ response.headers["Vary"], "Origin" ].compact_blank.join(", ") + return unless request.origin == "https://#{Rails.configuration.x.external_games.host}" + + response.headers["Access-Control-Allow-Origin"] = request.origin + response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + end end diff --git a/config/routes.rb b/config/routes.rb index ffe155b..498f392 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,6 +41,8 @@ collection do post :start post :finish + match :start, via: :options, action: :preflight, as: nil + match :finish, via: :options, action: :preflight, as: nil end end diff --git a/test/controllers/game_attempts_controller_test.rb b/test/controllers/game_attempts_controller_test.rb index e272041..bb24d85 100644 --- a/test/controllers/game_attempts_controller_test.rb +++ b/test/controllers/game_attempts_controller_test.rb @@ -56,4 +56,75 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest assert_equal "error", response_json["status"] assert_equal "Game attempt has not been started", response_json["message"] end + + test "rejects a tampered token with a JSON error" do + post start_game_attempts_url, params: { token: "not-a-real-token" }, as: :json + assert_response :unprocessable_entity + + response_json = JSON.parse(response.body) + assert_equal "error", response_json["status"] + assert_equal "Invalid token", response_json["message"] + assert_equal 0, GameAttempt.where(token: "not-a-real-token").count + end + + test "accepts a JSON body" do + token = valid_token + + post start_game_attempts_url, params: { token: token }, as: :json + assert_response :success + assert GameAttempt.find_by(token: token).started? + end + + test "does not require a CSRF token" do + with_forgery_protection do + post start_game_attempts_url, params: { token: valid_token }, as: :json + assert_response :success + end + end + + test "allows CORS requests from the games host" do + origin = "https://#{Rails.configuration.x.external_games.host}" + + post start_game_attempts_url, params: { token: valid_token }, as: :json, headers: { "Origin" => origin } + assert_response :success + assert_equal origin, response.headers["Access-Control-Allow-Origin"] + assert_includes response.headers["Vary"], "Origin" + end + + test "does not allow CORS requests from other origins" do + post start_game_attempts_url, params: { token: valid_token }, as: :json, + headers: { "Origin" => "https://example.com" } + assert_nil response.headers["Access-Control-Allow-Origin"] + end + + test "answers CORS preflight requests for start and finish" do + origin = "https://#{Rails.configuration.x.external_games.host}" + + %i[start finish].each do |action| + process :options, url_for(controller: "game_attempts", action: action, only_path: true), + headers: { "Origin" => origin, "Access-Control-Request-Method" => "POST" } + + assert_response :no_content + assert_equal origin, response.headers["Access-Control-Allow-Origin"] + assert_equal "POST, OPTIONS", response.headers["Access-Control-Allow-Methods"] + assert_equal "Content-Type", response.headers["Access-Control-Allow-Headers"] + end + end + + private + + def valid_token + GameAttempt.generate_token( + student_id: game_attempts(:one).student_id, + game_id: game_attempts(:one).game_id + ) + end + + def with_forgery_protection + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + yield + ensure + ActionController::Base.allow_forgery_protection = original + end end From dbeff5ffbfbea250e6706be61a6231c72c07a01b Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:17:21 -0400 Subject: [PATCH 03/12] Embed the real game iframe and remove the stimulus quiz The play page now embeds the game from the games host and passes the signed game attempt token as the token GET parameter. The fake quiz is no longer needed, so this removes the quiz controller, the embed page and layout, and the embed route. Co-Authored-By: Claude Sonnet 5.5 --- app/controllers/game_landing_controller.rb | 6 -- app/javascript/controllers/quiz_controller.js | 73 ------------------- app/views/game_landing/embed.html.erb | 7 -- app/views/game_landing/show.html.erb | 2 +- app/views/layouts/embed.html.erb | 16 ---- config/routes.rb | 1 - .../game_landing_controller_test.rb | 15 ++-- 7 files changed, 9 insertions(+), 111 deletions(-) delete mode 100644 app/javascript/controllers/quiz_controller.js delete mode 100644 app/views/game_landing/embed.html.erb delete mode 100644 app/views/layouts/embed.html.erb diff --git a/app/controllers/game_landing_controller.rb b/app/controllers/game_landing_controller.rb index 95795b4..2bd46ce 100644 --- a/app/controllers/game_landing_controller.rb +++ b/app/controllers/game_landing_controller.rb @@ -1,8 +1,6 @@ class GameLandingController < ApplicationController include StudentAuthentication - layout "embed", only: :embed - def show @game = Game.find(params[:id]) @game_attempt_token = GameAttempt.generate_token(student_id: current_student.id, game_id: @game.id) @@ -10,10 +8,6 @@ def show logger.info("game attempt token: #{{ game_attempt_token: @game_attempt_token, student_id: current_student.id, game_id: @game.id }}") end - def embed - @game = Game.find(params[:id]) - end - private def current_student = Current.student diff --git a/app/javascript/controllers/quiz_controller.js b/app/javascript/controllers/quiz_controller.js deleted file mode 100644 index 5acdf8c..0000000 --- a/app/javascript/controllers/quiz_controller.js +++ /dev/null @@ -1,73 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -const QUESTIONS = [ - { text: "The sky is blue.", answer: true }, - { text: "Fish can breathe air out of water.", answer: false }, - { text: "The earth orbits the sun.", answer: true } -] - -export default class extends Controller { - static targets = ["root"] - static values = { startUrl: String, finishUrl: String } - - connect() { - this.token = new URLSearchParams(window.location.search).get("token") - this.questionIndex = 0 - this.correctCount = 0 - this.started = false - this.renderQuestion() - } - - renderQuestion() { - const question = QUESTIONS[this.questionIndex] - this.rootTarget.innerHTML = ` -

${question.text}

-
- - -
- ` - this.rootTarget.querySelectorAll("button").forEach((button) => { - button.addEventListener("click", () => this.answer(button.dataset.answer === "true")) - }) - } - - async answer(value) { - if (!this.started) { - this.started = true - await this.post(this.startUrlValue) - } - - if (value === QUESTIONS[this.questionIndex].answer) this.correctCount += 1 - - this.questionIndex += 1 - if (this.questionIndex < QUESTIONS.length) { - this.renderQuestion() - } else { - await this.finish() - } - } - - async finish() { - const outcome = this.correctCount === QUESTIONS.length ? "pass" : "fail" - - await this.post(this.finishUrlValue, { - game_attempt: { outcome, score: this.correctCount } - }) - - this.rootTarget.innerHTML = `

You scored ${this.correctCount} out of ${QUESTIONS.length}.

` - } - - async post(url, extraParams = {}) { - const csrfToken = document.querySelector("meta[name='csrf-token']")?.content - - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken - }, - body: JSON.stringify({ token: this.token, ...extraParams }) - }) - } -} diff --git a/app/views/game_landing/embed.html.erb b/app/views/game_landing/embed.html.erb deleted file mode 100644 index bbc39d5..0000000 --- a/app/views/game_landing/embed.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%= tag.div data: { - controller: "quiz", - quiz_start_url_value: start_game_attempts_path, - quiz_finish_url_value: finish_game_attempts_path - } do %> -
-<% end %> diff --git a/app/views/game_landing/show.html.erb b/app/views/game_landing/show.html.erb index 78de03c..fba3162 100644 --- a/app/views/game_landing/show.html.erb +++ b/app/views/game_landing/show.html.erb @@ -2,6 +2,6 @@

<%= @game.title %>

-<%= tag.iframe src: embed_game_path(@game, token: @game_attempt_token), +<%= tag.iframe src: external_game_url(@game.slug, token: @game_attempt_token), class: "w-full h-[600px] border-0", title: @game.title %> diff --git a/app/views/layouts/embed.html.erb b/app/views/layouts/embed.html.erb deleted file mode 100644 index a1a5e11..0000000 --- a/app/views/layouts/embed.html.erb +++ /dev/null @@ -1,16 +0,0 @@ - - - - <%= content_for(:title) || "Endsideout" %> - - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - - <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - - - - <%= yield %> - - diff --git a/config/routes.rb b/config/routes.rb index 498f392..a6c14c1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,7 +33,6 @@ resources :games, only: [] do member do get :play, to: "game_landing#show" - get :embed, to: "game_landing#embed" end end resources :classroom_rosters, only: %i[show], param: :uuid diff --git a/test/controllers/game_landing_controller_test.rb b/test/controllers/game_landing_controller_test.rb index 1d00cd5..10cb7cf 100644 --- a/test/controllers/game_landing_controller_test.rb +++ b/test/controllers/game_landing_controller_test.rb @@ -13,21 +13,22 @@ class GameLandingControllerTest < ActionDispatch::IntegrationTest assert_redirected_to new_student_session_url end - test "show generates a game attempt token and embeds the game" do + test "show generates a game attempt token and embeds the external game" do get play_game_url(@game) assert_response :success - iframe_src = assert_select("iframe").first["src"] - token = Rack::Utils.parse_nested_query(URI(iframe_src).query)["token"] + iframe_src = URI(assert_select("iframe").first["src"]) + assert_equal Rails.configuration.x.external_games.host, iframe_src.host + assert_equal "/games/#{@game.slug}", iframe_src.path + + token = Rack::Utils.parse_nested_query(iframe_src.query)["token"] assert_equal( { "student_id" => @student.id, "game_id" => @game.id }, GameAttempt.verify_token(token) ) end - test "embed renders the quiz mount point" do - get embed_game_url(@game, token: "some-token") - assert_response :success - assert_select "[data-controller='quiz']" + test "the embed page no longer exists" do + assert_raises(NoMethodError) { embed_game_url(@game) } end end From 1947c6a5052a86d4bb56370b8aa6b1ab2012688d Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:17:25 -0400 Subject: [PATCH 04/12] Document the game attempts API for game developers Add docs/game-api.md. It explains the token GET parameter on the game URL, the start and finish endpoints, the error responses, and the hosts. It includes fetch and curl examples. The README links to it. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 + docs/game-api.md | 134 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 docs/game-api.md diff --git a/README.md b/README.md index 986085c..c648578 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Curriculum is delivered through **schools** and their **classrooms**. A classroo See [Architecture Decision Records (ADRs)](docs/adrs/README.md) for architectural decisions and guidelines. +See [Game API](docs/game-api.md) for how games report start and finish to the LMS. + ## Getting Started 🛠️ Built on **Ruby on Rails 8** with Hotwire, Tailwind, SQLite, and the Solid stack (Queue/Cache/Cable), deployed with Kamal. diff --git a/docs/game-api.md b/docs/game-api.md new file mode 100644 index 0000000..0f855e2 --- /dev/null +++ b/docs/game-api.md @@ -0,0 +1,134 @@ +# Game API + +This document tells game developers how a game reports play to the EndsideOut LMS. +A game runs inside an iframe (a web page embedded in another page). The LMS shows the iframe to a signed-in student. + +## How a game starts + +The LMS loads your game at this address: + +``` +https:///games/?token= +``` + +The `slug` is the short name of the game in the LMS. The `token` is a signed value that identifies one play session of one student. +The token is a GET parameter (a value in the URL query string). + +When your game loads, read the `token` from the URL. For example: + +```js +const token = new URLSearchParams(window.location.search).get("token") +``` + +Send this exact value in every call to the LMS. Do not change it, and do not decode it. +If the game loads without a `token`, the game cannot report play. In that case, do not call the API. + +## Hosts + +| Environment | Game host | LMS host | +| --- | --- | --- | +| Production | `endsideoutgames.netlify.app` | `LMS_HOST` (ask EndsideOut for the production address) | +| Staging | `staging--endsideout-games.netlify.app` | `LMS_HOST` (ask EndsideOut for the staging address) | + +Only a game that runs on the matching game host can call the LMS from a browser. +A call from any other origin fails the browser CORS check (the browser rule for calls between sites). + +## Request rules + +- Send a `POST` request with the header `Content-Type: application/json`. +- Put the `token` in the JSON body. +- Do not send cookies. Do not send a CSRF token. The `token` is the only credential. +- Every response is JSON with a `status` field. The value is `ok` or `error`. An error also has a `message`. + +## Start a game + +When the student begins to play, call this once. + +``` +POST https://LMS_HOST/game_attempts/start +``` + +Body: + +```json +{ "token": "" } +``` + +Success response, status 200: + +```json +{ "status": "ok" } +``` + +Example: + +```js +await fetch(`https://${LMS_HOST}/game_attempts/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }) +}) +``` + +## Finish a game + +When the student finishes the game, call this once. You must call `start` first. + +``` +POST https://LMS_HOST/game_attempts/finish +``` + +Body: + +```json +{ + "token": "", + "game_attempt": { "outcome": "win", "score": 100 } +} +``` + +The `game_attempt` fields are: + +| Field | Type | Meaning | +| --- | --- | --- | +| `outcome` | string | The result of the play, for example `win`. | +| `score` | integer | The score of the student. | + +Success response, status 200: + +```json +{ "status": "ok" } +``` + +Example: + +```js +await fetch(`https://${LMS_HOST}/game_attempts/finish`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, game_attempt: { outcome: "win", score: 100 } }) +}) +``` + +## Errors + +If a call fails, it returns status 422. + +| Message | Cause | Fix | +| --- | --- | --- | +| `Invalid token` | The token is missing, changed, or not from the LMS. | Send the `token` from the page URL without any change. | +| `Game attempt has not been started` | You called `finish` before `start`. | Call `start` first. | + +## Try it with curl + +Replace `` with a token from a game page URL. + +```sh +curl -X POST https://LMS_HOST/game_attempts/start \ + -H "Content-Type: application/json" \ + -d '{"token":""}' + +curl -X POST https://LMS_HOST/game_attempts/finish \ + -H "Content-Type: application/json" \ + -d '{"token":"","game_attempt":{"outcome":"win","score":100}}' +``` From 1604a131a3116cc5f51f122cffbc9844093f114b Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:37:17 -0400 Subject: [PATCH 05/12] fix: update tests --- test/integration/external_game_route_test.rb | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/test/integration/external_game_route_test.rb b/test/integration/external_game_route_test.rb index 24ce2f7..e656698 100644 --- a/test/integration/external_game_route_test.rb +++ b/test/integration/external_game_route_test.rb @@ -1,17 +1,10 @@ require "test_helper" class ExternalGameRouteTest < ActionDispatch::IntegrationTest - test "points at the staging games host outside production" do - assert_equal "staging--endsideout-games.netlify.app", Rails.configuration.x.external_games.host - assert_equal "https://staging--endsideout-games.netlify.app/games/game-1", external_game_url("game-1") + test "the direct route works correctly" do + expected_host = "https://staging--endsideout-games.netlify.app" + assert_equal "#{expected_host}/games/game-1", external_game_url("game-1") + # ensure URL encoding is applied to the token query parameter + assert_equal "#{expected_host}/games/game-1?token=abc%3D%3D", external_game_url("game-1", token: "abc==") end - - test "adds the token as a query parameter" do - assert_equal "https://staging--endsideout-games.netlify.app/games/game-1?token=abc%3D%3D", - external_game_url("game-1", token: "abc==") - end - - private - - def external_game_url(...) = Rails.application.routes.url_helpers.external_game_url(...) end From 40ecd3769ab9619897668e73430701a25e769a76 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:49:54 -0400 Subject: [PATCH 06/12] fix: update finish! --- app/controllers/game_attempts_controller.rb | 3 +-- app/models/game_attempt.rb | 4 ++-- test/models/game_attempt_test.rb | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/controllers/game_attempts_controller.rb b/app/controllers/game_attempts_controller.rb index 3b2f559..50a8f9f 100644 --- a/app/controllers/game_attempts_controller.rb +++ b/app/controllers/game_attempts_controller.rb @@ -20,8 +20,7 @@ def finish return end - if @game_attempt.update(game_attempt_params) - @game_attempt.finish! + if @game_attempt.finish!(game_attempt_params) render json: { status: "ok" } end end diff --git a/app/models/game_attempt.rb b/app/models/game_attempt.rb index f0f7e85..44a0833 100644 --- a/app/models/game_attempt.rb +++ b/app/models/game_attempt.rb @@ -29,8 +29,8 @@ def started? started_at.present? end - def finish!(outcome = "completed") - update(finished_at: Time.current, outcome: outcome) + def finish!(params) + update(params.merge(finished_at: Time.current)) end def finished? diff --git a/test/models/game_attempt_test.rb b/test/models/game_attempt_test.rb index 35dfbf1..1d0844a 100644 --- a/test/models/game_attempt_test.rb +++ b/test/models/game_attempt_test.rb @@ -9,20 +9,20 @@ class GameAttemptTest < ActiveSupport::TestCase attempt.start! assert attempt.started? + refute attempt.finished? end - test "can complete a game attempt" do + test "can finish a game attempt" do attempt = GameAttempt.create(student: students(:one), game: games(:one)) attempt.start! refute attempt.finished? - attempt.finish!("completed") + attempt.finish!(outcome: "win", score: 100) assert attempt.finished? end - test "new game attempts require a token" do attempt = GameAttempt.new(student: students(:one), game: games(:one)) assert_not attempt.valid? From dc21a0718484e9dfc27353dc6b6504d402b75851 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:52:50 -0400 Subject: [PATCH 07/12] fix: add hosts --- docs/game-api.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/game-api.md b/docs/game-api.md index 0f855e2..143ee6c 100644 --- a/docs/game-api.md +++ b/docs/game-api.md @@ -25,10 +25,10 @@ If the game loads without a `token`, the game cannot report play. In that case, ## Hosts -| Environment | Game host | LMS host | -| --- | --- | --- | -| Production | `endsideoutgames.netlify.app` | `LMS_HOST` (ask EndsideOut for the production address) | -| Staging | `staging--endsideout-games.netlify.app` | `LMS_HOST` (ask EndsideOut for the staging address) | +| Environment | Game host | LMS host | +| --- | --- |----------------------------------| +| Production | `endsideoutgames.netlify.app` | classroom.endsideout.org | +| Staging | `staging--endsideout-games.netlify.app` | staging-classroom.endsideout.org | Only a game that runs on the matching game host can call the LMS from a browser. A call from any other origin fails the browser CORS check (the browser rule for calls between sites). @@ -91,7 +91,7 @@ The `game_attempt` fields are: | Field | Type | Meaning | | --- | --- | --- | -| `outcome` | string | The result of the play, for example `win`. | +| `outcome` | string | The result of the play. | | `score` | integer | The score of the student. | Success response, status 200: From dabc74acbf350fda746be1bf50af999137fcbb32 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:54:44 -0400 Subject: [PATCH 08/12] Use rack-cors instead of hand-written CORS handling Replace the after_action headers, the preflight action, and the OPTIONS routes with the rack-cors middleware. The allowed origin is still the games host, and the rules for the game attempts API now live in config/initializers/cors.rb. The middleware answers preflight requests with status 200 instead of 204, so the preflight test now expects 200. Co-Authored-By: Claude Sonnet 5.5 --- Gemfile | 2 ++ Gemfile.lock | 5 +++++ app/controllers/game_attempts_controller.rb | 14 -------------- config/initializers/cors.rb | 11 +++++++++++ config/routes.rb | 2 -- test/controllers/game_attempts_controller_test.rb | 13 ++++++++----- 6 files changed, 26 insertions(+), 21 deletions(-) create mode 100644 config/initializers/cors.rb diff --git a/Gemfile b/Gemfile index 966eb80..b7a86da 100644 --- a/Gemfile +++ b/Gemfile @@ -73,3 +73,5 @@ end gem "tailwindcss-rails", "~> 4.6" # Used for database seeding gem "faker", "~> 3.8" +# Handles CORS for the game attempts API, which the games call from another origin +gem "rack-cors", "~> 3.0" diff --git a/Gemfile.lock b/Gemfile.lock index 53b095e..6d53f40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -245,6 +245,9 @@ GEM raabro (1.5.0) racc (1.8.1) rack (3.2.7) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -444,6 +447,7 @@ DEPENDENCIES kamal propshaft puma (>= 5.0) + rack-cors (~> 3.0) rails (~> 8.1.4) reactionview rubocop-rails-omakase @@ -573,6 +577,7 @@ CHECKSUMS raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-cors (3.0.0) sha256=7b95be61db39606906b61b83bd7203fa802b0ceaaad8fcb2fef39e097bf53f68 rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.3.0) sha256=b118d177cc002c572fc1b6fd8d19c9ed5881a36e8d6f689f9e618f9f861241e8 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 diff --git a/app/controllers/game_attempts_controller.rb b/app/controllers/game_attempts_controller.rb index 50a8f9f..60c75a1 100644 --- a/app/controllers/game_attempts_controller.rb +++ b/app/controllers/game_attempts_controller.rb @@ -3,7 +3,6 @@ class GameAttemptsController < ApplicationController skip_forgery_protection before_action :game_attempt_from_token, only: %i[start finish] - after_action :set_cors_headers rescue_from ActiveSupport::MessageVerifier::InvalidSignature do render json: { status: "error", message: "Invalid token" }, status: :unprocessable_entity @@ -25,10 +24,6 @@ def finish end end - def preflight - head :no_content - end - private def game_attempt_from_token @game_attempt = GameAttempt.find_by(token: params[:token]) @@ -46,13 +41,4 @@ def create_game_attempt_params def game_attempt_params params.expect(game_attempt: [ :outcome, :score ]) end - - def set_cors_headers - response.headers["Vary"] = [ response.headers["Vary"], "Origin" ].compact_blank.join(", ") - return unless request.origin == "https://#{Rails.configuration.x.external_games.host}" - - response.headers["Access-Control-Allow-Origin"] = request.origin - response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "Content-Type" - end end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 0000000..00e3fca --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,11 @@ +# The games run on another origin (see external_games.rb) and call the game +# attempts API from the browser. +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins "https://#{Rails.configuration.x.external_games.host}" + + resource "/game_attempts/*", + headers: %w[Content-Type], + methods: %i[post options] + end +end diff --git a/config/routes.rb b/config/routes.rb index a6c14c1..e6a9dd3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,8 +40,6 @@ collection do post :start post :finish - match :start, via: :options, action: :preflight, as: nil - match :finish, via: :options, action: :preflight, as: nil end end diff --git a/test/controllers/game_attempts_controller_test.rb b/test/controllers/game_attempts_controller_test.rb index bb24d85..09d9e80 100644 --- a/test/controllers/game_attempts_controller_test.rb +++ b/test/controllers/game_attempts_controller_test.rb @@ -101,13 +101,16 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest origin = "https://#{Rails.configuration.x.external_games.host}" %i[start finish].each do |action| - process :options, url_for(controller: "game_attempts", action: action, only_path: true), - headers: { "Origin" => origin, "Access-Control-Request-Method" => "POST" } + process :options, "/game_attempts/#{action}", headers: { + "Origin" => origin, + "Access-Control-Request-Method" => "POST", + "Access-Control-Request-Headers" => "Content-Type" + } - assert_response :no_content + assert_response :success assert_equal origin, response.headers["Access-Control-Allow-Origin"] - assert_equal "POST, OPTIONS", response.headers["Access-Control-Allow-Methods"] - assert_equal "Content-Type", response.headers["Access-Control-Allow-Headers"] + assert_includes response.headers["Access-Control-Allow-Methods"].downcase, "post" + assert_includes response.headers["Access-Control-Allow-Headers"].downcase, "content-type" end end From 68ebc6f13114ba4fb1a54e76908dbdd9618b06bf Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Sun, 25 Oct 2026 13:59:31 -0400 Subject: [PATCH 09/12] fix: remove unneeded test --- .../game_attempts_controller_test.rb | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/test/controllers/game_attempts_controller_test.rb b/test/controllers/game_attempts_controller_test.rb index 09d9e80..9f78cf0 100644 --- a/test/controllers/game_attempts_controller_test.rb +++ b/test/controllers/game_attempts_controller_test.rb @@ -75,17 +75,11 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest assert GameAttempt.find_by(token: token).started? end - test "does not require a CSRF token" do - with_forgery_protection do - post start_game_attempts_url, params: { token: valid_token }, as: :json - assert_response :success - end - end - test "allows CORS requests from the games host" do origin = "https://#{Rails.configuration.x.external_games.host}" post start_game_attempts_url, params: { token: valid_token }, as: :json, headers: { "Origin" => origin } + assert_response :success assert_equal origin, response.headers["Access-Control-Allow-Origin"] assert_includes response.headers["Vary"], "Origin" @@ -94,6 +88,7 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest test "does not allow CORS requests from other origins" do post start_game_attempts_url, params: { token: valid_token }, as: :json, headers: { "Origin" => "https://example.com" } + assert_nil response.headers["Access-Control-Allow-Origin"] end @@ -122,12 +117,4 @@ def valid_token game_id: game_attempts(:one).game_id ) end - - def with_forgery_protection - original = ActionController::Base.allow_forgery_protection - ActionController::Base.allow_forgery_protection = true - yield - ensure - ActionController::Base.allow_forgery_protection = original - end end From 4371a4f9eec1f52f632c49988eadda69cd16ce60 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Mon, 26 Oct 2026 20:23:53 -0400 Subject: [PATCH 10/12] fix: update url and docs --- config/routes.rb | 2 +- docs/game-api.md | 179 ++++++++++--------- test/integration/external_game_route_test.rb | 4 +- 3 files changed, 94 insertions(+), 91 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index e6a9dd3..cdf9e3e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,7 +4,7 @@ direct :external_game do |slug, options| URI::HTTPS.build( host: Rails.configuration.x.external_games.host, - path: "/games/#{slug}", + path: "/#{slug}", query: options&.slice(:token)&.to_query.presence ).to_s end diff --git a/docs/game-api.md b/docs/game-api.md index 143ee6c..21346e0 100644 --- a/docs/game-api.md +++ b/docs/game-api.md @@ -3,112 +3,129 @@ This document tells game developers how a game reports play to the EndsideOut LMS. A game runs inside an iframe (a web page embedded in another page). The LMS shows the iframe to a signed-in student. -## How a game starts +## Summary +- The LMS loads your game in an iframe with a `token` GET parameter in the URL. +- The game calls the LMS API using the token to report when the game starts and finishes. -The LMS loads your game at this address: +## API Request Documentation -``` -https:///games/?token= -``` - -The `slug` is the short name of the game in the LMS. The `token` is a signed value that identifies one play session of one student. -The token is a GET parameter (a value in the URL query string). - -When your game loads, read the `token` from the URL. For example: +### Start a game -```js -const token = new URLSearchParams(window.location.search).get("token") +```http +POST https://LMS_HOST/game_attempts/start +Content-Type: application/json ``` -Send this exact value in every call to the LMS. Do not change it, and do not decode it. -If the game loads without a `token`, the game cannot report play. In that case, do not call the API. - -## Hosts - -| Environment | Game host | LMS host | -| --- | --- |----------------------------------| -| Production | `endsideoutgames.netlify.app` | classroom.endsideout.org | -| Staging | `staging--endsideout-games.netlify.app` | staging-classroom.endsideout.org | - -Only a game that runs on the matching game host can call the LMS from a browser. -A call from any other origin fails the browser CORS check (the browser rule for calls between sites). - -## Request rules +**Request Body Schema:** -- Send a `POST` request with the header `Content-Type: application/json`. -- Put the `token` in the JSON body. -- Do not send cookies. Do not send a CSRF token. The `token` is the only credential. -- Every response is JSON with a `status` field. The value is `ok` or `error`. An error also has a `message`. +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "The signed token from the URL." + } + }, + "required": ["token"] +} +``` -## Start a game +**Example** +```json +{ + "token": "abcd" +} +``` -When the student begins to play, call this once. +### Finish a game -``` -POST https://LMS_HOST/game_attempts/start +```http +POST https://LMS_HOST/game_attempts/finish +Content-Type: application/json ``` -Body: +**Request Body Schema:** ```json -{ "token": "" } +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "The signed token from the URL." + }, + "game_attempt": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "description": "The result of the play.", + "enum": ["game_completed", "game_over"] + }, + "score": { + "type": "integer", + "description": "The score of the student." + } + }, + "required": ["outcome"] + } + }, + "required": ["token", "game_attempt"] +} ``` -Success response, status 200: - +**Example** ```json -{ "status": "ok" } +{ + "token": "abcd", + "game_attempt": { + "outcome": "game_completed", + "score": 93 + } +} ``` -Example: - -```js -await fetch(`https://${LMS_HOST}/game_attempts/start`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token }) -}) -``` -## Finish a game +## Details -When the student finishes the game, call this once. You must call `start` first. +The LMS loads the game in an iframe using the following URL: ``` -POST https://LMS_HOST/game_attempts/finish +https:///?token= ``` -Body: +The `slug` is the unique name of the game, such as `fruit-veggie-quiz`. The `token` is a signed value that identifies one play session of one student. +The token is a GET parameter (a value in the URL query string). -```json -{ - "token": "", - "game_attempt": { "outcome": "win", "score": 100 } -} +When your game loads, read the `token` from the URL. For example: + +```js +const token = new URLSearchParams(window.location.search).get("token") ``` -The `game_attempt` fields are: +Send this exact value in every call to the LMS. Do not change it, and do not decode it. +If the game loads without a `token`, the game cannot report play. In that case, do not call the API. -| Field | Type | Meaning | -| --- | --- | --- | -| `outcome` | string | The result of the play. | -| `score` | integer | The score of the student. | +## Hosts + +| Environment | Game host | LMS host | +| --- | --- |----------------------------------| +| Production | `endsideoutgames.netlify.app` | classroom.endsideout.org | +| Staging | `staging--endsideout-games.netlify.app` | staging-classroom.endsideout.org | -Success response, status 200: +Only a game that runs on the matching game host can call the LMS from a browser. +A call from any other origin fails the browser CORS check. -```json -{ "status": "ok" } -``` +## Request rules -Example: +- Send a `POST` request with the header `Content-Type: application/json`. +- Put the `token` in the JSON body. +- Do not send cookies. Do not send a CSRF token. The `token` is the only credential. +- Every response is JSON with a `status` field. The value is `ok` or `error`. An error also has a `message`. -```js -await fetch(`https://${LMS_HOST}/game_attempts/finish`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, game_attempt: { outcome: "win", score: 100 } }) -}) -``` ## Errors @@ -118,17 +135,3 @@ If a call fails, it returns status 422. | --- | --- | --- | | `Invalid token` | The token is missing, changed, or not from the LMS. | Send the `token` from the page URL without any change. | | `Game attempt has not been started` | You called `finish` before `start`. | Call `start` first. | - -## Try it with curl - -Replace `` with a token from a game page URL. - -```sh -curl -X POST https://LMS_HOST/game_attempts/start \ - -H "Content-Type: application/json" \ - -d '{"token":""}' - -curl -X POST https://LMS_HOST/game_attempts/finish \ - -H "Content-Type: application/json" \ - -d '{"token":"","game_attempt":{"outcome":"win","score":100}}' -``` diff --git a/test/integration/external_game_route_test.rb b/test/integration/external_game_route_test.rb index e656698..7391e56 100644 --- a/test/integration/external_game_route_test.rb +++ b/test/integration/external_game_route_test.rb @@ -3,8 +3,8 @@ class ExternalGameRouteTest < ActionDispatch::IntegrationTest test "the direct route works correctly" do expected_host = "https://staging--endsideout-games.netlify.app" - assert_equal "#{expected_host}/games/game-1", external_game_url("game-1") + assert_equal "#{expected_host}/game-1", external_game_url("game-1") # ensure URL encoding is applied to the token query parameter - assert_equal "#{expected_host}/games/game-1?token=abc%3D%3D", external_game_url("game-1", token: "abc==") + assert_equal "#{expected_host}/game-1?token=abc%3D%3D", external_game_url("game-1", token: "abc==") end end From 6191d1f2e8b2a1c4617c9a8256826f6673fe41ff Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Wed, 28 Oct 2026 20:01:59 -0400 Subject: [PATCH 11/12] fix: tweaks --- app/controllers/game_attempts_controller.rb | 2 +- app/controllers/game_landing_controller.rb | 3 --- test/controllers/game_attempts_controller_test.rb | 14 +++----------- test/controllers/game_landing_controller_test.rb | 4 ---- test/fixtures/game_attempts.yml | 2 +- 5 files changed, 5 insertions(+), 20 deletions(-) diff --git a/app/controllers/game_attempts_controller.rb b/app/controllers/game_attempts_controller.rb index 60c75a1..4956e0b 100644 --- a/app/controllers/game_attempts_controller.rb +++ b/app/controllers/game_attempts_controller.rb @@ -14,7 +14,7 @@ def start end def finish - if !@game_attempt.started? + unless @game_attempt.started? render json: { status: "error", message: "Game attempt has not been started" }, status: :unprocessable_entity return end diff --git a/app/controllers/game_landing_controller.rb b/app/controllers/game_landing_controller.rb index 2bd46ce..eeb23a5 100644 --- a/app/controllers/game_landing_controller.rb +++ b/app/controllers/game_landing_controller.rb @@ -4,12 +4,9 @@ class GameLandingController < ApplicationController def show @game = Game.find(params[:id]) @game_attempt_token = GameAttempt.generate_token(student_id: current_student.id, game_id: @game.id) - - logger.info("game attempt token: #{{ game_attempt_token: @game_attempt_token, student_id: current_student.id, game_id: @game.id }}") end private def current_student = Current.student - helper_method :current_student end diff --git a/test/controllers/game_attempts_controller_test.rb b/test/controllers/game_attempts_controller_test.rb index 9f78cf0..0f73a0a 100644 --- a/test/controllers/game_attempts_controller_test.rb +++ b/test/controllers/game_attempts_controller_test.rb @@ -7,7 +7,7 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest game_id: game_attempts(:one).game_id ) - post start_game_attempts_url, params: { token: token } + post start_game_attempts_url, params: { token: token }, as: :json assert_response :success response_json = JSON.parse(response.body) @@ -32,7 +32,7 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest assert_not attempt.finished? - post finish_game_attempts_url, params: { token: token, game_attempt: { outcome: "win", score: 100 } } + post finish_game_attempts_url, params: { token: token, game_attempt: { outcome: "win", score: 100 } }, as: :json assert_response :success response_json = JSON.parse(response.body) @@ -46,7 +46,7 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest game_id: game_attempts(:one).game_id ) - post finish_game_attempts_url, params: { token: token, game_attempt: { outcome: "win", score: 100 } } + post finish_game_attempts_url, params: { token: token, game_attempt: { outcome: "win", score: 100 } }, as: :json assert_response :unprocessable_entity # record is created, but not started, so it should not be finished @@ -67,14 +67,6 @@ class GameAttemptsControllerTest < ActionDispatch::IntegrationTest assert_equal 0, GameAttempt.where(token: "not-a-real-token").count end - test "accepts a JSON body" do - token = valid_token - - post start_game_attempts_url, params: { token: token }, as: :json - assert_response :success - assert GameAttempt.find_by(token: token).started? - end - test "allows CORS requests from the games host" do origin = "https://#{Rails.configuration.x.external_games.host}" diff --git a/test/controllers/game_landing_controller_test.rb b/test/controllers/game_landing_controller_test.rb index 10cb7cf..c91ba22 100644 --- a/test/controllers/game_landing_controller_test.rb +++ b/test/controllers/game_landing_controller_test.rb @@ -27,8 +27,4 @@ class GameLandingControllerTest < ActionDispatch::IntegrationTest GameAttempt.verify_token(token) ) end - - test "the embed page no longer exists" do - assert_raises(NoMethodError) { embed_game_url(@game) } - end end diff --git a/test/fixtures/game_attempts.yml b/test/fixtures/game_attempts.yml index 8e0dd1f..273c6ab 100644 --- a/test/fixtures/game_attempts.yml +++ b/test/fixtures/game_attempts.yml @@ -9,7 +9,7 @@ one: token: onetoken two: - outcome: expired + outcome: game_over started_at: 2026-08-29 11:53:38 finished_at: 2026-08-29 11:53:38 student: two From c19e5d5efe614420cd65b95b30f113d25457d7d5 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Wed, 28 Oct 2026 20:06:14 -0400 Subject: [PATCH 12/12] fix: update test --- test/controllers/game_landing_controller_test.rb | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/controllers/game_landing_controller_test.rb b/test/controllers/game_landing_controller_test.rb index c91ba22..ac3a507 100644 --- a/test/controllers/game_landing_controller_test.rb +++ b/test/controllers/game_landing_controller_test.rb @@ -19,12 +19,7 @@ class GameLandingControllerTest < ActionDispatch::IntegrationTest iframe_src = URI(assert_select("iframe").first["src"]) assert_equal Rails.configuration.x.external_games.host, iframe_src.host - assert_equal "/games/#{@game.slug}", iframe_src.path - - token = Rack::Utils.parse_nested_query(iframe_src.query)["token"] - assert_equal( - { "student_id" => @student.id, "game_id" => @game.id }, - GameAttempt.verify_token(token) - ) + assert_includes iframe_src.path, @game.slug + assert_includes iframe_src.query, "token=" end end