MSOUT // Anatomy

OneNote → Markdown · CLI & hosted web edition
Section 00 — Orientation

00The premise, in one paragraph

A user opens a web page, signs in with a Microsoft account, picks a notebook, and downloads it as a .zip of Markdown. No MSAL, no Graph API — the automation drives the real OneNote web UI through Playwright, because Microsoft does not offer an API for this.

The hard part is not the export. It is that a user's Microsoft password must reach a browser-automation process and nothing else, on infrastructure the user does not control. Almost every decision in this codebase follows from that one sentence.

The credential is bytes, never a value

The password is held in browser memory, sent cross-origin to exactly one build-time-configured origin, streamed byte-for-byte through two proxies, and handed to a Playwright driver inside a throwaway container.

It is never parsed, never JSON-encoded, never turned into a string, never logged, never retried.

Capability is split, deliberately

The capability to hold a password, the capability to reach the internet, and the capability to create containers on the host are three different processes on three different networks.

No single compromise reaches two of them.

The public surface is tiny

The backend's public surface is deliberately small and its authority surface is a closed set of verbs, because a browser-facing process is the one most likely to be found by a scanner.

And it makes almost no decisions about authorisation itself.

Two claims worth stating up front, because both are counter-intuitive.
1. A runner has unrestricted internet egress, on purpose. There is no allowlist of Microsoft hostnames — an allowlist fails quietly the day Microsoft changes a hostname. What is denied is topology: a runner has no route to the Docker socket, the host gateway, the api or the orchestrator.
2. The api — the browser-facing process — cannot read the exported notes or the saved cookies. It holds SQLite rows and an SSE hub. It never mounts the artifact tree.

Section 01 — The lineage

01Two products, one engine

There are two ways to use this system, and they are not two codebases — they are the same three libraries wrapped in two different shells. The libraries are the engine; everything else is packaging, and none of it is ever forked.

The three engine libraries published to npm · consumed as libraries, never spawned as CLIs
The interchange format between them is auth.json — a Playwright storageState blob, {cookies, origins}. login() writes it; listNotebooks() and runExport() both feed it straight into browser.newContext({storageState}). Nothing else crosses between them.
Two shells click to switch
CLI mode the original product · one Node process

ms-onenote-exporter login → list → export. One process, one Chromium, your terminal, your machine. The umbrella CLI is a pure translation layer: rename options, call one function, map the result to an exit code.

StepCallsReads the result
loginlogin()the boolean
checkcheckAuth(url, file)the boolean (positional, legacy 2-arg form)
listlistNotebooks()the returned array — an empty list is success
exportrunExport()the returned stats via exitCodeForStats() — deliberately not process.exitCode

The CLI reads the return value rather than the process exit code because runExport() sets process.exitCode as a side effect. Reading the side effect would make the library's contract unobservable and would leak state between callers.

Web mode the hosted edition · four containers

The same three libraries, but invoked from inside a disposable container that a Go orchestrator creates per session. The browser talks to a Node API; the API talks to the orchestrator; the orchestrator creates the runner; the runner is the only process that touches the password and the only one that may import an @msout/ package at runtime.

ComponentLanguageThe one thing it is
frontendReact 18 + Vitestatic files. no state, no secret, no authorisation logic
caddyCaddy 2.10TLS termination and a sendfile file server
apiFastify 5 + node:sqlitethe authority — session state, the SSE hub, the CSRF wall
orchestratorGo 1.25, zero depsthe pool. the only holder of docker.sock
runnerFastify 5 + Playwrightthe throwaway container holding the credential and Chromium
Version skew is real and intentional. The CLI pins the published versions it was released against; the hosted runner pins newer ones, and its Dockerfile asserts that its dependency list is exactly @msout/microsoft-webauth, @msout/microsoft-onenote-list-notebooks, @msout/microsoft-onenote-export-notebook, archiver, fastify — and fails the build otherwise.

Section 02 — Topology

02Four containers, four networks

This is the diagram everything else hangs off. Click a network row on the right to trace its members; click a container to read its brief. The single most consequential word in the whole compose file is internal: true — it is what makes "the api cannot reach the internet" a property of the topology rather than a rule somebody has to remember.

