The 2026 overhaul¶
For everyone: what changed when the course app was rebuilt in 2026, and the full list of issues found and what happened to each.
Before it launched, the course app went through a full review and rebuild. Here's what changed, area by area.
Security and access¶
Before: an admin account could promote itself to superadmin or act on one; a student's upload could run as a web page in a grader's browser; every lesson PDF and submission could be downloaded by anyone who guessed its address, signed in or not; public sign-up let anyone create an account that could sometimes reach admin pages; pages decided who could see them only in the browser, after the data had already loaded.
After: roles and permissions are checked on the server before any page loads or any action runs; only a superadmin can manage another admin's or superadmin's account; files are kept in private storage and downloaded only through a link that checks who's asking and expires quickly; public sign-up is gone — only staff create accounts; a locked level shows titles only, with no content sent until it's unlocked.
Data and integrity¶
Before: the database structure had drifted from what the code expected, with tables and columns the app relied on missing entirely; there was no rule stopping a duplicate submission, and several important relationships between records weren't enforced; some multi-step saves could leave only part of a change written if something failed partway through.
After: the database was rebuilt from one consistent starting point that matches the code; duplicate submissions are prevented and record relationships are enforced; multi-step saves either complete fully or not at all.
Content and files¶
Before: uploaded files didn't survive running in production because the container couldn't keep them; a locked level's files were still sent to the browser even though they shouldn't have been visible; a submission could point at an arbitrary address instead of an uploaded file.
After: files are stored in Cloudflare R2, a service built for keeping them reliably; a locked level sends no files, resources or assignments until it's unlocked; a submission always uploads a real file through the storage system.
Assignments and grades¶
Before: a student could resubmit after an assignment was already graded, or even submit twice for the same assignment; a lesson with more than one assignment only ever showed the student the first one; deleting content with student work attached silently deleted that work too.
After: each student has exactly one submission per assignment with a history of attempts — a re-upload before grading replaces the current attempt's file, and a new attempt starts only once the work is returned for revision; students see every assignment for a lesson; content with student records attached can no longer be deleted.
Progress and enrolment¶
Before: "inactive" was measured from the day a student enrolled rather than their last real activity, so most students were wrongly flagged after a week; a level's status was worked out purely from its position in a list, so reordering levels could flip a student's recorded progress; there were two different, inconsistent ways to grant a student a level.
After: inactivity is measured from real recorded activity; completing a level is a permanent milestone for the student, unaffected by later changes to the course; there's one reliable way to grant or revoke a level.
Notifications¶
Before: a level-targeted announcement kept reaching people long after they'd moved past that level; announcement emails inserted the title and body without any escaping; nudge emails could be sent to an address chosen by the browser rather than the student being nudged.
After: level-targeted announcements go only to students currently on that level; announcement emails are escaped the same way nudge emails always were; a nudge's recipient is always looked up on the server from the student themselves.
UI and accessibility¶
Before: every portal page rendered blank for a moment before flashing in; several accessibility problems existed — clickable rows and boxes that weren't real links, icon buttons and search boxes with no label for screen readers, hand-built tabs with no accessibility information; only one page in the whole app showed a loading indicator.
After: pages check the session on the server before rendering, so there's no blank flash; accessibility problems were fixed throughout, and an automated accessibility check now runs as part of testing; every page has a loading indicator.
Architecture and code health¶
Before: several admin areas were built as browser pages that fetched data through an internal API instead of reading the database directly like the rest of the app, adding a large amount of plumbing for no real benefit; the project's own developer guide recommended patterns that had caused real bugs; a number of components existed in two or three near-identical, drifted copies.
After: those admin areas read the database directly like everywhere else, and the extra plumbing was removed; the developer guide no longer recommends the patterns that caused the bugs; duplicated components were merged into single, shared versions.
Operations¶
Before: the app's production package defeated its own lightweight mode by copying in the full development setup and reinstalling things on every start; the deployment scripts and infrastructure were built for a single app with a number of rough edges.
After: the production package is lean and starts the same way every time; the deployment pipeline, secrets handling and infrastructure now run both apps reliably, with scheduled backups and monitoring.
Findings¶
Every issue found during the review is listed below, in the order it was recorded, with what happened to it.
| ID | Area | Finding | Outcome | What changed |
|---|---|---|---|---|
| SEC-01 | Security & access | An admin account could promote itself to superadmin, or take over, ban or delete a superadmin, through the identity-management API. | fixed | Admin and superadmin are now distinct, checked by a capability system; only a superadmin can manage another admin or superadmin account, and no one can act on their own role or ban status. |
| SEC-02 | Security & access | A student's assignment upload accepted any file and kept its original name, so a crafted file could run as a web page in a grader's browser. | fixed | Uploads are now checked and stored as plain objects in private file storage, never served as a web page from the app's own address. |
| SEC-03 | Security & access | Every uploaded file (lesson PDFs and submissions) could be downloaded by anyone who guessed its address, with no sign-in check. | fixed | Files are kept in private storage and downloaded only through a link that checks the signed-in user is allowed to see that file, and that expires after a short time. |
| SEC-04 | Security & access | Anyone could create their own account through a public sign-up form, and a self-signed-up account could reach admin pages because nothing restricted it. | fixed | Public sign-up is switched off; only staff create accounts, and every page checks the signed-in user's role before showing anything. |
| SEC-05 | Security & access | Pages decided who could see them only in the browser, after the page had already loaded with its data; the underlying data had no server-side check. | fixed | Every page now checks the signed-in user's role and permissions on the server before it loads any data. |
| SEC-06 | Security & access | Faculty were treated the same as admins for content, so a faculty member could change or delete course content and grant or revoke any student's access. | fixed | Faculty can grade and view progress but can no longer change content, assignments or enrolment; those stay admin-only. |
| SEC-07 | Security & access | A locked level's lesson files and module assignments were sent to the browser even though the level was locked, so the lock could be bypassed. | fixed | A locked level now shows only its module and lesson titles; no files, resources or assignments are sent until the level is unlocked. |
| SEC-08 | Security & access | Submitting an assignment accepted any file address typed by the browser, including another student's file or an outside link. | fixed | A submission now always uploads a real file through the file-storage system; it can no longer point at an arbitrary address. |
| SEC-09 | Security & access | Admin actions didn't check who the target user was, so an admin could reset a superadmin's password or ban a superadmin. | fixed | Admin actions now check the target account and refuse to act on a superadmin unless the actor is a superadmin. |
| SEC-10 | Security & access | The file holding local secrets, including the sign-in secret and the first superadmin's password, was committed to the code repository. | fixed | That file is no longer tracked; secrets are kept out of the repository, and every value needed is documented in an example file with placeholders. |
| SEC-11 | Security & access | The "change your password" requirement was only enforced when moving between pages; the same actions were still reachable directly. | withdrawn | The team decided the password prompt is a reminder rather than a hard requirement — a user can choose "Keep current password" to dismiss it — so enforcing it everywhere else was no longer the goal. |
| SEC-12 | Security & access | A nudge email's recipient address came from the browser, so it could be sent to any address, and the record of who received it was wrong. | fixed | The recipient is always looked up on the server from the student being nudged; the browser can no longer choose the address. |
| SEC-13 | Security & access | An announcement email's title and body were inserted into the email without escaping, unlike the nudge email. | fixed | Announcement emails now escape the title and body the same way the nudge email does. |
| SEC-14 | Security & access | Several forms and pages (assignments, grades, announcements, enrolment, nudges) accepted input with no length limit or format check, and one page let the caller ask for an unlimited number of rows at once. | fixed | Every form field is validated and length-capped on the server, and list pages cap how many rows can be requested at once. |
| SEC-15 | Security & access | Pages for editing levels, modules and lessons took their parent from the address but never checked it matched, and reordering could mix items from different parents. | fixed | Every edit now checks the item's full parent chain; the old free-standing reorder pages were removed along with the admin pages that used them. |
| SEC-16 | Security & access | A tracing ID sent by the browser was written straight into the server's logs with no validation. | fixed | Inbound tracing IDs are no longer trusted; the server always generates its own. |
| SEC-17 | Security & access | The "teaching admin" flag had no effect on what an account was allowed to do — it only changed labels and counts. | accepted | This is by design: the flag is still a label only, counting a teaching admin alongside faculty for dashboards and faculty-wide announcements; access is controlled entirely by role, never by this flag. |
| BUG-01 | Broken functionality | Changing your password after being told to never actually cleared the "must change password" flag, so the prompt kept reappearing even after the password was changed. | fixed | The flag is now cleared correctly when the password is changed, as part of rebuilding the account-management code. |
| BUG-02 | Broken functionality | Submitting an assignment was broken: the first attempt failed outright, and a second attempt reported success while still keeping the old file. | fixed | Submissions and resubmissions now store the uploaded file correctly and show the attempt that was actually saved. |
| BUG-03 | Broken functionality | The database structure had drifted from the code that expected it — several columns and tables the app relied on were never actually created by the migration scripts. | fixed | The database schema was rebuilt from a single, consistent starting point that matches the code exactly. |
| BUG-04 | Broken functionality | Changing a user's role to student or faculty always failed. | fixed | Role changes between all four roles now work correctly. |
| BUG-05 | Broken functionality | Accounts created by an admin had no username set, so the new user could never sign in (sign-in is username-only). | fixed | Admin-created accounts are always given a working username. |
| BUG-06 | Broken functionality | Updating your profile always failed. | fixed | Profile updates now save correctly. |
| BUG-07 | Broken functionality | Uploaded files didn't survive in production: the running container couldn't write to the folder it used for storage, and nothing was kept across restarts. | fixed | Files are stored in Cloudflare R2, a service built for keeping files reliably, instead of inside the running container. |
| BUG-08 | Broken functionality | One of the inactivity settings couldn't actually be saved or read, so the screen always showed the same fixed number regardless of what was entered. | fixed | All platform settings are now read and written consistently, so changes take effect. |
| BUG-09 | Broken functionality | "Inactive" was measured from the day a student enrolled rather than from their last activity, so most students were wrongly flagged after a week regardless of how active they were. | fixed | Inactivity is now measured from the student's most recent recorded activity. |
| BUG-10 | Broken functionality | The admin dashboard and the announcements list were built once when the app was packaged, with no database available, so they could ship with empty data baked in and never refresh. | fixed | Those pages now read fresh data from the database on every visit. |
| BUG-11 | Broken functionality | A configuration flag meant to be on or off treated the text "false" as true, which could silently turn on a feature that was meant to be off. | fixed | Configuration flags are now read correctly, so "false" means off. |
| BUG-12 | Broken functionality | Some navigation links in the student and faculty side menus pointed at pages that didn't exist. | fixed | Those links now point at real pages. |
| BUG-13 | Broken functionality | Several automated tests were failing and the code had a number of linting errors. | fixed | The test suite and linter are clean as part of the rebuild, and both run as part of the project's checks. |
| BUG-14 | Broken functionality | A leftover, uncommitted edit had broken the course seed data script, which also used fixed, hard-coded row numbers. | fixed | That script was replaced entirely by the content import, which doesn't hard-code row numbers. |
| BUG-15 | Broken functionality | A network timeout from the old browser-side API client was reported as a generic error instead of a timeout, because the relevant code could never actually run. | fixed | That API client was removed along with the rest of the code it supported, once pages were rebuilt to read the database directly. |
| LOGIC-01 | Business rules | A student could resubmit an assignment after it had already been graded, and could even submit twice for the same assignment, because nothing stopped a duplicate; resubmitting also reset the submission time. | fixed | Each student now has exactly one submission per assignment with a history of attempts; once graded, a new attempt only happens after the grader returns it for revision. |
| LOGIC-02 | Business rules | A level with no lessons in it could never be "completed" (it always showed 0%), which blocked the next level from ever being granted; rounding also let 99.5% count as fully complete. | fixed | A level with no lessons now counts as complete, and completion percentages are calculated correctly. |
| LOGIC-03 | Business rules | There were two different, inconsistent ways to grant a student the next level — one checked and logged properly, the other didn't check or log anything and wasn't even used from the screen. | fixed | There is now one way to grant or revoke a level, with its checks and its record kept together in a single, reliable step. |
| LOGIC-04 | Business rules | A level's status (locked, active or completed) was worked out purely from its position in the list, so reordering levels after students had already progressed could flip their progress incorrectly. | fixed | Level completion is now a permanent milestone recorded against the student, not recalculated from the current order each time. |
| LOGIC-05 | Business rules | An announcement aimed at "everyone who reached level X" kept reaching people who had since moved past that level, because access to a level was never taken away once reached; a teaching admin also didn't receive faculty announcements. | fixed | Level-targeted announcements now go to students currently on that level; teaching admins are included in faculty-wide announcements. |
| LOGIC-06 | Business rules | Three different places in the app disagreed about which level to show when nudging a student (the highest, the first granted, or the lowest). | fixed | Every nudge screen now shows the same, correctly chosen level. |
| LOGIC-07 | Business rules | The dashboard's risk chart and the student list used two different counting rules, so clicking a bar on the chart could show a different set of students than expected. | fixed | The chart and the list now use the same counting rule. |
| LOGIC-08 | Business rules | A lesson could have more than one assignment, but students were only ever shown the first one, in no particular order; the others still silently counted as pending work. | fixed | Students now see every assignment for a lesson, in a consistent order. |
| LOGIC-09 | Business rules | Deleting a level, module or lesson that already had student progress, submissions or grades attached quietly deleted all of that too, and faculty were allowed to do it. | fixed | Content with any student record attached can no longer be deleted; deleting is admin-only, and content without student records can still be removed. |
| LOGIC-10 | Business rules | Clicking the complete toggle repeatedly wrote a new activity entry each time, flooding the activity feed, and marking a lesson incomplete had no access check at all. | fixed | Repeated clicks with no real change no longer create extra activity entries, and marking a lesson incomplete now checks the user's access like every other action. |
| LOGIC-11 | Business rules | A level waiting for the next grant showed its "completed" time as the current moment on every single page view, rather than the time it was actually completed. | fixed | The completion time is now stamped once and stays fixed. |
| LOGIC-12 | Business rules | A "due date" field existed in the database but could never actually be set anywhere in the app, so the "overdue" logic built around it was unreachable, dead code. | fixed | The unused due-date field and the dead overdue logic for students were removed; grading now has its own working "overdue" indicator based on how long a submission has waited. |
| LOGIC-13 | Business rules | The dashboard's overall completion rate divided by every lesson in the whole course, even for students who hadn't reached the later levels yet, unfairly lowering their score. | fixed | The completion rate is now calculated fairly, against the lessons a student can actually see. |
| LOGIC-14 | Business rules | The complete toggle was shown even on a locked level (it just failed silently when clicked), the level detail page was missing its progress bar and status badge, and a module page ignored which level it belonged to. | fixed | Locked content no longer offers actions it would refuse, and the level and module pages show the right progress information. |
| DATA-01 | Data integrity | The database had no rule stopping a student from submitting twice to the same assignment, and several important relationships, such as a submission's author, had no link enforced at the database level, so orphaned, inconsistent rows were possible. | fixed | The database now enforces one submission per student per assignment and proper relationships between records, so inconsistent rows can't be created. |
| DATA-02 | Data integrity | Some tables stored times without a time zone while others stored it with one, so a report that grouped by week could give different answers depending on the server's own time zone setting. | fixed | The sign-in tables now store times with a time zone, like every other table. |
| DATA-03 | Data integrity | Platform settings — their keys, defaults and validation — were scattered across several different places in the code, with hard-coded values in more than one. | fixed | Settings are now defined and validated in one place. |
| DATA-04 | Data integrity | A column the sign-in system expected to exist was missing from the database. | fixed | That column was added. |
| DATA-05 | Data integrity | Several multi-step database writes weren't wrapped together, so a failure partway through could leave only some of the change saved; many of the underlying pages also had no error handling, so a database failure crashed with a raw error. | fixed | Multi-step writes are now wrapped so they all succeed or all fail together, and errors are caught and shown sensibly. |
| DATA-06 | Data integrity | The position of a newly added item (a level, module, lesson and so on) was worked out by reading every existing row first, which was slow and could clash if two people added items at the same moment. | fixed | New, admin-created items are no longer reordered this way; they simply follow imported items in the order they were created. |
| ERR-01 | Error handling | Many parts of the app treated a database error exactly like "there's no data", silently showing an empty page instead of telling anyone something had gone wrong. | fixed | Errors are now logged everywhere they used to be swallowed, and the pattern is enforced across the codebase. |
| ERR-02 | Error handling | Changing your password always showed the same "current password is incorrect" message, even when the real problem was something else entirely. | fixed | Password-change failures now show a message that matches what actually went wrong. |
| ERR-03 | Error handling | Some pages showed the server's own internal error message directly to the user. | fixed | User-facing errors now show a plain, friendly message instead of the server's internal text. |
| ERR-04 | Error handling | The system was designed to tag each request with a tracing ID to help investigate problems, but the plumbing to actually carry that ID into the logs was never connected. | fixed | Tracing IDs now flow through to the logs, so a problem can be traced from the screen back to what the server recorded. |
| ARCH-01 | Architecture | Several admin areas (content, assignments, students, grades, settings) were built as browser-side pages that fetched data through an internal API, instead of reading the database directly on the server as the rest of the app does — a large amount of extra plumbing for no real benefit. | fixed | Those areas were rebuilt as server-rendered pages that read the database directly, matching the rest of the app, and the extra plumbing was removed. |
| ARCH-02 | Architecture | The project's own developer guide recommended patterns that had caused real bugs: bypassing type-checking around the sign-in library, and a database tool that had let the database structure drift from the code. | fixed | The developer guide no longer recommends either pattern; both were replaced with safer approaches. |
| ARCH-03 | Architecture | The way the app was packaged for deployment defeated its own lightweight production mode — it copied in the full development setup and reinstalled things on every start. | fixed | The production package is now lean and starts the same way every time, without reinstalling anything. |
| ARCH-04 | Architecture | Announcement emails were sent one by one, in the middle of handling the request and before the page could respond, with a fresh mail connection opened for every single email. | fixed | Emails are now queued and sent afterwards through a shared, reused connection, so posting an announcement responds immediately. |
| ARCH-05 | Architecture | The PDF viewer loaded a required piece of its own code from an outside website rather than from the app itself. | fixed | The PDF viewer's code is now bundled with the app instead of being fetched from elsewhere. |
| ARCH-06 | Architecture | Some data was read through the same mechanism meant for actions that change things, triggered as a side effect of the page rendering rather than as part of loading the page properly. | fixed | Those reads are now built the same way as every other list in the app. |
| SIMP-01 | Code cleanup | A large, custom-built internal API client existed only because other admin pages were built the browser-side way, and it included code that was never actually used. | fixed | That client and its unused code were deleted once the pages it supported were rebuilt. |
| SIMP-02 | Code cleanup | A whole system for tracking tracing IDs across requests existed but didn't actually work. | fixed | The tracing-ID system was rebuilt so it works, replacing the old, non-functional version. |
| SIMP-03 | Code cleanup | The app had its own 100-line wrapper around the logging library that just re-implemented what the library already did. | fixed | That wrapper was removed in favour of using the logging library directly. |
| SIMP-04 | Code cleanup | Several dashboard features — a chart, some queries — were built but never actually shown anywhere on screen. | fixed | That unused code was removed. |
| SIMP-05 | Code cleanup | A number of components existed in two or three near-identical copies that had drifted apart over time: password forms, the PDF viewer, submission rows, portal layouts and side menus, a couple of small helper functions, settings forms, and a level-list query. | fixed | Each of those was merged into one shared version. |
| SIMP-06 | Code cleanup | A client-side permission-checking component was redundant once every page checked permissions properly on the server. | fixed | That component was removed along with the code it depended on. |
| SIMP-07 | Code cleanup | A handful of dependencies were mis-placed or unnecessary: a command-line tool listed as a runtime dependency, a types package for a library no longer used, a mismatched Node.js types version, and a little-used helper library, among others. | fixed | Those dependencies were cleaned up, moved or upgraded to match; the team deliberately kept its error-handling helper library, since the real problem was how errors were handled, not the library itself. |
| UI-01 | User interface | Every portal page rendered blank for a moment while the session loaded, then suddenly flashed in. | fixed | Pages now check the session on the server before rendering, so there's no blank flash. |
| UI-02 | User interface | Several forms copied their starting values into on-screen state in an error-prone way that can show stale data; one page even changed the browser's address while the page was still rendering. | fixed | Forms now set their initial values the standard way, and the address change was moved to the right place in the code. |
| UI-03 | User interface | A number of accessibility problems were found: whole rows and boxes acting as clickable links without being real links, links nested inside buttons, hand-built tabs with no accessibility information, and icon buttons, search boxes and a role picker with no label for screen readers. | fixed | These were fixed across the app as every area was rebuilt, and an automated accessibility check now runs as part of testing. |
| UI-04 | User interface | Only one page in the whole app showed a loading indicator; most pages, including one that ran seven separate queries, showed nothing while loading. | fixed | Every page now has a loading indicator. |
| UI-05 | User interface | Colours were hard-coded in several places instead of using the app's shared colour choices, and a dark-mode colour scheme existed but was never actually used anywhere. | fixed | Hard-coded colours were replaced with the shared set, and the unused dark-mode colours were removed — the app now has one deliberate, light theme. |
| UI-06 | User interface | Searching the nudge history sent a request to the server on every single keystroke, and an older page of results could be appended after the search had already been cleared. | fixed | Nudge search now settles down before searching, and stale results can no longer be appended after a search is reset. |
| UI-07 | User interface | Announcements were always cut off at 160 characters with no way for a student to read more, and dates were shown in a mix of two different formats around the app. | fixed | Students can read the full announcement text, and dates are shown in one consistent format everywhere. |
| UI-08 | User interface | The PDF viewer's width was fixed at whatever size the page happened to be when it first loaded and never adjusted; the student detail page used a fixed three-column layout that didn't adapt; a floating bar sat in the wrong place when the side menu was collapsed. | fixed | The PDF viewer now resizes properly, the student detail layout adapts, and the floating bar follows the side menu correctly. |
Other fixes¶
A few issues were fixed along the way that weren't tracked with their own ID above:
- The script that resets the local development database now checks first that it's actually pointed at a local development database, so it can never be run by accident against a shared one.
- The app's own sign-in check used to skip the uploads folder entirely, letting files bypass it; that gap no longer exists now that files are served through the authorised download system instead.
- A number of deployment and infrastructure issues turned up while setting up production: configuration written for a single app was generalised to handle both apps; scripts referencing the wrong paths were fixed; a database connection setting that only worked inside the container's own network was fixed so it also works from the host machine; a mismatch between where images were published and where they were expected to be was fixed; a deployment credential that had been passed on a command line, where it could leak into logs, is now passed more safely; backups now run on a schedule instead of only on request; a restore step that could hide a real failure now reports it properly; backup and restore steps that needed administrator rights from an account that didn't have them were fixed; the deploy connection no longer uses a single, long-lived credential, and no longer skips checking the server's identity; network access rules are now tied to specific, named devices rather than whole network ranges; old container images are cleaned up on a schedule rather than just the completely unused ones; and the server's uptime and scheduled jobs are now actively monitored, with an alert if either goes quiet.
Decisions¶
A few things were looked at and deliberately kept or decided a particular way, rather than changed:
- Accounts are created only by staff — there's no public sign-up. A new or reset account is given a one-time temporary password, which can be kept at the first sign-in prompt or changed there and then.
- There are exactly four roles — student, faculty, admin, superadmin. Only a superadmin can manage another admin's or superadmin's account, and no one can change their own role or ban status. There is always at least one active superadmin.
- Faculty grade work, view student progress, send nudges and post announcements; changing content, enrolment and settings stays admin-only.
- A level counts as complete once every lesson in it has been finished. That completion stays recorded for the student even if a lesson is added or edited afterwards — it shows as "Not done" rather than reopening the level.
- Assignments and grades are records of a student's work; they don't by themselves control which level a student can see. A student can submit and resubmit on any level they can access until the work is graded.
- A locked level, module or lesson shows titles only — no files, resources or assignments are sent until it's unlocked.
- An email address is optional for an account. A blank one still gets in-app notifications; nothing is ever emailed to a missing address.
- Every notification is delivered both in-app and by email, from a single step that can't send one without the other.
- The "teaching admin" label counts that account among faculty for dashboards and faculty-wide announcements; it carries no extra access.
- Students already partway through the course on Moodle before moving here were brought in by staff, through a records-based process — a CSV import or a per-student dialog — rather than a live connection to Moodle; their carried-over progress is flagged so it's clear it wasn't completed in this app, and payment for access stays outside the app.
- Lesson PDFs, module resources and submissions are kept in private file storage; a download only works when the app itself checks the signed-in user is allowed to see that file.
- Content imported from the content repository is owned by it — every field it provides is locked and read-only in the app, and changes only through a new import.
- There's no reordering of levels, modules or lessons in the admin screens; imported items keep the content repository's order, and admin-created items follow after them in the order they were created.
- The app uses one light theme only; there's no dark mode.
- This documentation site is open to everyone, because nothing on it is secret; the site covering how the platform is built and run is kept separate, with its own password, for developers and the operator.