Skip to content

Spike to move editor into editor-api - #1040

Draft
zetter-rpf wants to merge 18 commits into
mainfrom
editor-app
Draft

zetter-rpf wants to merge 18 commits into
mainfrom
editor-app

Conversation

@zetter-rpf

@zetter-rpf zetter-rpf commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

This is a spike into moving the functionality of editor.raspberrypi.org into Editor API and rendering pages on the server.

It's not intended to be merged in it's current state.

Why?

  • Generating HTML on the server can be quicker than loading JS and generating it the browser
  • Minimising frontend javascript can reduce client errors where we have less visibility and control
  • Server side rendering simplifies and separate pages simplifies state and reduces bugs. For example, there's no need for any 'loading' states for pages as the data already exists.
  • Having editor and editor API in the repository simplifies development, testing and releases
  • I think it also lets us remove GraphQL as the editor is the only place it's used?

Migrating classroom in a similar way would have the same advantages.

Downsides

I think the main negative for us is that we're less used to writing HTML in .erb file and using view components than we are at writing react.

There is a small scaling disadvantage - the frontend of editor.raspberrypi.org is scaled for us by cloudflare and practically can handle any load. Generating HTML on the server is slightly more work than returning JSON responses.

Approach

I asked Claude to move the functionality from editor-standalone into here, preferring to use server side rendering rather client side Javascript. You can see the plan committed at editor_app/PLAN.md

The editor specific controllers and views are in an editor_app folder and use an EditorApp namespace

I've used view components as that's what our rails version of the design library uses. We could use them less if we wanted - moving things to the view or into generic models that are used by views.

The editor web component is still used.

What I found from doing this:

  • A large part of the changes is getting the editor client set up so that login and token renewal works
  • Less javascript is needed that I expected (see editor_app/app/assets/javascripts) - just for dialogs, token renewal and editor web component integration

