docs: My Resources, sign-in codes, invitations, and what the image carries
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Has been cancelled

Nothing documented My Resources, the slide renderer, PubMed or web search, and
the authentication doc predated both sign-in codes and registration invitations.

docs/my-resources.md is new and covers the feature end to end: what a resource
is, where its material comes from, why both searches run in the route rather
than as tools the model never called, why keyword engines get the topic while
retrieval gets the instruction too, how a presentation is designed as a deck
rather than written as markdown, the separate multi-image path, and what the
export pipeline is made of.

docs/authentication.md gains sign-in codes — storage, lifetime, reuse,
supersession, guessing, and that two-factor still applies — and registration
invitations, including the exact condition that decides when a code may be
deleted and why it is written to match the status the list displays. Both new
rate limits are in the table, with a note that Express matches app.use paths on
segment boundaries, so a new sign-in endpoint needs its own limiter or it has
none at all.

docs/deployment.md now says what the runtime image carries and why — pandoc for
Word, python3 with apk-installed lxml and pillow for the slide renderer,
python-pptx pinned, and that PDF conversion is not in the image at all but goes
to Gotenberg, so Word and PowerPoint still work when it is down.

docs/configuration.md picks up LOGIN_RATE_LIMIT_MAX, LOGIN_CODE_RATE_LIMIT_MAX
and GOTENBERG_URL, none of which were listed. README gains a My Resources
section and indexes the two new docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-11 20:49:10 +02:00
parent b98ffc61c7
commit fa2e7523d6
7 changed files with 289 additions and 0 deletions

View file

@ -32,6 +32,14 @@ The app runs as an authenticated Express/Postgres service with a browser fronten
- Marp slide editing with preview and PPTX export.
- Keyword, semantic, and hybrid search using Postgres/pgvector where configured.
### My Resources
- Private teaching material any signed-in user can generate for themselves — nobody else sees it.
- Presentations are designed as slide decks (comparisons, tables, callouts, figures beside text), not written as markdown for a parser to guess at.
- Grounded in the indexed clinical library, and optionally PubMed and the web, each admin-enabled.
- Optional illustrations, several per resource, placed through the deck.
- Revise in place, and download as PowerPoint, Word or PDF. See [docs/my-resources.md](docs/my-resources.md).
### Clinical Assistant
- Optional MCP-backed clinical assistant integration.
@ -166,6 +174,8 @@ Primary references:
- `docs/ai-providers.md` for model/provider setup.
- `docs/speech.md` for server-side STT/TTS setup.
- `docs/learning-hub.md` for the CMS and education workflow.
- `docs/my-resources.md` for private teaching material, the slide renderer, and search sources.
- `docs/retrieval-tuning.md` for how much corpus each feature retrieves, and what it costs.
- `docs/configuration.md` for environment variables.
- `docs/deployment.md` for production deployment.
- `docs/mobile-build.md` for the Capacitor wrapper and app-store build notes.

View file

