diff --git a/README.md b/README.md
index 1afd358e..1f4af880 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/docs/authentication.md b/docs/authentication.md
index 64283a44..c03262b3 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -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
diff --git a/docs/configuration.md b/docs/configuration.md
index d8e6583d..abfe7516 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -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
diff --git a/docs/deployment.md b/docs/deployment.md
index fcc85dae..8df531f1 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -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 |
diff --git a/docs/my-resources.md b/docs/my-resources.md
new file mode 100644
index 00000000..92dfe9ed
--- /dev/null
+++ b/docs/my-resources.md
@@ -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 `` 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 `` 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.
diff --git a/docs/retrieval-tuning.md b/docs/retrieval-tuning.md
index 9799e269..0eed4bbd 100644
--- a/docs/retrieval-tuning.md
+++ b/docs/retrieval-tuning.md
@@ -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
diff --git a/src/utils/documentExport.js b/src/utils/documentExport.js
index fa0513d4..285bb8ac 100644
--- a/src/utils/documentExport.js
+++ b/src/utils/documentExport.js
@@ -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')) {