The stack hover a node · click a container · edges animate during the trace
credential path event stream files on disk control / signed call
component

click any container

Each box above is one process with exactly one job. Hover to highlight, click to read its brief — including what it is structurally prevented from doing.

Network matrix click a row to isolate its members
Slot lifecycle one pool, six states
vacant → starting → idle → bound → draining → dead → vacant

vacant means "no container and no create in flight", and it is deliberately distinct from starting. Conflating the two made every release shrink the pool permanently.

A claim creates a bound container rather than relabelling an idle one — an idle runner holds a tmpfs at /data, not a vault. Holding the empty artifact directory is not a capability; the vault bind is exactly what makes a container credential-bearing.

Host paths what each container actually sees
On the hostContainsWho may touch it
/srv/msout/vault/<guid>/auth.json, notebook cache, out/, logsorchestrator rw · runner rw
/srv/msout/artifacts/<id>/vault.zip or vault.partial.ziporchestrator rw · runner rw · caddy read-only
/srv/msout/artifacts/.staging/in-progress archivesnever servable, by construction
/srv/msout/data/api.dbSQLite in WAL modeapi only · named volume
The nest that makes isolation work. The orchestrator binds the session's own directory into its runner, not the vault root. So a runner physically cannot walk to a sibling session's auth.json — not because it is told not to, but because the path is not mounted into it.
Host paths resolve differently per container

The table on the left lists container-side names. On the host they resolve differently for each service, and only the orchestrator sees the identity that table used to claim for all of them.

ServiceHost sourceIn-container
orchestrator/opt/msout/data/vaultthe same path
caddy/opt/msout/data/artifacts/srv/msout/artifacts :ro
apinamed volume msout_api-data/srv/msout/data
A bind source is resolved on the host. That is why VAULT_HOST_DIR and ARTIFACT_HOST_DIR must be absolute host paths the api has also mounted. As named volumes they resolved to /var/lib/docker/volumes/…/_data, which the orchestrator did not know about — so the runner mounted an empty, root-owned directory and every session failed with EACCES: mkdir '/data/<guid>' while /healthz cheerfully said ok.

Section 03 — The hot path

03Trace the credential

Press play and watch a packet move across the topology above. Nine hops, four processes, three of which never learn what the packet says. The narration on the right names the mechanism at each hop — and the reason it is written that way.

Live topology idle
00 / 16
Narration what is happening and why
The rules, and what each one prevents

Every row below is load-bearing, and every one of them has shipped as a real bug at least once. The password is bytes from the browser's <input> to the Playwright driver — never a value in any process along the way.

RuleWhat it prevents
no JSON encoding of the bodyhunter2 becoming "hunter2" — a password containing a quote or backslash arriving as a different password
no "equivalent" copya byte-identical copy that is still a value living in a process
account travels as a headera delimiter in the body — and a delimiter is another truncation surface
framing checked twiceone refactor away from a 4 KB → 256 KB hole
cap created on the refusal path tooa caller who understates Content-Length and then sends 4 MB
no retry on this routereplaying a password defeats the point of a single submission
no trimminga trailing space is genuinely part of the password
the api never logs a bodythe credential route's body is the password
Identity: what is a session

The GUID identifies a session. The 43-character secret authorises it. Both are minted in the browser, which sounds wrong until you notice what it buys: the frontend never has to be trusted, because the server validates the shape mechanically and stores only the hash.

FactValueNote
session idGUIDgenerated client-side, validated as a UUID before it is ever joined to a host path
session secret256-bit base64url = 43 charsstored only as sha256(secret), compared with timingSafeEqual
artifact id32 random bytes, base64url, 43 charsminted by the api, not the runner — an id derived from the GUID would put it in every download path and every Referer
cookie__Host-msoutHttpOnly · Secure · SameSite=None · Path=/ · no Domain allowed by the prefix
secrets on the hostfiles, mode 0400environment variables carry paths, never values — an env var is visible in docker inspect

Section 04 — Inside the runner

