Spike to move editor into editor-api - #1040
Draft
zetter-rpf wants to merge 18 commits into
Draft
zetter-rpf wants to merge 18 commits into
zetter-rpf wants to merge 18 commits into
Conversation
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>
Test coverageSimpleCov coverage data was unavailable for this run. |
zetter-rpf
marked this pull request as ready for review
September 30, 2026 15:47
zetter-rpf
requested
a balanced review from Copilot
and removed request for
Copilot
September 30, 2026 15:47
zetter-rpf
marked this pull request as draft
September 30, 2026 15:48
zetter-rpf
commented
Sep 30, 2026
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' |
Contributor
Author
There was a problem hiding this comment.
I'm not sure if these should be here rather than in the EditorApp
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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?
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.mdThe editor specific controllers and views are in an
editor_appfolder and use anEditorAppnamespaceI'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:
editor_app/app/assets/javascripts) - just for dialogs, token renewal and editor web component integrationWhat needs more work
To try it
dev-config/hydra/clients/v2/editor-client.jsonin profile to add the following redirect_uris:"http://editor.localhost:3009/auth/callback" and "http://editor.localhost:3009/auth/silent_renew"
Screen.Recording.2026-09-30.at.16.12.40.mov