@ -103,6 +103,8 @@ Providers tested: Authentik, Azure AD, Okta, Keycloak, Google, PocketID.
|---|---|
| `/api/*` general | 200 req / min / IP |
| `/api/auth/login` | 10 / 15 min |
| `/api/auth/login-code/request` | 5 / hour |
| `/api/auth/login-code/verify` | 10 / 15 min |
| `/api/auth/register` | 5 / hour |
| `/api/auth/forgot-password` | 5 / hour |
| `/api/auth/resend-verification` | 3 / 15 min |
@ -111,6 +113,15 @@ Providers tested: Authentik, Azure AD, Okta, Keycloak, Google, PocketID.
Limits are per-IP (`express-rate-limit`). A clinic behind a single NAT shares
the bucket; increase or switch to per-user keying if that becomes a problem.
Requesting a sign-in code is limited more tightly than attempting one, because
each request sends mail to somebody else's address — the cost of abuse lands on
the mailbox owner, not the caller. `LOGIN_CODE_RATE_LIMIT_MAX` overrides it.
These are separate limiters rather than covered by the `/api/auth/login` one:
Express matches `app.use` paths on segment boundaries, so `/api/auth/login` does
**not** match `/api/auth/login-code/...`. A new sign-in endpoint needs its own
entry or it has no limit at all.
## Login enumeration resistance
`/api/auth/login` returns `"Invalid credentials"` for:
@ -121,6 +132,67 @@ the bucket; increase or switch to per-user keying if that becomes a problem.
`"Email not verified"` is still returned for unverified accounts — deemed a
necessary UX tradeoff over perfect indistinguishability.
## Sign-in codes
A six-digit code emailed to the address being signed in with, offered beside the
password rather than instead of it. The screen asks for the email first, then
shows both routes: the code depends on mail being delivered and the password
does not, so neither is allowed to be the only way in.
`POST /api/auth/login-code/request``POST /api/auth/login-code/verify`.
What makes it a front door rather than a weaker side entrance:
| | |
|---|---|
| Storage | bcrypt hash only, in `login_codes` — a code read out of the database is not a working credential |
| Lifetime | 10 minutes |
| Reuse | single use, marked used **before** the session is issued so a replay cannot race it |
| Supersession | requesting a new code deletes the previous one |
| Guessing | 5 wrong attempts burn the code; six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries at one code |
| 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 |
Generation uses rejection sampling on `crypto.randomBytes`, not modulo, which
would make low digits slightly likelier.
`loginCodes.sweep()` clears codes more than a day past expiry. It is fire and
forget: housekeeping never fails a request.
### Frontend note
`public/js/authFetch.js` keeps an allowlist of `/api` paths callable with no
verified account owner, and rejects everything else **before it is sent**. A new
pre-auth endpoint must be added there or it fails as a "Connection error" with
no request ever leaving the browser.
## Registration invitations
`registration_invites` holds codes that let someone register while
`registration_invite_only` is on. Only the hash is stored; the code is shown
once, at creation.
Four states: `active`, `used`, `expired`, `revoked`.
**Revoke** stops a live code and leaves the row, marked. **Delete** removes the
row, and is only permitted once the code can no longer be redeemed:
```sql
(used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW()))
```
Deleting a code that could still be redeemed takes it off the list without
taking it out of anybody's inbox: the holder keeps something that looks valid,
it quietly stops working, and nothing is left to say who had it. Revoked rows
are kept because revoking records a decision somebody took.
The condition is written to match the status the admin list displays. The
simpler `used OR expires_at <= NOW()` would also catch a revoked code whose date
had since passed — a row the screen still labels revoked and offers no delete
on, so button and query would disagree about the same row.
`DELETE /api/admin/invites/spent` clears them in bulk under the same rule. It is
declared **before** `/invites/:id` or Express reads `spent` as an id.
## Turnstile (Cloudflare bot protection)
Applied to `/api/auth/register` and `/api/auth/forgot-password` when

View file

@ -72,12 +72,15 @@ keys):
| `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile. Turnstile check is no-op when secret is unset. |
| `LOKI_URL` | Optional Loki ingest URL for shipping audit/api/access logs. |
| `NTFY_URL`, `NTFY_TOPIC` | Optional ntfy push for new-login / password-change notifications. |
| `LOGIN_RATE_LIMIT_MAX` | Sign-in attempts per IP per 15 min (default 10). Raised in the e2e stack so multi-worker Playwright runs do not trip it. |
| `LOGIN_CODE_RATE_LIMIT_MAX` | Emailed sign-in codes per IP per hour (default 5). Lower than the sign-in limit because each request sends mail to somebody else's address. See `docs/authentication.md`. |
### Integrations
| Variable | Purpose |
|---|---|
| `NEXTCLOUD_URL` | Nextcloud base URL (per-user credentials entered in app). |
| `GOTENBERG_URL` | Document conversion service for PDF export (default `http://gotenberg:3000`). PowerPoint and Word are produced in-process and keep working when this is unreachable; only PDF fails. |
| `S3_BUCKET`, `S3_REGION`, `S3_PREFIX`, `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_FORCE_PATH_STYLE` | Document object storage. `S3_FORCE_PATH_STYLE=true` for MinIO, Backblaze B2, most non-AWS providers. |
## `app_settings` — live runtime configuration

View file