04The recursive walk

This is what the runner does once it holds the credential. It calls runExport() — and that single call is a recursive descent through OneNote's own DOM, clicking through a real web application one navigation item at a time. Expand any node to see what happens inside it.

One notebook a synthetic tree — the real shape varies per notebook
notebook group section page failure path
Inspector select a node
Waiting for a page to actually be on screen

The old implementation slept a fixed 3 seconds after every click and hoped. The exporter now proves the page it wants is the page on the canvas, with a settled image count, using a signature that must be identical to the previous observation.

The canonical transition, recorded in the source:
[previous] → [previous+previous] → [] → [wanted+wanted] → [wanted]

Two titles means OneNote is mid-clone-transition. An empty canvas means it is mid-switch. The settled state is the requested page, the expected images all decoded, and the signature unchanged — which is why a single retry is offered before the run refuses.
ObservationVerdict
0 outlinesnever settled
0 titlessettled — an untitled page is legitimate
> 1 titlestill mid-switch, showing two pages at once
images > imagesReadycontent still decoding — not settled
title matches, normalisedsettled
The three things the walk refuses to guess
  • A group is a toggle, not an opener. aria-expanded is read before clicking. The original code clicked an already-expanded group and collapsed it — measured, it lost 8 pages in one notebook.
  • An empty section list at the root means the notebook is not there. It sets notebookNotFound rather than reporting a cheerful zero-page export. The summary then never prints "Export complete!".
  • Headless cannot unlock a password-protected section. It is skipped — and a directory named <name> [passProtected] is still created, so the omission is visible in the tree rather than silent.
Exit codes are part of the contract. exitCodeForStats(stats) returns 3 when anything was lost — groups, sections or pages. A missed asset never changes it, because a linked-but-undownloaded file is recoverable on a re-run and is called out in the note body instead.
Progress is counts, never a fraction. Mid-run the totals are not knowable, so announcing a percentage would be a lie. The exporter emits export-progress once per section — deliberately, because a per-page firehose would drown the log. The hosted edition therefore re-publishes the last known counts every 15 s as a heartbeat, which it states plainly: it cannot invent counts inside a section.

Section 05 — From pixels to a .md file

05Anatomy of a single page

One page in, one Markdown file out. Here is the actual pipeline, with a real DOM snippet on the left and the bytes it becomes on the right. Hover any element to isolate its contribution.

Input the live OneNote canvas, cloned
DOM sourceBecomes
.OutlineContainera block, in visual order
.WACEFContaineran attachment — a real downloaded file
.WACImageContaineran image, saved locally
.TitleOutlinethe file name
.TitleDateTimeOutlinethe first line of the body
iframe[src*=youtube|vimeo]an embed link
a[href^=onenote:]an internal link — resolved in a second pass
Visual order, not DOM order. Blocks are sorted by their real position — top edge, then left edge, within a 10 px band — so a file chip lands between the two paragraphs that introduce it, exactly as it appeared on screen.
Output on disk, inside Section/Page.md
MarkdownWhy
![[assets/x.png|alt]]Obsidian wikilink, not a relative path — the output is meant to be opened as a vault
[[assets/report.pdf]]an attachment becomes a link to the file beside it
<!-- onenote-link:id -->a placeholder — the target path is not knowable until the whole walk finishes
[[Real/Path|Text]]written in a second pass over every emitted file, once all paths are settled
> ⚠️ n assets…an honest failure note — rebuilt every run, so it disappears when a re-run succeeds
Attachments three strategies, tried in order
StrategyHowCost
directsharepoint / OneDrive only — opens the cloud page, clicks through the Office viewer to Download a Copy≈72 s
ui-clickdouble-clicks the chip in the OneNote canvas, races the download against a popupseconds
fallbacka plain request for the source URL, rejecting any text/htmlfast
A missing element is permanent, not retryable. If the clickable chip never appears in the DOM, that is not a transient network blip — retrying it three times is three seconds spent proving a thing that is already known. Every strategy run reports wins over attempts, so the distribution is visible instead of guessed.
On disk the shape a vault takes
Notebook/ Section/ Page.md assets/ Page_img_1.png Page_img_2.jpg Page_video_1.mp4 report.pdf Other Section/ Another Page.md Sub Group/ Nested Section/ Deep Note.md Locked Section [passProtected]/
Name collisions get a suffix; re-runs do not accumulate. Two pages titled "Duplicate Title" become Duplicate Title.md and Duplicate Title_1.md — the used-name sets are per-run and never consult the filesystem. That is what stops report.pdf_1.pdf chains from a previous bug.
But a re-run is a merge, not a clean mirror. A file from an earlier run that no longer corresponds to anything in the notebook is left in place, and the exporter says so in as many words rather than silently producing a subtly wrong vault.

