From 3f9e97555998e696bbd4c0bec90c55448c07b30c Mon Sep 17 00:00:00 2001
From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com>
Date: Wed, 25 Feb 2026 15:47:49 +0000
Subject: [PATCH 1/3] Create cookie for scratch auth when loading projects
This feels like a sensible place as it should always be set before a scratch project is loaded
We may need to extend this when creating/remixing projects if it uses another path.
Alternatively we could make a new endpoint that creates a cookie.
---
app/controllers/api/projects_controller.rb | 15 +++++++++
app/models/project.rb | 1 +
config/initializers/cors.rb | 1 +
spec/requests/projects/show_spec.rb | 37 ++++++++++++++++++++++
4 files changed, 54 insertions(+)
diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb
index 1ebb350f9..97cba898d 100644
--- a/app/controllers/api/projects_controller.rb
+++ b/app/controllers/api/projects_controller.rb
@@ -4,12 +4,15 @@
module Api
class ProjectsController < ApiController
+ include ActionController::Cookies
+
before_action :authorize_user, only: %i[create update index destroy]
before_action :load_project, only: %i[show update destroy show_context]
before_action :load_projects, only: %i[index]
load_and_authorize_resource
before_action :verify_lesson_belongs_to_school, only: :create
after_action :pagination_link_header, only: %i[index]
+ before_action :set_auth_cookie_for_scratch, only: %i[show]
def index
@paginated_projects = @projects.page(params[:page])
@@ -59,6 +62,18 @@ def show_context
private
+ def set_auth_cookie_for_scratch
+ return unless @project.project_type == Project::Types::CODE_EDITOR_SCRATCH
+ return unless Flipper.enabled?(:cat_mode, school)
+
+ cookies[:scratch_auth] = {
+ value: request.headers['Authorization'],
+ secure: Rails.env.production?,
+ same_site: :strict,
+ http_only: true
+ }
+ end
+
def verify_lesson_belongs_to_school
return if base_params[:lesson_id].blank?
return if school&.lessons&.pluck(:id)&.include?(base_params[:lesson_id])
diff --git a/app/models/project.rb b/app/models/project.rb
index dc692aa2d..f74dbcdb4 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -5,6 +5,7 @@ module Types
PYTHON = 'python'
HTML = 'html'
SCRATCH = 'scratch'
+ CODE_EDITOR_SCRATCH = 'code_editor_scratch'
end
belongs_to :school, optional: true
diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb
index 2fa210159..2443b654c 100644
--- a/config/initializers/cors.rb
+++ b/config/initializers/cors.rb
@@ -23,5 +23,6 @@
end
def standard_cors_options
+ resource '/api/projects/*', headers: :any, methods: %i[get post patch put delete], credentials: true, expose: ['Link']
resource '*', headers: :any, methods: %i[get post patch put delete], expose: ['Link']
end
diff --git a/spec/requests/projects/show_spec.rb b/spec/requests/projects/show_spec.rb
index d14a09c26..7d83dedd6 100644
--- a/spec/requests/projects/show_spec.rb
+++ b/spec/requests/projects/show_spec.rb
@@ -54,6 +54,43 @@
end
end
+ context 'when setting scratch auth cookie' do
+ let(:project_type) { Project::Types::PYTHON }
+ let!(:project) { create(:project, school:, user_id: teacher.id, locale: nil, project_type:) }
+
+ before do
+ Flipper.disable :cat_mode
+ Flipper.disable_actor :cat_mode, school
+ end
+
+ it 'does not set auth cookie when project is not scratch' do
+ get("/api/projects/#{project.identifier}", headers:)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.cookies['scratch_auth']).to be_nil
+ end
+
+ context 'when project is code editor scratch' do
+ let(:project_type) { Project::Types::CODE_EDITOR_SCRATCH }
+
+ it 'does not set auth cookie when cat_mode is not enabled' do
+ get("/api/projects/#{project.identifier}", headers:)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.cookies['scratch_auth']).to be_nil
+ end
+
+ it 'sets auth cookie to auth header' do
+ Flipper.enable_actor :cat_mode, school
+
+ get("/api/projects/#{project.identifier}", headers:)
+
+ expect(response).to have_http_status(:ok)
+ expect(cookies['scratch_auth']).to eq(UserProfileMock::TOKEN)
+ end
+ end
+ end
+
context 'when loading a student\'s project' do
let(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) }
let(:lesson) { create(:lesson, school:, school_class:, user_id: teacher.id, visibility: 'students') }
From 39f4ebbe1ec4b49d8dca88f1f6dd53bba7ecb0cc Mon Sep 17 00:00:00 2001
From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com>
Date: Wed, 25 Feb 2026 15:51:39 +0000
Subject: [PATCH 2/3] Handle scratch project saving and updating
This shows a static project for now, later we will likely pull project data from the database or object store.
The updating always succeeds and returns an ok but doesn't perform any updates.
---
.../api/scratch/projects_controller.rb | 18 ++
.../api/scratch/scratch_controller.rb | 21 ++
.../concerns/identifiable_by_cookie.rb | 15 +
app/views/api/scratch/projects/show.json | 264 ++++++++++++++++++
config/initializers/cors.rb | 1 +
config/routes.rb | 4 +
.../scratch/showing_a_scratch_project_spec.rb | 14 +
.../updating_a_scratch_project_spec.rb | 40 +++
8 files changed, 377 insertions(+)
create mode 100644 app/controllers/api/scratch/projects_controller.rb
create mode 100644 app/controllers/api/scratch/scratch_controller.rb
create mode 100644 app/controllers/concerns/identifiable_by_cookie.rb
create mode 100644 app/views/api/scratch/projects/show.json
create mode 100644 spec/features/scratch/showing_a_scratch_project_spec.rb
create mode 100644 spec/features/scratch/updating_a_scratch_project_spec.rb
diff --git a/app/controllers/api/scratch/projects_controller.rb b/app/controllers/api/scratch/projects_controller.rb
new file mode 100644
index 000000000..4904724d3
--- /dev/null
+++ b/app/controllers/api/scratch/projects_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Api
+ module Scratch
+ class ProjectsController < ScratchController
+ skip_before_action :authorize_user, only: [:show]
+ skip_before_action :check_scratch_feature, only: [:show]
+
+ def show
+ render :show, formats: [:json]
+ end
+
+ def update
+ render json: { status: 'ok' }, status: :ok
+ end
+ end
+ end
+end
diff --git a/app/controllers/api/scratch/scratch_controller.rb b/app/controllers/api/scratch/scratch_controller.rb
new file mode 100644
index 000000000..2500b8495
--- /dev/null
+++ b/app/controllers/api/scratch/scratch_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Api
+ module Scratch
+ class ScratchController < ApiController
+ include IdentifiableByCookie
+
+ before_action :authorize_user
+ before_action :check_scratch_feature
+
+ def check_scratch_feature
+ return if current_user.nil?
+
+ school = current_user&.schools&.first
+ return if Flipper.enabled?(:cat_mode, school)
+
+ raise ActiveRecord::RecordNotFound, 'Not Found'
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/identifiable_by_cookie.rb b/app/controllers/concerns/identifiable_by_cookie.rb
new file mode 100644
index 000000000..6ce693598
--- /dev/null
+++ b/app/controllers/concerns/identifiable_by_cookie.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module IdentifiableByCookie
+ extend ActiveSupport::Concern
+ include ActionController::Cookies
+
+ def identify_user
+ token = cookies[:scratch_auth]
+ User.from_token(token:) if token
+ end
+
+ def current_user
+ @current_user ||= identify_user
+ end
+end
diff --git a/app/views/api/scratch/projects/show.json b/app/views/api/scratch/projects/show.json
new file mode 100644
index 000000000..501c70955
--- /dev/null
+++ b/app/views/api/scratch/projects/show.json
@@ -0,0 +1,264 @@
+{
+ "targets": [
+ {
+ "isStage": true,
+ "name": "Stage",
+ "variables": {
+ "`jEk@4|i[#Fk?(8x)AV.-my variable": [
+ "my variable",
+ "1"
+ ]
+ },
+ "lists": {},
+ "broadcasts": {},
+ "blocks": {
+ "Q*IGJj0mdm@]wJ,v%1M5": {
+ "opcode": "event_whenflagclicked",
+ "next": "]ML!StPuGDgh@B`^[v0d",
+ "parent": null,
+ "inputs": {},
+ "fields": {},
+ "shadow": false,
+ "topLevel": true,
+ "x": 188,
+ "y": 132
+ },
+ "]ML!StPuGDgh@B`^[v0d": {
+ "opcode": "data_setvariableto",
+ "next": null,
+ "parent": "Q*IGJj0mdm@]wJ,v%1M5",
+ "inputs": {
+ "VALUE": [
+ 1,
+ [
+ 10,
+ "1"
+ ]
+ ]
+ },
+ "fields": {
+ "VARIABLE": [
+ "my variable",
+ "`jEk@4|i[#Fk?(8x)AV.-my variable"
+ ]
+ },
+ "shadow": false,
+ "topLevel": false
+ }
+ },
+ "comments": {},
+ "currentCostume": 0,
+ "costumes": [
+ {
+ "name": "backdrop1",
+ "dataFormat": "svg",
+ "assetId": "cd21514d0531fdffb22204e0ec5ed84a",
+ "md5ext": "cd21514d0531fdffb22204e0ec5ed84a.svg",
+ "rotationCenterX": 240,
+ "rotationCenterY": 180
+ }
+ ],
+ "sounds": [],
+ "volume": 100,
+ "layerOrder": 0,
+ "tempo": 60,
+ "videoTransparency": 50,
+ "videoState": "on",
+ "textToSpeechLanguage": null
+ },
+ {
+ "isStage": false,
+ "name": "teapot",
+ "variables": {},
+ "lists": {},
+ "broadcasts": {},
+ "blocks": {
+ "+7D@27U/UaREbT1:$D2p": {
+ "opcode": "motion_goto_menu",
+ "next": null,
+ "parent": "WQem=5FI$H@eF8Oy8=%m",
+ "inputs": {},
+ "fields": {
+ "TO": [
+ "_random_"
+ ]
+ },
+ "shadow": true,
+ "topLevel": false
+ },
+ ":Cul13Fz-$kW#uI)b3Ih": {
+ "opcode": "motion_turnright",
+ "next": "`HzTVWK}[NHS}n(wXj(~",
+ "parent": "o6dY$%i#z$_:%J%y.1Uv",
+ "inputs": {
+ "DEGREES": [
+ 1,
+ [
+ 4,
+ "15"
+ ]
+ ]
+ },
+ "fields": {},
+ "shadow": false,
+ "topLevel": false
+ },
+ "El0`*m+.st%JF!*L54E,": {
+ "opcode": "control_repeat",
+ "next": null,
+ "parent": "JP;xPsfOSUqBfPdVA)C9",
+ "inputs": {
+ "TIMES": [
+ 1,
+ [
+ 6,
+ "5"
+ ]
+ ],
+ "SUBSTACK": [
+ 2,
+ "WQem=5FI$H@eF8Oy8=%m"
+ ]
+ },
+ "fields": {},
+ "shadow": false,
+ "topLevel": false
+ },
+ "JP;xPsfOSUqBfPdVA)C9": {
+ "opcode": "event_whenflagclicked",
+ "next": "El0`*m+.st%JF!*L54E,",
+ "parent": null,
+ "inputs": {},
+ "fields": {},
+ "shadow": false,
+ "topLevel": true,
+ "x": 5,
+ "y": -1196
+ },
+ "Lf3w#r641AVS(Z;,+4y3": {
+ "opcode": "event_whenthisspriteclicked",
+ "next": "o6dY$%i#z$_:%J%y.1Uv",
+ "parent": null,
+ "inputs": {},
+ "fields": {},
+ "shadow": false,
+ "topLevel": true,
+ "x": 277,
+ "y": -1199
+ },
+ "TFT|@Y(or^}uOFGDC|FB": {
+ "opcode": "looks_setsizeto",
+ "next": null,
+ "parent": "`HzTVWK}[NHS}n(wXj(~",
+ "inputs": {
+ "SIZE": [
+ 1,
+ [
+ 4,
+ "100"
+ ]
+ ]
+ },
+ "fields": {},
+ "shadow": false,
+ "topLevel": false
+ },
+ "WQem=5FI$H@eF8Oy8=%m": {
+ "opcode": "motion_goto",
+ "next": "k`v/#:wkfU{|QkjM)6G|",
+ "parent": "El0`*m+.st%JF!*L54E,",
+ "inputs": {
+ "TO": [
+ 1,
+ "+7D@27U/UaREbT1:$D2p"
+ ]
+ },
+ "fields": {},
+ "shadow": false,
+ "topLevel": false
+ },
+ "`HzTVWK}[NHS}n(wXj(~": {
+ "opcode": "control_wait",
+ "next": "TFT|@Y(or^}uOFGDC|FB",
+ "parent": ":Cul13Fz-$kW#uI)b3Ih",
+ "inputs": {
+ "DURATION": [
+ 1,
+ [
+ 5,
+ "1"
+ ]
+ ]
+ },
+ "fields": {},
+ "shadow": false,
+ "topLevel": false
+ },
+ "k`v/#:wkfU{|QkjM)6G|": {
+ "opcode": "control_wait",
+ "next": null,
+ "parent": "WQem=5FI$H@eF8Oy8=%m",
+ "inputs": {
+ "DURATION": [
+ 1,
+ [
+ 5,
+ "0.1"
+ ]
+ ]
+ },
+ "fields": {},
+ "shadow": false,
+ "topLevel": false
+ },
+ "o6dY$%i#z$_:%J%y.1Uv": {
+ "opcode": "looks_setsizeto",
+ "next": ":Cul13Fz-$kW#uI)b3Ih",
+ "parent": "Lf3w#r641AVS(Z;,+4y3",
+ "inputs": {
+ "SIZE": [
+ 1,
+ [
+ 4,
+ "200"
+ ]
+ ]
+ },
+ "fields": {},
+ "shadow": false,
+ "topLevel": false
+ }
+ },
+ "comments": {},
+ "currentCostume": 0,
+ "costumes": [
+ {
+ "name": "teapot",
+ "bitmapResolution": 1,
+ "dataFormat": "svg",
+ "assetId": "8a9dadf4eea61892ec6908b1c99e4961",
+ "md5ext": "8a9dadf4eea61892ec6908b1c99e4961.svg",
+ "rotationCenterX": 23.171110153198242,
+ "rotationCenterY": 22.78113555908203
+ }
+ ],
+ "sounds": [],
+ "volume": 100,
+ "layerOrder": 1,
+ "visible": true,
+ "x": 0,
+ "y": 6,
+ "size": 100,
+ "direction": 105,
+ "draggable": false,
+ "rotationStyle": "all around"
+ }
+ ],
+ "monitors": [],
+ "extensions": [],
+ "meta": {
+ "semver": "3.0.0",
+ "vm": "12.2.2",
+ "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
+ }
+}
\ No newline at end of file
diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb
index 2443b654c..3dc4dd85c 100644
--- a/config/initializers/cors.rb
+++ b/config/initializers/cors.rb
@@ -23,6 +23,7 @@
end
def standard_cors_options
+ resource '/api/scratch/*', headers: :any, methods: %i[get post put], credentials: true, expose: ['Link']
resource '/api/projects/*', headers: :any, methods: %i[get post patch put delete], credentials: true, expose: ['Link']
resource '*', headers: :any, methods: %i[get post patch put delete], expose: ['Link']
end
diff --git a/config/routes.rb b/config/routes.rb
index 500644cb6..623ce9fa5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -33,6 +33,10 @@
mount GraphiQL::Rails::Engine, at: '/graphql', graphql_path: '/graphql#execute' unless Rails.env.production?
namespace :api do
+ namespace :scratch do
+ resources :projects, only: %i[show update]
+ end
+
resource :default_project, only: %i[show] do
get '/html', to: 'default_projects#html'
get '/python', to: 'default_projects#python'
diff --git a/spec/features/scratch/showing_a_scratch_project_spec.rb b/spec/features/scratch/showing_a_scratch_project_spec.rb
new file mode 100644
index 000000000..12e584740
--- /dev/null
+++ b/spec/features/scratch/showing_a_scratch_project_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Showing a Scratch project', type: :request do
+ it 'returns scratch project JSON' do
+ get '/api/scratch/projects/any-identifier'
+
+ expect(response).to have_http_status(:ok)
+
+ data = JSON.parse(response.body, symbolize_names: true)
+ expect(data).to have_key(:targets)
+ end
+end
diff --git a/spec/features/scratch/updating_a_scratch_project_spec.rb b/spec/features/scratch/updating_a_scratch_project_spec.rb
new file mode 100644
index 000000000..eaa91936b
--- /dev/null
+++ b/spec/features/scratch/updating_a_scratch_project_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Updating a Scratch project', type: :request do
+ let(:school) { create(:school) }
+ let(:teacher) { create(:teacher, school:) }
+ let(:cookie_headers) { { 'Cookie' => "scratch_auth=#{UserProfileMock::TOKEN}" } }
+
+ before do
+ Flipper.disable :cat_mode
+ Flipper.disable_actor :cat_mode, school
+ end
+
+ it 'responds 401 Unauthorized when no cookie is provided' do
+ put '/api/scratch/projects/any-identifier', params: { project: { targets: [] } }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'responds 404 Not Found when cat_mode is not enabled' do
+ authenticated_in_hydra_as(teacher)
+
+ put '/api/scratch/projects/any-identifier', params: { project: { targets: [] } }, headers: cookie_headers
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it 'updates a project when cat_mode is enabled and a cookie is provided' do
+ authenticated_in_hydra_as(teacher)
+ Flipper.enable_actor :cat_mode, school
+
+ put '/api/scratch/projects/any-identifier', params: { project: { targets: [] } }, headers: cookie_headers
+
+ expect(response).to have_http_status(:ok)
+
+ data = JSON.parse(response.body, symbolize_names: true)
+ expect(data[:status]).to eq('ok')
+ end
+end
From 0c6cef7aeb5d4aae3fcc8411a446ac19f23af85d Mon Sep 17 00:00:00 2001
From: Chris Zetter <253059100+zetter-rpf@users.noreply.github.com>
Date: Wed, 25 Feb 2026 16:08:29 +0000
Subject: [PATCH 3/3] Handle loading and creating assets
For now, the show always returns a static asset and the create always succeeds but doesn't do any saving.
Note that the /internalapi/asset/.../get/ route is dictated by Scratch.
---
.../api/scratch/assets_controller.rb | 18 ++++++++
app/views/api/scratch/assets/show.svg | 20 +++++++++
config/routes.rb | 2 +
.../scratch/creating_a_scratch_asset_spec.rb | 41 +++++++++++++++++++
.../scratch/showing_a_scratch_asset_spec.rb | 12 ++++++
5 files changed, 93 insertions(+)
create mode 100644 app/controllers/api/scratch/assets_controller.rb
create mode 100644 app/views/api/scratch/assets/show.svg
create mode 100644 spec/features/scratch/creating_a_scratch_asset_spec.rb
create mode 100644 spec/features/scratch/showing_a_scratch_asset_spec.rb
diff --git a/app/controllers/api/scratch/assets_controller.rb b/app/controllers/api/scratch/assets_controller.rb
new file mode 100644
index 000000000..08e6cf2c5
--- /dev/null
+++ b/app/controllers/api/scratch/assets_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Api
+ module Scratch
+ class AssetsController < ScratchController
+ skip_before_action :authorize_user, only: [:show]
+ skip_before_action :check_scratch_feature, only: [:show]
+
+ def show
+ render :show, formats: [:svg]
+ end
+
+ def create
+ render json: { status: 'ok', 'content-name': params[:id] }, status: :created
+ end
+ end
+ end
+end
diff --git a/app/views/api/scratch/assets/show.svg b/app/views/api/scratch/assets/show.svg
new file mode 100644
index 000000000..65f2112db
--- /dev/null
+++ b/app/views/api/scratch/assets/show.svg
@@ -0,0 +1,20 @@
+
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 623ce9fa5..91e498688 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -35,6 +35,8 @@
namespace :api do
namespace :scratch do
resources :projects, only: %i[show update]
+ get '/assets/internalapi/asset/:id(.:format)/get/' => 'assets#show'
+ post '/assets/:id' => 'assets#create'
end
resource :default_project, only: %i[show] do
diff --git a/spec/features/scratch/creating_a_scratch_asset_spec.rb b/spec/features/scratch/creating_a_scratch_asset_spec.rb
new file mode 100644
index 000000000..5d1ce8679
--- /dev/null
+++ b/spec/features/scratch/creating_a_scratch_asset_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Creating a Scratch asset', type: :request do
+ let(:school) { create(:school) }
+ let(:teacher) { create(:teacher, school:) }
+ let(:cookie_headers) { { 'Cookie' => "scratch_auth=#{UserProfileMock::TOKEN}" } }
+
+ before do
+ Flipper.disable :cat_mode
+ Flipper.disable_actor :cat_mode, school
+ end
+
+ it 'responds 401 Unauthorized when no cookie is provided' do
+ post '/api/scratch/assets/example.svg'
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'responds 404 Not Found when cat_mode is not enabled' do
+ authenticated_in_hydra_as(teacher)
+
+ post '/api/scratch/assets/example.svg', headers: cookie_headers
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it 'creates an asset when cat_mode is enabled and a cookie is provided' do
+ authenticated_in_hydra_as(teacher)
+ Flipper.enable_actor :cat_mode, school
+
+ post '/api/scratch/assets/example.svg', headers: cookie_headers
+
+ expect(response).to have_http_status(:created)
+
+ data = JSON.parse(response.body, symbolize_names: true)
+ expect(data[:status]).to eq('ok')
+ expect(data[:'content-name']).to eq('example')
+ end
+end
diff --git a/spec/features/scratch/showing_a_scratch_asset_spec.rb b/spec/features/scratch/showing_a_scratch_asset_spec.rb
new file mode 100644
index 000000000..3afbc8993
--- /dev/null
+++ b/spec/features/scratch/showing_a_scratch_asset_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Showing a Scratch asset', type: :request do
+ it 'returns scratch asset SVG' do
+ get '/api/scratch/assets/internalapi/asset/example.svg/get/'
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('