Sign-in is email → code at sso.pedshub.com, and new accounts come from an
invitation link minted there, so the app's own code emails and invite codes
recorded a path nobody can take. Gone: the login-code routes and their rate
limiters, the invite admin API and card, the invite field on the register
form, the "email me a code / use my password" choice on the sign-in screen
(an email now leads straight to the password), both utility modules, and
the invite-only setting. A migration drops login_codes and
registration_invites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The e2e stack shared production's Postgres — same server, same database,
same table. Seeded robots sat in `users` beside real clinicians, and
anything a test wrote, or a migration under test changed, landed on real
data. Nothing about "run the tests" should be able to reach an account
belonging to a person.
Now it has a Postgres and a Redis of its own, both on tmpfs: created
empty on every run, held in RAM, gone on teardown. scripts/e2e.sh is one
command that recreates the stack, seeds it, runs the browser and leaves
the app up at 127.0.0.1:3553 so it can be clicked around in, with the
report served at :3554.
Two bugs fell out of it immediately, both of which only a database that
did not already exist could have found:
The schema could not be built from nothing. The entrypoint migrated
before the app created its baseline tables, so the first migration
failed on saved_encounters not existing. It never showed because every
database this has ever run against already had the baseline. Then, one
layer down, 1777800000000_generated-images creates a table with a
foreign key to learning_content — which the baseline stopped creating
when Learning Hub was removed. Restoring into a brand-new database could
not have booted. The entrypoint now stands aside when the database is
empty and lets the app do it in the order it already gets right, and the
foreign key is only created where its target is. All 20 migrations
replay from empty, producing the same 23 tables production has.
Configuration lives in the database, so a throwaway one starts at
defaults — 14 settings against production's 49. That is why every model
picker was empty: models.custom did not exist. The tests were right and
the environment was incomplete, so the seed now states what the suite
depends on, with fictional model ids: a test should not pass because of
a setting somebody changed on the live system last week.
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
The sign-in screen asks for an email first, then offers both ways in together:
a six-digit code sent to that address, or the password. Beside rather than
instead — a code depends on mail being delivered and a password does not, so
neither may be the only route. "Use a different email" goes back a step, and
creating an account stays where it was.
What keeps it from being a second, weaker front door:
- Only a bcrypt hash is stored, so a code read out of the database is not a
working credential.
- Ten minutes, single use, marked used before the session is issued so a replay
cannot race it, and requesting a new one deletes the old.
- Five wrong guesses burn it. Six digits is a million possibilities, which is
plenty against a person and nothing against a script with unlimited tries.
- Requesting a code answers identically whether or not the address exists, and
every verify failure returns one message. A sign-in screen that says "no such
account" is a way of finding out who has one.
- Two-factor still applies: a code proves you can read the mailbox, which is one
factor, and an account that asked for a second still wants it.
- Its own rate limits, tighter for requesting than for attempting, because
requesting sends mail to someone else's address. These had to be separate
limiters: Express matches app.use paths on segment boundaries, so
/api/auth/login does not cover /api/auth/login-code — checked against a real
router rather than assumed.
Two bugs found while building it, both mine:
authFetch keeps an allowlist of endpoints callable with no verified owner and
rejects everything else before it is sent. The new endpoints were not on it, so
the request never left the browser and surfaced as "Connection error".
reveal() hid elements by appending 'hidden' to className and showed them with a
non-global replace, so hiding twice left two copies and showing stripped one.
The "use a different email" link never reappeared. It uses classList now, which
is idempotent.
Verified against the running server: correct code signs in, the same code again
is refused, a superseded code is refused, five wrong guesses burn it, an expired
one is refused, and the stored value is a hash. In the browser: requesting a
code advances the screen, a wrong code is refused without losing the screen, and
the password route still signs in.
Not yet demonstrated: a correct code typed into the browser. The harness keeps
racing the one-live-code rule — the page's own request supersedes whatever code
the test holds, and with SMTP off the delivered one cannot be read. The same
request reaches the server on the wrong-code path, and the endpoint itself is
verified, but that last step is untested end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Markdown could express about five of the things the renderer can draw, so the
model had no way to say "put this figure beside these three bullets" or "make
this a comparison with two labelled columns" — my parser inferred a layout from
the shape of a list, and inferring is what made every deck look the same.
A presentation is now described as a deck: the model returns JSON naming a
layout per slide and the prompt it wants each figure drawn from. Four layouts
were added to the renderer for it — two tinted labelled columns for a
comparison, a callout card for a red flag or a dose, a figure beside its
bullets, and a full-slide figure. Articles stay markdown, which is what prose
wants.
Markdown is still produced, serialised from the deck, so Word export and text
editing keep working and the stored artifact stays readable by a person. The
deck is stored alongside it because that serialisation is lossy by design:
round-tripping through markdown would throw away exactly the layout choices this
was built to capture. A resource made before this, or an article forced into
slides, still renders by inferring from its markdown.
Nothing here can cost more than the thing that went wrong. A reply that is not a
deck falls back to asking for markdown rather than saving the model's apology; a
malformed slide degrades to bullets rather than throwing; a comparison with one
column is not a comparison; a figure that cannot be queued leaves a slide of
text rather than an empty frame; and JSON wrapped in fences or a covering
sentence is read rather than refused.
Verified live on "croup versus epiglottitis": the model chose section, bullets,
table, compare, figure, callout and image layouts across thirteen slides, and
the exported deck was rendered to PDF, rasterised and looked at — the comparison
renders as two tinted cards, the red flag as a callout, and the figure sits
beside its bullets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Pandoc's pptx writer was the ceiling on how good a generated deck could be, and
the model on top made no difference to it. It maps markdown onto a handful of
reference layouts with no per-slide layout, no positioning and no control over
how large an image is drawn, which is why every deck came out as bullets on a
template — and why autofit had to be injected into its emitted OOXML by hand
afterwards, because LibreOffice ignores the autofit pandoc leaves off.
scripts/render_pptx.py draws the deck and src/utils/slideSpec.js decides what
each slide is. Markdown stays the stored artifact, so "change slide 4" is still
a text edit and Word export is untouched — pandoc still writes docx, where its
output is good.
What that buys, all of it visible in a rendered deck rather than argued for:
- 16:9, not pandoc's 4:3.
- A pipe table becomes a real table with a header band and banded rows, not
eight lines of text with pipes in them.
- A list longer than seven items becomes two columns instead of a wall of text.
- Text is measured and sized to fit before the file is written, so nothing
depends on a renderer honouring autofit.
- Wrapped lines hang under the text instead of running back to the margin,
which is the clearest single tell that a deck was generated.
- An image is drawn at its own aspect ratio, centred, with a caption.
Figures now reach the deck at all, which they never did. They were queued and
shown on the page, but nothing recorded that they belonged to the resource, so
an export could not include them: user_resources.image_ids holds them, a
modification adds to that list rather than replacing it, and export fetches the
finished ones to a scratch directory. They are spread through the deck rather
than appended, because ending on three unexplained pictures is worse than
showing each near its material, and a References slide stays last.
If the renderer fails for any reason, pandoc still produces a deck — a plainer
deck beats a failed download.
Verified end to end: a seven-slide request with three figures exported as a
13-page deck; the slides were rendered to PDF, rasterised and looked at. All
three formats still download.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
PubMed joins web search as an optional source for a generated resource: a
literature search on the topic, with abstracts, cited by PMID in References.
Off by default, admin-enabled, with its own optional API key (NCBI raises the
rate limit from 3/sec to 10/sec; it works without one).
Neither search is a tool any more, and that is the point. Offering them as
function calls meant the model decided whether to search, and with a prompt
ending "Output ONLY Pandoc markdown" it decided not to — every time, with and
without corpus grounding, no matter how the tool description was worded.
Calling callAI with the tool directly produced a correct pubmed_search call, so
the plumbing was never the problem. The search only ever needed the topic, and
the route knows the topic before it calls the model, so both searches now run up
front and their results go into the prompt as findings, exactly the way corpus
excerpts do. Ticking the box now means the search happened.
Verified live against deepseek-v4-flash: 30 corpus excerpts and 6 PubMed
results, and a References slide carrying both the library sources and four real
PMIDs (29562151, 38506440, 35721052, 28814254).
Three fixes to illustration, which had never once fired:
- The dispatch call had been lost in a refactor. The tool was still offered, the
model still called it, and the call was dropped, so no job was ever enqueued.
- imageContext was passed as a bare topic string where dispatch expects
{ request, history }, which made the bound request undefined.
- The prompt never mentioned the tool existed while explicitly demanding only
markdown — the same suppression that killed the searches. It now says an
illustration is available and that calling it is not a violation of that rule.
my_resources is its own image workflow rather than a reuse of learning_hub,
because generated_image_links only accepts learning_hub assets, and that is
exactly the barrier that keeps a private illustration out of published content.
The illustration renders in the panel, rather than a toast pointing at an image
history this feature does not have.
Verified end to end: job queued, rendered, and the asset served to its owner as
a correctly labelled subglottic-anatomy teaching diagram.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Learning is moderator-owned: content published into categories that everyone
sees, behind router.use(moderatorMiddleware). That is right for institutional
material and wrong as the only way in — an ordinary user could not generate
anything at all.
So this is a separate pathway rather than a loosening of that one. Learning is
untouched; the moderator gate stays exactly where it was. A signed-in user can
generate a deck or an article for their own use, keep it, refine it and export
it, and nobody else ever sees it.
Private by construction. Every statement filters on the owner and there is no
route that returns another person's work, which a test asserts statement by
statement rather than trusting. The foreign key cascades, so deleting an account
takes its drafts with it. There is no category, no publish state and no sharing:
adding sharing later should be a deliberate feature, not something that leaks
out of a forgotten WHERE clause.
Markdown is the artifact. Every format is rendered from it on demand — pptx and
docx by pandoc, both carrying the house reference deck, and PDF by Gotenberg,
whose LibreOffice preserves a deck's layout in a way rendering from markdown
would not. That is what makes "add a slide on when to admit" a text edit rather
than a binary patch.
Gotenberg was published on the host but on a network of its own, so reaching it
from a container went out and back through the host gateway. It now joins
danvics_convert, owned by danvics-net like the others. PDF is the one export
allowed to fail: if that service is down, the deck and the document still
download and the error says which.
Verified end to end as a plain user: the moderator route still refuses with 403,
generation returned a deck grounded on 12 corpus excerpts, the library lists
only their own, pptx/docx/pdf all downloaded valid, "add a slide on when to
admit" put the slide in the right place and left References last, and an
unauthenticated request gets 401 while someone else's id gets 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Citation quality
- A citation naming a source that never came back is never rendered as a
link, so it appears as plain text and nobody learns it happened. It is now
measured on the server, where the answer and the sources both exist, so it
is seen whether or not a browser rendered it.
- Four Prometheus counters feed a Grafana dashboard (Ped-AI Citation
Quality): answers, citations written, answers affected, and individual
unresolved markers. Only answers with at least one unresolved citation are
stored, with the question and the titles retrieval returned, so an operator
can judge whether retrieval came back thin or the model over-cited. Rows
expire after 30 days: this is a quality signal, not a transcript log.
- Both answer paths are covered. /chat/stream is normal; /chat is the
fallback the client uses when streaming fails, so auditing only the first
would have hidden exactly the answers produced under failure.
- The tracker is resolved on demand and allowed to be absent. Seven test
files load this route with a hand-built list of permitted imports, and
adding a hard dependency would mean editing all seven — and the eighth
written later would break. Observation must never be able to fail an
answer, so a missing module simply means no tracking.
- Metric registration reuses an already-registered counter, because this
module can legitimately load twice in one process.
SSO settings on mobile
- Six rows were laid out inline: flex with a 160px label and an input that
would not shrink, so on a phone the row was wider than the screen with
nothing to scroll and no way to reach the rest. They use .admin-row now,
which already stacks below 640px. Verified at 390px and 360px: nothing
off-screen, no sideways overflow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
registration_enabled was a single switch: open to anyone, or closed to
everyone. This adds the setting an operator actually wants in between — open
to people you invited.
A code is single-use, expires (7 days by default, 90 maximum), and can be
revoked or deleted. It is stored hashed with only its last four characters
kept, because an invite grants account creation and a database dump should
not hand someone a working one. The code is readable exactly once, in the
response that creates it.
The claim is a single conditional UPDATE carrying every condition, so two
registrations racing the same code cannot both succeed. It happens after the
account exists, so a code is never spent on a failed registration — and if
the race is lost, the just-created account is removed rather than left behind
as a free registration. The rejection never says which of the four reasons
applied; distinguishing them would tell someone probing codes which guesses
were closer.
Codes avoid I, L, O and U so they survive being read aloud or copied off a
screen, and matching ignores case and separators.
The sign-up field appears only when the server says a code is required. The
admin card creates, lists, revokes and deletes, and carries the toggle.
Verified against the live database: create, claim, second claim refused,
unknown code refused, revoking a used code refused, delete. 684 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.
Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).
Server (src/routes/notes.js):
GET /api/notes now filters deleted_at IS NULL
GET /api/notes/trash new — list trashed items, newest-
deleted first
DELETE /api/notes/:id now soft-deletes (sets deleted_at)
DELETE /api/notes/:id?hard=1 hard-delete, only allowed on items
already in trash (UI bug can't
erase an active note)
POST /api/notes/:id/restore pull a note out of trash
POST /api/notes/trash/empty hard-delete every trashed note for
the user
Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
• Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
• Trash tab shows deleted-at timestamps, Restore + delete-forever
per row, Empty-trash button at the bottom
• Active list and trash count refresh in parallel after every
save / delete / restore
• Delete button in the editor now says "Move to trash" and uses
the showConfirm helper (no native dialogs)
Sanitizer swap (public/js/notes.js):
Replaced the homegrown allowlist walker with DOMPurify (already
loaded from cdnjs in index.html, used by learningHub.js too).
Custom HTML sanitizers historically have bypasses; DOMPurify is
the right primitive.
Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
as new dev deps):
9 contract tests covering script-tag stripping, inline event
handlers, img onerror, iframe/object, style attributes, every
preserved tag in the allowlist, javascript: URI rejection,
null/undefined input, and nested-script-inside-paragraph. Total
test count: 37 → 46 passing.
SW cache bumped to pedscribe-v12-notes5.
New "Notes" tab under Clinical Tools — a per-user scratchpad that's
explicitly NOT fed into AI prompts (distinct from user_memories).
Two-pane layout: searchable list on the left, rich-text editor with
title + save/edit/delete on the right.
Backend:
migrations/…_add-personal-notes.js — personal_notes table
(id, user_id → users ON DELETE CASCADE, title, body, created_at,
updated_at) with indexes on user_id + (user_id, updated_at).
src/routes/notes.js — CRUD + one AI endpoint:
GET /api/notes list, newest-updated first
GET /api/notes/:id fetch one
POST /api/notes create (title + body required)
PUT /api/notes/:id update
DELETE /api/notes/:id remove
POST /api/notes/from-voice transcript → { title, body }
via callAI (admin-controlled
provider — never selectable by
the clinician).
Body + title encrypted at rest via the same cryptoUtil used for
user_memories; 500-note per-user cap; 200-char title / 50 KB
body limits.
Frontend:
public/components/notes.html — empty-state card ("Hello 👋"),
sidebar list with search, editor head with voice-bar (Dictate /
Pause / Resume / Stop + live timer + pulse indicator), Tiptap
body, metadata footer. Uses existing .tp-* toolbar classes.
public/js/notes.js — lazy-init on first tab activation; Tiptap
editor built from window.Tiptap (same bundle the Content
Manager uses); delegated list clicks; Ctrl/Cmd+S to save;
uses app.js's AudioRecorder + transcribeAudio so the STT
pipeline is shared. On stop → transcribe → /api/notes/from-
voice → drop the AI-structured title + body into the editor;
clinician reviews then saves.
public/css/styles.css — 70 lines of .notes-* styles (card
layout, warm empty-state with gradient icon + tips, pulse
animation for the recording indicator, focus states, hover
nudges).
public/sw.js — bump cache from pedscribe-v12 → pedscribe-v12-
notes1 so clients pick up the new module/component.
Admin-controlled STT provider: the recorder posts its audio blob
to the existing /api/transcribe (Google / LiteLLM / ElevenLabs /
Browser Whisper — whatever admin wired up in Settings). Users
cannot pick the provider from this UI.
Age parser (src/routes/billing.js):
- Now sums year + month + week + day matches so "4 yr 11 mo"
(59 months) correctly maps to the 5-11y billing bracket instead
of being billed as 1-4y. Added bounds sanity check.
Graceful SIGTERM shutdown (server.js):
- Closes the HTTP listener first, then drains batched audit queues,
then ends the Postgres pool. 9-second hard deadline to beat
Docker's 10-second SIGKILL. Previously an in-flight note save
during a container restart could truncate the write.
Explicit LLM fallback opt-in (src/utils/ai.js):
- The OpenRouter / LiteLLM silent fallback now requires admin
setting `ai.allow_model_fallback = true` (default: false). If
primary fails and fallback is disabled, the error is surfaced
to the caller. Prevents silent spillover from a BAA-covered
primary to a non-covered fallback.
Prompt injection delimiters (src/utils/promptSafe.js):
- Wraps user transcripts, dictations, refine-instructions, and
pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
appends an explicit system instruction telling the model to
treat the wrapped content as data rather than commands.
- Applied to soap.js, hpi.js, refine.js. Extend to other AI
routes incrementally.
Cross-tab logout sync (public/js/authFetch.js, auth.js):
- BroadcastChannel('pedscribe-auth') — logout in one tab posts
a message; all sibling tabs clear state and reload, dropping
any PHI-containing UI immediately.
Backup code race-free consumption (src/routes/auth.js):
- tryConsumeBackupCode() now uses a Postgres transaction with
SELECT ... FOR UPDATE so concurrent login attempts using the
same code serialize. First wins, second sees the already-
shortened array.
Optimistic encounter locking (migrations/...add-encounter-version):
- saved_encounters.version INTEGER NOT NULL DEFAULT 1
- POST /api/encounters/saved accepts an expected_version and
rejects with 409 if the row has advanced. Falls back to
last-write-wins if the client doesn't pass one (backward compat).
Audit log batching (src/utils/auditQueue.js):
- Audit / api_log / access_log writes are buffered in memory and
flushed every 1s or every 50 entries via one multi-row INSERT.
Under load this reduces DB pressure by ~50x. On SIGTERM the
shutdown path drains the queue before exiting.
Infrastructure only — no existing data or tables modified.
src/db/migrate.js — programmatic runner, fires at boot after
the existing idempotent initDatabase()
migrations/1744600000000... — intentionally empty example, documents
the file shape. Registered in the new
pgmigrations tracking table so it won't
rerun.
.node-pg-migraterc.json — CLI config (migrations-dir, utc naming)
docs/migrations.md — workflow + conventions
package.json — migrate:up/down/new/status npm scripts
(status is a direct pgmigrations query
since node-pg-migrate v7 lacks a status
subcommand)
src/utils/sessions.js:
- parseUserAgent now recognizes the Capacitor wrapper (UA suffix
"PedScribe-Android" / "PedScribe-iOS") and labels sessions
"PedScribe (Android)" instead of "Chrome on Android".
Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.