Section 06 — The seam

06Two event vocabularies

The @msout packages report what they saw on screen. The api reports what is true of a session. Those are different claims, and neither side can see the other's vocabulary — which is exactly why the runner is a deliberate seam rather than a thin pipe. Click any row to see the reasoning.

package event what the screen showed
→
api event what is true of a session
Two SSE hops they are not interchangeable
  1. runner → api. A bounded backoff, and an explicit gap declared when the ring buffer cannot cover the request. The runner's frames carry the type inside the JSON, so it is parsed separately from the api's own writer.
  2. api → browser. EventSource with withCredentials: true. The api writes named frames, so the transport registers a listener per contract event name and keeps onmessage only as a fallback for an unnamed one.
Why that detail is load-bearing. EventSource cannot send custom headers — which is precisely why the CSRF header check on mutating routes is a security control rather than hygiene. And a transport that only listens on onmessage will silently receive nothing from a server writing named frames.
The pump starts before the request that triggers it. The runner publishes login-started on acceptance. A pump attached after the response misses events already emitted — and the symptom is a login that works, with no challenge shown and no failure reported.
The export terminal is the runner's, not the package's

The package states its own contract plainly: "what this package owes the caller is the truth… labelling is the caller's job." It emits export-done the moment the walk ends and returns its statistics separately.

After the call returnsapi outcome
the walk threwfailed · nothing staged
notebookNotFoundfailed · nothing staged
items lost, no pages writtenfailed · nothing staged
items lost, some pages writtenpartial · staged and labelled
otherwisedone · staged
Two ordering facts make this the only workable shape. The package emits export-done before it returns, so the counts do not exist when the event fires; and the api dedupes terminals by artifact id, so whichever arrives first is the only one acted on. Without this the api is told only pages: 0 and answers done — a 130-byte empty zip presented as a successful download. Which is exactly what happened on the deployed host.

Section 07 — Rules with teeth

07Invariants & the mechanism that enforces each

A boundary nobody can violate is worth ten boundaries everybody must remember. Each invariant below names the mechanism that enforces it and, where one exists, the assertion that catches its removal. Expand a card to read it.

Two reconcilers, one authority each

Two facts about the running system exist in two different places, and each has exactly one reconciler. Neither trusts the other's view alone.

FactAuthorityReconciler
session state · expiry · notebook list · export state · artifact id SQLite the api's sweeper — every 30 s
container existence · slot binding · artifact presence on disk Docker labels + the filesystem the orchestrator's pool — adopts on boot, never deletes
The seam that had no owner is the interesting one. A slot the orchestrator holds bound to a session that no longer exists: the api's sweeper prunes its rows, the orchestrator's reconciler adopts its containers, and neither can see it. The symptom is a pool that looks full and cannot fill — every login 409, recoverable only by restarting the orchestrator.

Closed by making the check liveness, not duration: the orchestrator reports what it holds bound, and every tick the api releases anything whose session has no row. The api's row is the definition of "live" — so there is no policy number to pick, and the answer survives a restart of either process. A row in any state owns its slot; the erase machine's own remove is the one exception, and it takes it.
The lesson that keeps recurring. An assertion about a handler is not an assertion about the transport. For anything crossing a process, a socket, a stream or a repository boundary, assert the bytes at the far side — not that the handler ran, not that a value was set. Ten separate defects in this project's history were invisible to a fully green suite: a capability declared but never executed; an SSE header asserted absent when absent, never present when present; JSON.stringify on a password body; an event pump left pointing at a runner the session had been rebound away from — every signal green, and the export simply lost.

