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/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/app/controllers/game_attempts_controller.rb b/app/controllers/game_attempts_controller.rb index 03b0fff..4956e0b 100644 --- a/app/controllers/game_attempts_controller.rb +++ b/app/controllers/game_attempts_controller.rb @@ -1,19 +1,25 @@ 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] + rescue_from ActiveSupport::MessageVerifier::InvalidSignature do + render json: { status: "error", message: "Invalid token" }, status: :unprocessable_entity + end + def start @game_attempt.start! render json: { status: "ok" } 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 - 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/controllers/game_landing_controller.rb b/app/controllers/game_landing_controller.rb index 95795b4..eeb23a5 100644 --- a/app/controllers/game_landing_controller.rb +++ b/app/controllers/game_landing_controller.rb @@ -1,21 +1,12 @@ 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) - - 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 - helper_method :current_student end 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/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/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/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/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..cdf9e3e 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: "/#{slug}", + query: options&.slice(:token)&.to_query.presence + ).to_s end namespace :admin do @@ -29,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/docs/game-api.md b/docs/game-api.md new file mode 100644 index 0000000..21346e0 --- /dev/null +++ b/docs/game-api.md @@ -0,0 +1,137 @@ +# 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. + +## 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. + +## API Request Documentation + +### Start a game + +```http +POST https://LMS_HOST/game_attempts/start +Content-Type: application/json +``` + +**Request Body Schema:** + +```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"] +} +``` + +**Example** +```json +{ + "token": "abcd" +} +``` + +### Finish a game + +```http +POST https://LMS_HOST/game_attempts/finish +Content-Type: application/json +``` + +**Request Body Schema:** + +```json +{ + "$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"] +} +``` + +**Example** +```json +{ + "token": "abcd", + "game_attempt": { + "outcome": "game_completed", + "score": 93 + } +} +``` + + +## Details + +The LMS loads the game in an iframe using the following URL: + +``` +https:///?token= +``` + +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). + +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` | 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. + +## 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`. + + +## 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. | diff --git a/test/controllers/game_attempts_controller_test.rb b/test/controllers/game_attempts_controller_test.rb index e272041..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 @@ -56,4 +56,57 @@ 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 "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, "/game_attempts/#{action}", headers: { + "Origin" => origin, + "Access-Control-Request-Method" => "POST", + "Access-Control-Request-Headers" => "Content-Type" + } + + assert_response :success + assert_equal origin, response.headers["Access-Control-Allow-Origin"] + assert_includes response.headers["Access-Control-Allow-Methods"].downcase, "post" + assert_includes response.headers["Access-Control-Allow-Headers"].downcase, "content-type" + end + end + + private + + def valid_token + GameAttempt.generate_token( + student_id: game_attempts(:one).student_id, + game_id: game_attempts(:one).game_id + ) + end end diff --git a/test/controllers/game_landing_controller_test.rb b/test/controllers/game_landing_controller_test.rb index 1d00cd5..ac3a507 100644 --- a/test/controllers/game_landing_controller_test.rb +++ b/test/controllers/game_landing_controller_test.rb @@ -13,21 +13,13 @@ 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"] - 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']" + iframe_src = URI(assert_select("iframe").first["src"]) + assert_equal Rails.configuration.x.external_games.host, iframe_src.host + assert_includes iframe_src.path, @game.slug + assert_includes iframe_src.query, "token=" 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 diff --git a/test/integration/external_game_route_test.rb b/test/integration/external_game_route_test.rb new file mode 100644 index 0000000..7391e56 --- /dev/null +++ b/test/integration/external_game_route_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class ExternalGameRouteTest < ActionDispatch::IntegrationTest + test "the direct route works correctly" do + expected_host = "https://staging--endsideout-games.netlify.app" + 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}/game-1?token=abc%3D%3D", external_game_url("game-1", token: "abc==") + end +end 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?