@ -6,6 +6,24 @@
- Reverse proxy (Caddy, Nginx, Traefik) for TLS termination
- At least one configured AI provider (Bedrock / Azure / Vertex / LiteLLM / OpenRouter)
## What the image carries
Beyond Node, the runtime image installs a few tools that document export depends
on. They are in `Dockerfile` and worth knowing about before trimming it:
| | For |
|---|---|
| `pandoc-cli` | Word (`.docx`) export |
| `python3`, `py3-lxml`, `py3-pillow` | the slide renderer. Both libraries are C extensions with no Alpine wheels, so they come from apk rather than pip — installing them from source would mean carrying a compiler in the runtime image |
| `python-pptx==1.0.2` (pip) | builds the decks. Pinned: unpinned, a rebuild from the same commit could produce different slides |
| `ffmpeg`, `curl`, `jq` | audio handling and entrypoint scripting |
Roughly 58MB of that is Python. PDF conversion is **not** in the image — it goes
to Gotenberg over the network (`GOTENBERG_URL`, default `http://gotenberg:3000`),
so PowerPoint and Word still work when Gotenberg is down and only PDF fails.
See [my-resources.md](my-resources.md) for what the renderer does.
## Images
| Image | Role |

177
docs/my-resources.md Normal file
View file

@ -0,0 +1,177 @@
# My Resources
Teaching material a signed-in user generates for themselves — a deck for
tomorrow's session, a handout, a summary — kept privately and exported as
PowerPoint, Word or PDF.
Deliberately separate from the Learning Hub. That is moderator-owned content
published into categories for everyone; this needs no role beyond being signed
in, and nothing here is shared. Every statement filters on `user_id`, and there
is no route that returns another person's work. Sharing, if it is ever wanted,
should be a deliberate feature rather than something that leaks out of a
forgotten `WHERE` clause.
## What a resource is
| Column | |
|---|---|
| `markdown` | the readable artifact — what Word renders and what a text edit edits |
| `deck` | for a presentation, the slide structure the model designed (see below) |
| `image_ids` | the illustration jobs belonging to this resource, in the order they were made |
| `topic`, `grounded_count` | what it was asked for and how many library excerpts it was written from |
`MAX_PER_USER` caps how many a person may keep.
## Sources
One function, `gatherSources()`, answers "what is this written from" for both
generating and modifying, so the two cannot drift into offering different things
or searching them differently.
- **The clinical library** — on by default. Semantic retrieval over the indexed
corpus. Budgets are in [retrieval-tuning.md](retrieval-tuning.md).
- **PubMed** — admin-enabled, optional API key. Returns structured records so a
reference carries a PMID somebody can look up.
- **The web** — admin-enabled, provider-configurable (Tavily, Serper, Brave,
SearXNG).
- **Illustrations** — see below.
Each option hides itself when an administrator has not enabled it, so nothing
appears that a person could tick and then be refused.
Nothing here may fail a generation. A retrieval or search that comes back empty
is reported as a reason and the resource is written from what is available.
### Searching is the route's job, not the model's
Both searches run up front on the topic, and their results go into the prompt as
findings. They are **not** offered as tools.
They were, once. Tested live against a question explicitly about recent trials,
the model never called them — with or without corpus grounding, and no matter
how the tool description was worded, because the prompt ends "Output ONLY Pandoc
markdown" and a model told to output only markdown does not emit a tool call.
Calling `callAI` with the tool directly produced a correct 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.
### Keyword searches get the topic, retrieval gets more
PubMed ANDs every mapped term, so one unrecognised word takes a query to zero:
`"febrile seizures"` returns six results and `"febrile seizures in under-fives"`
returns none. A query that finds nothing is retried against progressively
shorter versions of itself, longest first, and the response says which query
actually worked. Those retries are spaced — three `esearch` calls back to back
trips NCBI's three-a-second limit without an API key.
When modifying, the library search gets the topic *plus* the instruction —
retrieval is semantic and benefits from context — while PubMed and the web get
the topic alone.
### Empty searches must not invite invention
When a search was asked for and came back empty, the prompt says not to invent a
citation, a PMID or a URL to fill the gap. Without that the model supplies them
from memory, and a fabricated PMID is indistinguishable from a real one.
## Presentations are designed, not written
A presentation is described as a **deck**: the model returns JSON naming a
layout per slide and the prompt for each figure it wants. Articles stay
markdown, which is what prose wants.
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" — the parser inferred a layout from
the shape of a list, and inferring is what made every deck look the same.
Layouts: `title`, `section`, `bullets`, `two`, `compare`, `table`, `callout`,
`figure`, `image`. See `src/utils/deckSchema.js` for what each accepts.
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 discard the layout choices.
Nothing costs more than the thing that went wrong — a reply that is not a deck
falls back to asking for markdown; a malformed slide degrades to bullets; a
comparison with one column is not a comparison; JSON wrapped in fences or a
covering sentence is read rather than refused.
## Illustrations
`resourceImages.js`, **not** the shared `imageTool.dispatch` — that one permits
exactly one image per request, which is right for a chat reply and wrong for a
twelve-slide deck, and the clinical assistant and Learning Hub depend on that
rule. Same queue, same storage, same `my_resources` workflow, same asset
endpoint; only the number differs, bounded at `MAX_IMAGES` because each figure
is a paid request.
"Use 3 diagrams" in the instructions is read as the number it is. Writing
"include a diagram of the airway" switches the illustration option on and says
why, rather than the request being dropped in silence; switching it off by hand
sticks.
`my_resources` is its own image workflow rather than a reuse of `learning_hub`
because `generated_image_links` only accepts `learning_hub` assets — that is the
barrier keeping a private illustration out of published content.
### Getting a model to illustrate at all
Two things had to be right, both measured:
1. The illustration guidance is the **last** thing in the prompt. Placed before
the output rules it lost: the model returned 3297 characters of markdown and
zero tool calls, while the same tool and wording in a shorter prompt produced
three calls.
2. Even last, it loses to a prompt carrying thirty library excerpts —
deterministically: library off → three calls, library on → none. So when the
author names a number the call is **required** rather than offered. With no
number named the choice stays the model's.
A model that has just made three tool calls also tends to sign off instead of
writing — `"I'll create the presentation and the three teaching diagrams."` was
once returned as the resource, 61 characters, because only a completely empty
body counted as missing. A body with no title block and no heading is now
treated as missing whatever its length.
## Export
| Format | Built by |
|---|---|
| `pptx` | `scripts/render_pptx.py` (python-pptx) from the stored deck |
| `docx` | pandoc, from the markdown |
| `pdf` | Gotenberg (LibreOffice), from whichever office file above |
Pandoc's pptx writer was the ceiling on how good a deck could be, and the model
on top made no difference to it: a handful of reference layouts, no per-slide
layout, no positioning, no control over how large an image is drawn. It also
leaves a bare `<a:bodyPr/>` on every shape, so slides overflowed until autofit
was injected into its emitted OOXML by hand.
The renderer sizes text to fit before writing the file rather than trusting
autofit — LibreOffice ignores `<a:normAutofit/>` when converting to PDF, which
is how slides were being cut off mid-sentence. Wrapped bullet lines hang under
the text. Images are drawn at their own aspect ratio.
If the renderer fails for any reason, pandoc still produces a deck: a plainer
deck beats a failed download. The log line is
`deck renderer failed, falling back to pandoc`.
Figures are fetched to a scratch directory for the renderer and removed
afterwards. One that cannot be fetched is left out rather than failing a
download that works without it.
**Runtime dependency:** the image carries `python3`, `py3-lxml`, `py3-pillow`
(apk — both are C extensions with no Alpine wheels) and `python-pptx` pinned at
1.0.2 from pip. Roughly 58MB. Unpinned, a rebuild from the same commit could
produce different decks.
## Modify
`POST /api/my-resources/:id/refine` rewrites a resource in place, keeping its
id, its downloads and its References section. It offers the same four sources as
generating — it had none, so "add what the 2024 trial showed" was answered from
the model's memory rather than by looking anything up.
The previous version is replaced, not versioned.

View file

@ -60,6 +60,9 @@ clamped on read so a bad value cannot break a search.
`search_limit` is how many excerpts to request; `context_chars` is how much text
to pull around each one.
See [my-resources.md](my-resources.md) for the rest of that feature — its
sources, the deck renderer and illustrations.
**My Resources shares the Learning budget deliberately.** Both generate a whole
teaching resource from a topic, so they want the same shape of context. If they
ever need to diverge, `src/utils/learningRetrieval.js` is the single place that

View file

@ -176,6 +176,12 @@ function attachFigures(deck, files, figureIds) {
});
var slides = (deck.slides || []).map(function (slide) {
var copy = Object.assign({}, slide);
// The renderer reads whatever path this field holds and embeds that file in
// the download. Nothing today can put a path in a stored deck — normalise()
// never copies one and the edit endpoint writes markdown only — but the
// field is cleared before it is set, so the only paths that can reach the
// renderer are the figures just fetched for this export.
delete copy.image;
if (copy.image_job && byJob[copy.image_job]) copy.image = byJob[copy.image_job];
// A figure slide whose picture never arrived is still a slide of text.
if (!copy.image && (copy.type === 'image' || copy.type === 'figure')) {