The derived practice is one sentence: make the change, run the test, watch it go red. A test that has never failed is a claim, not a check.

Section 08 — Reference

08Ports, limits, lifetimes

Addressing
ServiceValue
caddy:80, :443 — the only published ports in the stack
api0.0.0.0:3000, unpublished
orchestrator:9100, unpublished
runner0.0.0.0:3100, unpublished, addressed by container alias
protocol version3
signing replay window60 s
Session lifetimes swept every 30 s
StateDeadline
unclaimed10 min
login in progress15 min
authenticated & idle30 min
absolute12 h
exportingno idle deadline
erasingnever touched
Runner & pool
KnobValue
recycle budget5 min
slot idle30 min
ready timeout45 s · 3 s probe
pool sizecompose default 2 · built-in 4
body quota8 GB · 2 GB min free
export timeout1 h · login 5 min
credential cap4 KB
Slot lifecycle a six-state machine, and why vacant is not starting
StateMeansTransition
vacantno container and no create in flight→ starting
startinga create is on the wire→ idle when healthy
idlea warm container holding a tmpfs, not a vault→ bound
boundclaimed by exactly one session, with the vault mounted→ draining
drainingreleasing→ idle or dead
deadgone→ vacant
Collapsing vacant into starting made every release shrink the pool permanently. And a claim creates a bound container rather than relabelling an idle one — because an idle runner holds a tmpfs at /data, not a vault. An idle pool one claim away from auth.json is the wrong shape: holding the empty artifact directory is not a capability, but the vault bind is exactly what makes a container credential-bearing.
Rate limits
  • 3 new sessions per hour per address — the deployed host raised this after real users hit it.
  • 60 requests per minute per address.
  • 6 concurrent exports service-wide, as a backstop on top of one-per-session.
  • Restoring an existing valid session is not charged against the session limiter.
Counters live in memory. A restart clears them. That is why the global caps and the pool size are the real backstop — a limiter a crash can reset is not the control that keeps the host up.
Getting the client address wrong is not theoretical. Proxy trust is off by default; the socket peer is the address, and the forwarded header is consulted only for a proxy on the configured trusted list. With that variable unset, every caller on the internet shared one bucket — three sessions an hour, each logged as the same internal proxy IP.
The deploy sequence that works
git archive --format=tar.gz -o msout.tar.gz HEAD scp msout.tar.gz root@host:/root/ ssh root@host cd /opt/msout && tar xzf … --exclude=.env --exclude=secrets sed -i "s|^IMAGE_TAG=.*|IMAGE_TAG=$SHA|" .env sed -i "s|^RUNNER_IMAGE=.*|…|" .env sh infra/check-env.sh # FIRST docker compose config --quiet # refuses, never defaults docker compose --profile runner build api orchestrator runner docker compose --profile runner up -d --wait ./infra/verify-deploy.sh "$SHA" # proves what is running
Four traps, each of which failed silently at least once. The image tag and the runner image are two variables and both must be the commit. The --profile runner flag is mandatory — without it, build runner "succeeds" without building and up -d prunes the network every login needs. And a .env containing $(…) is a silent total outage: the pool cannot create a runner, so every login 409s while every other signal looks healthy.
Where to start reading, by question
QuestionAnswer lives in
how does anything talk to anything?the compose file — its header comment explains the hostname
what is the design, and why?the backend's planning document
what is broken or unfinished?GitHub issues — the one consolidated list, so a document and reality cannot disagree
what is each service for?the four per-service READMEs, read in order: edge → api → orchestrator → runner
why is the topology like that?the infrastructure README
what does the browser send?frontend/src/lib/api.ts
what does the user see?the frontend's App.tsx and pages/
The project-level rule that produced this page. Everything here is read off the source, not off a plan. Where a plan and the code disagree, the code wins — and the disagreement is named rather than quietly resolved. Two repositories, two deployments, four backend containers: that is the whole system.