What needs more work

  • the UI isn't quite right in places so needs fixes (see video)
  • It would be good to have some browser based test coverage (which might be able to replace what's in the integration test repo).
  • Deciding licensing - should it be the same license as editor-api or different?
  • There are some embedded routes that may need to be implemented

To try it

Screen.Recording.2026-09-30.at.16.12.40.mov

zetter-rpf and others added 18 commits September 30, 2026 14:10
The Code Editor web app currently lives in the editor-standalone repo,
where it shares a React codebase with Code Classroom and cannot be
changed independently of it. Moving it here lets the editor evolve on
its own, and lets its pages be rendered on the server rather than in
the browser.

This change adds an EditorApp Rails engine and mounts it at the root
of any host listed in EDITOR_APP_HOSTS, so editor.raspberrypi.org can
serve the editor while editor-api.raspberrypi.org carries on serving
the API and admin. Existing routes are declared before the mount so
they keep working on both hosts, and the bare root is restricted to
non-editor hosts so that "/" reaches the engine and redirects to a
locale-prefixed path.

Locale resolution follows what the React app did: the path segment
first, then the i18next cookie, then Accept-Language, then English.
The engine declares the locales it supports rather than inferring them
from whichever translation files happen to be present.

OriginParser gains a .parse that takes a value directly, so the engine
can reuse its literal-or-regex host parsing without duplicating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit made OriginParser.parse_origins read ALLOWED_ORIGINS
with ENV.fetch. CorpMiddleware specs stubbed ENV#[] instead, so they
silently saw no allowed origins and the Cross-Origin-Resource-Policy
header stopped being asserted.

This change sets the variable with ClimateControl, as the school and
school class specs already do, so the specs exercise the middleware
through the environment rather than through a stub of the exact reader
the implementation happens to call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Code Editor landing page was a React component that needed the
whole SPA, its Redux store and a round trip to the school API before it
could show a heading and two "start coding" buttons. Rendering it on
the server means the page is useful in its first response.

This change ports the landing page to an ERB template in the EditorApp
engine, using the design system Button component and the light theme
tokens the React app applied. Whether a visitor sees the login options
or the school student view is now decided from the session rather than
from client-side state.

Translations are ported from editor-standalone into the engine, keyed
for Rails lazy lookup. The React student view referenced two keys that
are absent from every translation file and so rendered raw key names;
it now uses the existing translated "Go to Code Classroom" string. The
cross-origin localStorage writes that the student and teacher login
links performed are dropped, as localStorage cannot be read by Code
Classroom on its own origin.

I18n fallbacks to English are enabled to match the fallbackLng the
React app configured, so a page in a partially translated locale is
not left with missing-translation markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Code Editor pages had no navigation, so there was no way to log in,
log out or change language from them.

This change renders the global navigation from its Stencil web
component build, which exists specifically for server-rendered apps: it
posts to paths the host application already provides at /auth/rpi and
/logout, and takes the per-form CSRF tokens those posts need. In the
React app the language links were placeholders that JavaScript
intercepted; here they are real links to the current page in the chosen
language, so changing language works without JavaScript.

The account dropdown is hidden for school students, matching the React
app, which decided this from the user type held in its Redux store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The home page had no way to reach your projects, your school, or the
For Education page.

This change ports the editor secondary nav as a view component. Which
links appear is decided from the session and the user's school roles,
rather than from Redux state populated by a school API request, so the
nav is complete in the first response.

The React nav collapsed into a JavaScript overlay with a focus trap
below 600px. With at most four short links, the nav now wraps instead,
which needs no JavaScript and keeps every link reachable.

For Education pointed at a page whose entire content was a notice that
Code Editor for Education is now Code Classroom. That URL now
redirects to Code Classroom, so the page is not worth porting and
existing links to it keep working.

Route helpers and engine helpers reach components through the view
context, so a base component delegates them to keep templates readable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Code Editor pages had no footer, so the terms, privacy, cookies,
accessibility and safeguarding links the site is expected to carry were
missing, as was the route for reporting a safeguarding concern.

This change ports the footer as a view component. The React footer
decided whether to show the safeguarding report from a Redux value that
was an object whenever a user was signed in, so it showed the report to
every signed-in user and put an undefined school id into the form. It
now shows only to users who belong to an active school, and passes that
school.

Pages that fill the viewport suppress the footer by overriding
show_footer?, rather than matching the request path against a list of
page URLs as the React footer did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Locales.load_locales assigns I18n.available_locales, replacing whatever
was there. It runs as a side effect of autoloading UploadJob, to
populate a constant, so any locale an engine had registered was
silently dropped as soon as that job class was loaded. Its list has no
en-US, so every /en-US Code Editor page raised I18n::InvalidLocale
after that point. A run of the request specs found this only under
certain orderings, and eager loading would have made it permanent in
production.

This change makes the assignment a union, so locales registered
elsewhere survive. The method still returns exactly the list it
returned before, leaving the project locales UploadJob validates
against unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anyone picking this work up had no way to see what was built, what was
left, or which decisions had already been settled, so the reasoning
lived only in a chat log. Phases 0 and 1 are done and phases 2 to 5 are
not, which is not evident from the code alone.

This change adds editor_app/PLAN.md and points at it from CLAUDE.md. It
records what each commit so far delivered, the decisions taken and why,
the Hydra client registration this depends on in other repos, and the
four bugs found in the React app that must not be reintroduced.

It also states as a requirement that the editor host authenticates with
the editor Hydra client rather than the API dashboard one, because
Profile resolves the roles claim per client id and a shared client would
grant editor-admin on the public editor host. That client is registered
as public, so the engine must use PKCE against it unmodified rather than
making it confidential, which would break editor-ui and the SPA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan added in the previous commit is only useful if it is found.
CLAUDE.md is a symlink to AGENTS.md, so the pointer has to be committed
in AGENTS.md and was missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previously every host in this app authenticated with the editor
dashboard's Hydra client. Profile resolves the roles claim per client
id, so a session created on the public Code Editor host would carry the
same editor-admin role as the admin dashboard, and users under 13 could
not log in at all because the scope omitted allow-u13-login.

This change gives the Code Editor host the editor Hydra client. OmniAuth
runs its per-request setup callable on both the request and callback
phases, so a single provider can swap client by host and keep one
/auth/rpi and one /auth/callback. That client is registered as public,
with no token endpoint authentication, so the swap also drops the client
secret, moves client authentication into the request body and turns on
PKCE; sending Basic auth to a client registered as "none" is rejected
outright by Hydra. The spec asserts the oauth2 gem sends no Authorization
header once the secret is nil, because :basic_auth would send one built
from an empty password.

Alternatively the editor client could have been re-registered to accept a
secret, but it is also editor-ui's browser client, which requires none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previously every login landed on the root path, or /admin for admins,
regardless of where it started. On the Code Editor host that meant
logging in from the home page dropped admins into the admin dashboard,
and everyone else lost their place.

This change honours the origin OmniAuth already records from the returnTo
parameter, rejecting anything that is not a path on this site so it
cannot be used as an open redirect. The admin dashboard redirect is now
only a fallback for logins that named no origin. Logging out returns to
whichever host the user logged out from rather than always to the API
host, so signing out of the Code Editor leaves them on the Code Editor.

The access token expiry is recorded in the session so that the token
handed to the editor web component can be renewed before it lapses. The
token itself already lives in the session as part of the serialised user,
so it is not stored a second time; the session cookie has 4KB to work
with and the token is the largest thing in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The editor web component reads its user from local storage under a key it
is handed, once when it mounts and then every 45 seconds. Rails now owns
the session on the Code Editor host, so nothing was writing that key and
a signed-in user would have appeared signed out to the editor.

This change embeds the user as a JSON data block in the document head and
writes it to local storage from a synchronous inline script, so the key
is populated before any deferred script can mount the web component. When
nobody is signed in the same script removes the key, which is what clears
it after logging out. The key is derived from the Hydra issuer and the
editor client id, in the format oidc-client-ts used, so it stays stable
if the editor web component is ever loaded alongside the React app.

The payload is escaped as JSON rather than interpolated into JavaScript
so that a value containing a closing script tag cannot break out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Access tokens last an hour and an editor page can be open for much
longer. Previously the React app logged the user out when the token
expired, which tore down the editor and lost any unsaved code — exactly
the work the user was in the middle of.

This change renews the token by silent re-authorisation in a hidden
iframe, a couple of minutes before it lapses. The iframe is same origin,
so it writes the fresh token to the same local storage key the editor
already polls every 45 seconds, and the page it belongs to is never
navigated. When Hydra reports the session has genuinely gone, the page
says so in place and offers to log in again rather than redirecting, so
the user can still recover their work first.

Refresh tokens were rejected for this: Hydra is told remember_for = 0 at
login, so its session cookie lasts the whole browser session, whereas a
refresh token expires in its own right and would die on an idle page. The
editor client is also refused the offline_access scope today.

The renewal bypasses OmniAuth because its request phase requires a POST
under omniauth-rails_csrf_protection, which an iframe navigation cannot
do, so the authorize URL and the code exchange are built directly against
the public client with PKCE.

The "log in again" wording is English only for now; the engine falls back
to English until Crowdin picks the keys up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2 is done, so the plan now describes what was built and why rather
than what to build: the per-host OmniAuth client swap and the
:request_body auth scheme it needs, why the access token was not
duplicated into the session, and how silent renewal signals back to the
page that opened it.

The dead /session/token route goes with it. It was a leftover from an
earlier design that the silent renew routes replaced, and its controller
never existed, so any request to it raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previously every editor page emitted `<script type="module">import
"application"</script>` alongside an empty import map, because neither
turbo-rails nor a host `application.js` exists in this repo and
importmap-rails silently drops pins it cannot resolve. Browsers reported
a module resolution failure on every page load.

This change drops the engine's import map and its Stimulus wiring. The
engine already ships browser behaviour as plain Propshaft-served scripts
(`session_renewal.js`), so the remaining phases of the migration use
that pattern rather than adding Stimulus and Turbo for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previously `/:locale/projects/:identifier` on the editor host raised,
because the route was declared without a controller. The project page is
the one page that has to keep working exactly as it does in
editor-standalone, since it hosts the editor-ui web component.

This change renders it on the server. The project is resolved through
`ProjectLoader` with the locale from the path, authorised with cancancan,
and handed to `<editor-wc>` with the attributes the React
`Project.jsx` set. Two things get simpler in the move:
`friendly_errors_enabled` reads Flipper directly instead of
round-tripping `/api/features`, and the API the editor calls is now
same-origin. `offline_enabled` is spelled out as false while the service
worker stays out of scope, because the web component reads every boolean
attribute as `value !== "false"`.

`EditorApp::WebComponent` ports the `latest_version` indirection from
`getEditorWebComponentURL.js`, caching the resolved release for five
minutes. Scratch projects redirect to Experience CS, which owns that
editor. A missing or unauthorised project is now a Rails 404 or 403 page
rather than a React modal, and the page drops the footer so the editor
fills the viewport.

`project.js` replaces the `useEffect` listeners in
`ProjectComponentLoader.jsx`. It handles a remix's new identifier with
`history.replaceState` so the editor is never torn down, and gives the
web component a login form to submit for `editor-logIn`. Its
`editor-projectLoadFailed` handler needs a destination, so `/:locale/error`
is added despite the plan listing a dedicated error page as out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previously `/:locale/projects` on the editor host raised, so there was no
way to reach a saved project except by knowing its URL.

This change renders the project index on the server, using the same
filter the React index applied through GraphQL: personal projects only,
never a school or lesson project, most recently edited first. It is
paginated with kaminari, eight to a page, and the cursor-based "Load
more" button becomes a link to the next page. "Edited X ago" comes from
`time_ago_in_words` rather than date-fns.

Creating, renaming and deleting are plain forms in `<dialog>` elements,
calling `Project::Create` and `Project::Update`. Starter content mirrors
`src/utils/defaultProjects.js`. The React create modal gated
`code_editor_scratch` on `forLesson`, so the index only offers Python and
web projects, and an unrecognised project type is rejected as a bad
request. `dialogs.js` is the only JavaScript: eight rows means eight
rename and delete forms are already in the page, so opening one needs
nothing more than `showModal`, and the native `formmethod="dialog"`
closes it again.

Signing in is required. Anybody signed out is sent to the home page,
which offers the login that returns here, because login is a POST and
cannot be redirected to. School students are refused, matching the
redirect `ProjectLayout` applied to them; their projects belong to Code
Classroom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phases 3 and 4 are built, so the plan now describes what exists rather
than what to do: how `<editor-wc>` is configured and why its boolean
attributes are spelled out, the filter behind the index, why the index
never offers Blocks, and how the dialogs work without Turbo.

It also records the two decisions taken against the plan as written: the
engine uses plain Propshaft-served scripts rather than Stimulus and
Turbo, because neither is installed in this repo, and `/:locale/error`
exists despite being listed as out of scope, because the
editor-projectLoadFailed event needs a destination. The new strings are
English-only and are added to the Phase 5 Crowdin list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label Sep 30, 2026
@github-actions

Copy link
Copy Markdown

Test coverage

SimpleCov coverage data was unavailable for this run.
Run: https://github.com/RaspberryPiFoundation/editor-api/actions/runs/36739272000

@zetter-rpf
zetter-rpf marked this pull request as ready for review September 30, 2026 15:47
@zetter-rpf
zetter-rpf requested a balanced review from Copilot and removed request for Copilot September 30, 2026 15:47
@zetter-rpf
zetter-rpf marked this pull request as draft September 30, 2026 15:48
Comment thread config/routes.rb
Comment on lines +132 to +133
get '/auth/silent_renew/start', to: 'silent_renew#start', as: 'start_silent_renew'
get '/auth/silent_renew', to: 'silent_renew#callback', as: 'silent_renew'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if these should be here rather than in the EditorApp

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant