The page had grown by accretion: model discovery scattered across five
cards with a search box each, one Save writing eight keys from the
bottom of a card that also held a second Save for something else, and a
banner apologising that a button "applies only the settings above it".
Now it reads in groups — Accounts, Models, Assistant & prompts, Site —
and every card is a <details> that folds, so Save & Close means
something. The rule is that each card saves exactly what it shows,
which is what removed the need for the banner.
Models is one workflow in three steps. Discover & test has a single
search box and a kind switch (chat / image / speech / transcription /
embedding); the five discovery calls are unchanged, the switch only
decides which one answers. Roster is what has been added, including the
image roster, which had no visible list before. Availability is the
chat model, the two allowed lists, the per-workflow image settings and
the slide reviewer, under one Save.
Splitting the eight-key save follows from that rule: Save & Close writes
the five retrieval and citation keys; Save availability writes the chat
model, both allowed lists, the reviewer and the three image-settings
PUTs. No route, request shape or setting key changed.
Switching kind clears the results first — a row button would otherwise
add an image model to the chat roster.
The kind switch dispatches its event through document.defaultView's
CustomEvent. jsdom refuses one built from another realm, and the
existing announceModelsChanged() has exactly that bug: its event is
built from the Node global, dispatchEvent refuses it, and a try/catch
swallows the error — so models-changed propagation has only ever been
source-grepped, never actually tested. Left alone here to keep this
change to one subject.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Asking someone to find Settings → Security → Create new app password is a poor
first run, and it is the step people give up on. Nextcloud has its own answer:
Login Flow v2. The person enters their server address, signs in on Nextcloud the
way they normally do — SSO, 2FA, a password manager — and Nextcloud generates an
app password for this app. We never see their real password.
Pasting an app password still works, behind "Use an app password instead". It is
the fallback, not the front door.
The security of this is all in what is trusted. The remote server chooses both
the login URL and the poll endpoint, so both are SSRF-checked and both must be
on the host the person actually typed — an endpoint pointing elsewhere would
make this a request-forgery gadget aimed at whatever it named. The server
Nextcloud reports at the end is re-checked before it is stored. The poll token
is a credential, so polling happens server-side and the browser holds only an
opaque handle bound to its own account.
Flows live in memory with a 20 minute life, matching Nextcloud's own expiry: a
login lasts minutes, and a restart mid-flow is a retry rather than a loss.
Starting a second flow replaces the first, which is what clicking again means.
The tab is opened from the click itself, before the request — opening it after
an await is what a popup blocker stops.
Removed with Learning Hub: the WebDAV browse path. Its field, its route and its
column are gone, since nothing browses Nextcloud any more. nextcloud_folder is a
different column and still in use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
My Resources generates better slides than Learning Hub ever did — a typed deck
the model fills in, rendered by python-pptx with fit-to-slide text, figures, a
vision review and themes, against Learning Hub's markdown-through-pandoc — and
the articles and quizzes now live in the quiz app. Keeping a second, weaker
generator and a whole CMS beside it was not earning its maintenance.
Removed: three routers, the Learning Hub and Content Manager tabs, their
components and frontend modules, the five database tables, the WebDAV browser,
the content embedding column and its vector index.
Content was exported first — every article as markdown plus a full SQL dump of
all five tables — to ops-backups/learning-hub-export-*. That export is the
restore path; the migration's down() can recreate the shape but never the rows,
and says so.
Two things this simplifies rather than merely deletes:
generated_image_links existed only to record which published content an image
appeared in, and it was the sole reason a generated image could be read by
someone who did not make it. Images are now owner-only — the visibility rule is
one WHERE clause instead of a join across two tables and a published flag.
embeddings.js keeps the model discovery the admin panel uses and loses
searchSimilar and generateContentEmbedding, which queried a table that no longer
exists.
Kept deliberately: Nextcloud connect, disconnect and export, which are how a
generated note reaches a real filesystem and have nothing to do with Learning
Hub; learningRetrieval, which despite its name is the clinical corpus search My
Resources depends on; and the pandoc reference deck, still the fallback when the
python renderer fails, moved from assets/learning to assets/deck now that the
old name misleads.
Tests: four Learning-Hub-only files removed, and the individual cases inside
shared files that asserted its behaviour. Where a test used a Learning endpoint
only as a convenient example — the account-boundary token test, the policy
matrix — it now uses one that still exists, so the property it proves is
unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Both were removed from the gateway in favour of Kokoro, so the app's curated
voice lists for them went too. Kokoro's own check no longer has to exclude
them — it names its own voices and the list is open, so it accepts anything
that is not another family's voice.
Voices stay curated per family rather than discovered. Models are discovered
from /model/info; voices are not, because no TTS provider exposes its voice list
consistently, and a list guessed from a model id is how a picker ends up
offering a voice the provider rejects.
Tests that pinned the retired voices now pin the families that remain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
/metrics answered on every public hostname — app.pedshub.com, peds.danvics.com
and scribe.pedshub.com — with 201 lines naming routes, traffic volumes,
event-loop timings and process internals. No credential in it, but a free map of
the application for anyone probing. Prometheus scrapes the container directly
over the Docker network and never goes through the proxy, so a request carrying
X-Forwarded-For is by definition not Prometheus and now gets a 404.
METRICS_TOKEN allows an explicit override; unset, it can never match.
Verified: 404 on all three hostnames, and up{job="ped-ai"} still 1.
The workspace launcher was blank. [hidden]{display:none!important}, added to
stop "Retry loading settings" showing permanently, outranked
`body.assistant-mode-workspace .assistant-workspace-view { display:block }` —
and that view is the one element toggled by CSS rather than by JavaScript
removing the attribute, so it could never be revealed again. The attribute is
gone from the markup; the class already defaults it to display:none, so it still
starts hidden. Everything else using the attribute is JS-toggled and unaffected.
Verified with a real login: 17 cards at 208x40 in an 860x240 grid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Removed at the owner's request, entirely rather than switched off: the route's
allow-list, anonymous identity and flag lookup; the client's entry path, the
authFetch exception that let four endpoints out without an account, and the
workspace guard; the CSS, the in-page note, the admin flag and its save/load;
the test file and the assertions elsewhere that pinned it. Both settings rows
are deleted from app_settings.
Two things were checked rather than assumed on the way out. Removing the
anonymous identity collapsed every `if (!req.user.preview)` branch to its
authenticated side, so image tools, audit logging and citation storage now run
unconditionally — which is what they did before preview existed. And the route's
gate went back to a bare router.use(authMiddleware), which on a /api mount gates
every path below it in server.js; it is scoped to /clinical-assistant again, the
guard test catches it either way.
Verified after deploy: signed out, status, examples and chat all refuse with
401; signed in, chat still answers with 8 sources; extensions, encounters,
documents and admin remain shut.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Eleven routers are mounted on '/api' and called router.use(authMiddleware) with
no path. Mounted that way, the gate applies to every /api request that reaches
the router — including routes belonging to routers mounted further down
server.js. extensions.js did it from line 295; the assistant is mounted at 305.
So a signed-out request to /api/clinical-assistant/status was refused ten lines
before the preview middleware could look at it, whatever the admin setting said.
server.js line 250 already warned about this shape.
Each gate now names its own prefix, so a router protects its own routes and
nothing else. Verified afterwards that every namespace which must stay shut
still answers 401 signed out: extensions, encounters, memories, notes, diagrams,
generated images, image jobs, documents, audio backups, ED encounters,
don't-miss, patient education, billing, well visit, admin, transcribe and the
rest. Two of these routers were gating routes nobody realised they were gating.
Second defect in the same path: authMiddleware only ever looks for a token, so
calling it unconditionally after the preview identity had been assigned rejected
exactly the requests preview exists to serve. Only that identity may skip it;
authMiddleware stays strict everywhere else.
Preview now answers with a real cited answer, and stays as narrow as it was
designed to be — four allow-listed paths, no identity, nothing ownable. A test
now walks every /api router and fails on a blanket gate, which is how the last
six were found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU