ParkviewLabpaper-boxing

architecture

paper-boxing

what it is

paper-boxing is a small site manager for a home lab, the place where a project's documents live before its first release; what it is and why it exists are stated in northstar.md. The REST and MCP contract is api.md, and the way the stack is built, run and backed up is deployment.md. This document describes how the software is put together and why; the dated record of the choices is decisions.md, and each choice below links to its entry there.

the four containers

A deployment is one compose stack of four services and one data volume. The three paper-boxing images are built from one commit and carry one version (decided 2026-09-15); the site server is a stock nginx (decided 2026-09-15).

one compose stack a person an agent frontend :35840 reads the REST API per-browser session store on its own filesystem mcp :35842 reads the REST API writes nothing no token store backend :35843 REST API at /api/v1 enforces every rule itself reads and writes /data sites :35841 nginx:stable-alpine, 80 inside read-only, root /data/sites no authentication paper-boxing-data /data sites/<slug>/… staging/ paper-boxing.sqlite3 reads the site at <PUBLIC_SITES_URL>/<slug>/ straight from nginx signs in at /login the browser never holds the token REST API with the session token POST /mcp Bearer <agent token> on every request confirmed: GET /api/v1/tokens/self then the call, with the same token and X-Paper-Boxing-Via: mcp a file larger than the tool-call cap, with the same agent token PUT / GET /api/v1/sites/{slug}/files/{path} on the published port read-write /data read-only, root /data/sites carries a token no token paper-boxing image, one version stock nginx

The MCP server is a client of the backend's REST API exactly as the frontend is; the backend enforces every rule itself, whichever client calls; the frontend and the MCP server mount nothing.

ServiceImagePortReadsWrites
backendghcr.io/parkviewlab/paper-boxing-backend35843the data volume at /datathe data volume: sites/, staging/ and the database
frontendghcr.io/parkviewlab/paper-boxing-frontend35840the backend's REST APIits own per-browser session store, on its own filesystem
mcpghcr.io/parkviewlab/paper-boxing-mcp35842the backend's REST APInothing
sitesnginx:stable-alpine35841 (80 inside the container)the data volume at /data, read-only, with root /data/sitesnothing

The backend is a FastAPI application over SQLite; the frontend is a NiceGUI application; the MCP server is a FastAPI application carrying the MCP SDK's low-level Server behind a stateless StreamableHTTPSessionManager at /mcp; the site server is nginx with a configuration of a dozen directives, inline in the compose file. Each paper-boxing image listens inside its container on the number it publishes, so the port mappings are same-number; nginx listens on 80 inside and is published as 35841. Every paper-boxing image probes its own /health as its health check (the site server has none), and the frontend and the MCP server start once the backend is healthy. The ports are fixed in common/config.py (decided 2026-09-15); the address at which people and links reach the site server is PAPER_BOXING_PUBLIC_SITES_URL, from which every site's URL, <PAPER_BOXING_PUBLIC_SITES_URL>/<slug>/, is built. The README's configuration tables give every variable each service reads, with its default.

The volume, paper-boxing-data by default, is mounted read-write at /data in the backend and read-only at /data in the site server; the frontend and the MCP server mount nothing. It holds three things. sites/<slug>/... is the served tree, one folder per site. staging/ holds uploads in progress and sits on the same filesystem, so that a finished upload moves into place by one rename; the backend empties it at every start, since nothing there is in progress after a start. paper-boxing.sqlite3, in WAL mode, holds the accounts, the sessions and agent tokens (one table, told apart by type), the sites (slug, display name, creation time) and an index of the files (size, modification time and sha256 per file). The whole volume is mounted into nginx, rather than sites/ alone, because a Compose volume subpath needs Docker 26 or newer and the deployment host's version is not assured; with root at /data/sites, nothing beside that tree is reachable over HTTP (decided 2026-09-17).

the package and the images

One distribution, paper-boxing, in the src layout under src/paper_boxing/, with four subpackages (decided 2026-09-15). common/ is the contract and what every component shares: the pydantic schemas of every request and response body, the route table, the slug and path rules, the token types and scopes, the REST client BackendClient, the in-memory fake backend for tests, the error-body convention, the environment helpers, and the one place the version is derived from the package metadata. backend/, frontend/ and mcp/ are the three components. The core dependencies are pydantic and httpx; each image installs one extra on top: backend (fastapi, uvicorn, starlette, argon2-cffi), frontend (nicegui, httpx) and mcp (mcp[cli] pinned below 2, fastapi, uvicorn, starlette, httpx). tests/test_import_boundaries.py walks the source with ast and keeps the boundaries: common imports none of the three components, a component imports common and never another component, and the package root imports common only. Each component starts as python -m paper_boxing.<component>, also installed as the console script paper-boxing-<component>.

One Dockerfile builds the three images. A base stage on the uv python3.13 slim image copies pyproject.toml and uv.lock; each of the three targets installs its own extra with uv sync --locked --no-dev --extra <component> (the dependencies first, without the project, so that layer caches across source changes, then the project), sets its PORT, declares its EXPOSE and HEALTHCHECK, and runs uv run --no-sync python -m paper_boxing.<component>; only backend declares VOLUME ["/data"]. The Release workflow builds the three targets in a matrix for linux/amd64 and linux/arm64 and pushes them to GHCR with the tags X.Y.Z, X.Y and latest; there is no PyPI publishing, because paper-boxing is designed to run in Docker (decided 2026-09-15). The MCP subpackage follows the handbook's five-module layout for an MCP server (config.py, __main__.py, server.py, tools.py, schema.py) plus the permissions.py its reference servers carry, and one addition of paper-boxing's own, auth.py, the token gate described below. It serves Streamable HTTP only, with no stdio transport, because it runs in the stack on the Docker host for every machine on the LAN.

data flow

a person

A person opens the frontend and signs in at /login; the frontend calls POST /api/v1/auth/login, keeps the returned session token in NiceGUI's per-browser store, and from then on calls the backend's REST API with that token for every page and every action, through one shared HTTP client. An upload streams from the browser through the frontend to PUT /api/v1/sites/{slug}/files/{path} with its size declared; a download goes through the frontend's /download/<slug>/<path> route, which streams the file from the backend with the person's session, because the browser never holds the token. The person reads the site at <PAPER_BOXING_PUBLIC_SITES_URL>/<slug>/, straight from nginx, which serves the file the backend wrote.

an agent

An agent holds an agent token that a person created under Tokens, with the scope the person chose. It connects to POST /mcp on the MCP server's port with Authorization: Bearer <token> on every request, the handshake included. The gate confirms the token with the backend's GET /api/v1/tokens/self before anything else runs; the tools the agent sees are those its scope allows; a call is forwarded to the backend's REST API with the same token and the header X-Paper-Boxing-Via: mcp, and the backend enforces the scope again and logs the call under that token. A file larger than the tool-call cap goes to the backend's published port with the same token. What the agent writes is on the volume, served by nginx at once.

the API

Every route lives under /api/v1 and speaks JSON, except file bodies; every route except sign-in needs a bearer token. In outline:

auth
sign in, sign out
sites
list, create, one site, delete with the slug as confirmation
files and folders
list one folder, upload one file as the raw body, download one file, delete one file, delete a folder with an explicit recursive choice
tokens
list, create, the calling token's own record, revoke
users
list, create, change one's own password, remove

/health, /admin/version, /docs and /openapi.json sit outside /api/v1 and need no token. Every error, whatever produced it, has the body {"error": {"code", "message"}}. The contract, route by route with every status and code, is api.md; the route table itself is common/routes.py, from which the backend registers its routes, the fake backend registers the same, and the client builds its URLs, so the three cannot drift, and tests/test_routes.py checks that api.md names every route in the table.

authentication

Accounts are several and of equal standing: there are no roles, and any signed-in user adds and removes accounts, creates agent tokens and revokes any agent token (decided 2026-09-15). The first account is created from PAPER_BOXING_ADMIN_USERNAME and PAPER_BOXING_ADMIN_PASSWORD when no account exists; the variables are ignored once one does, and when they are unset and no account exists the backend starts and logs that nobody can sign in until they are set. There is no self-registration. The last account cannot be removed (409 last_user). Removing an account deletes its tokens with it, through the database's foreign key. A user changes their own password with the current one, and the change signs out that user's other sessions. Passwords are stored only as argon2id hashes (argon2-cffi, a random salt per hash, the cost settings inside the encoded string) and re-hashed at sign-in when the settings have been raised; a sign-in with an unknown username verifies a decoy hash, so it takes the same time as a sign-in with a wrong password.

There are two kinds of token.

a session token

A session token is issued at sign-in for the UI, acts with the full rights of its user (remove_destructive), and expires after a sliding period (PAPER_BOXING_SESSION_DAYS, default 14) that every authenticated request moves forward; an expired session is ended when it is first presented.

an agent token

An agent token is created by a signed-in user with a name and a scope, does not expire, and lives until it is revoked or its owner's account is removed (decided 2026-09-15).

Both kinds are 32 random bytes presented as pb_ followed by 43 URL-safe base64 characters, shown once at creation and stored only as sha256 digests; a presented secret is hashed and looked up by its digest, so no secret is ever compared, and every use is recorded. The scopes are ordered tiers:

tier 1
read_only

list and download

tier 2
read_write

adds creating a site and uploading

tier 3
remove_destructive

adds every delete

A token at one tier may do anything at that tier or below.

Each route has one access rule: public, any valid token, a scope of at least the route's tier, or a session token only. The session-only routes are sign-out and every route under tokens and users except GET /api/v1/tokens/self, which any valid token may call (it is how the MCP server confirms one), because managing accounts and tokens is a person's act; an agent token on one of them gets 403 session_required. A missing, unknown, revoked or expired token is 401 unauthorized before any other check, and a scope below the route's is 403 forbidden. The backend enforces every rule itself, whichever client calls; a request that came through the MCP server carries X-Paper-Boxing-Via: mcp and is logged as such, under the user and the token that acted, and no password or token secret is ever logged.

plain HTTP on a private LANThe deployment model is plain HTTP on a private LAN: passwords and tokens travel unencrypted, and there is no lockout after failed sign-ins; the README and the deployment guide say so plainly.

A tool call carries a file base64-encoded inside a JSON-RPC message, so the MCP server caps the files its tools carry at PAPER_BOXING_MCP_MAX_FILE_MB (default 8 MiB, decoded), and the tool descriptions say so. A larger file goes through PUT and GET /api/v1/sites/{slug}/files/{path} on the backend's REST API with the same agent token, up to the backend's own cap (PAPER_BOXING_MAX_UPLOAD_MB, default 200). That is why the backend's port is published rather than kept inside the compose network (decided 2026-09-17); nothing else needs it from outside.

the MCP server

The MCP server is its own image and process, a client of the backend's REST API exactly as the frontend is, rather than an endpoint built into the backend, so that the backend stays the one implementation of every rule and an upgrade of the MCP SDK never touches the process that writes files (decided 2026-09-15). It keeps three safeguards against token passthrough, taken from the MCP specification's security guidance.

1

Every request's bearer token is confirmed with the backend (GET /api/v1/tokens/self) before any tool runs; the server keeps no token store and nothing between requests.

2

Only a token the backend issued is accepted: an unknown, revoked or expired one gets 401 unauthorized with no tool run.

3

Only a token of type agent is accepted: a UI session token gets 403 wrong_token_type.

The confirmed token is forwarded to the backend on the call with X-Paper-Boxing-Via: mcp, and the backend enforces the scope again.

The safeguards live in mcp/auth.py as TokenGate, the ASGI callable at /mcp in front of the transport. Before it asks the backend, the gate answers what costs no confirmation, each in the contract's error shape with a fixed message:

405
a method other than POST
421
a Host outside PAPER_BOXING_MCP_ALLOWED_HOSTS (the transport's DNS-rebinding protection)
403
an Origin outside PAPER_BOXING_MCP_ALLOWED_ORIGINS (a non-browser client sends no Origin and passes)
400
a Content-Type other than application/json
413
a declared Content-Length above the body limit

The body limit is derived from the cap (a file at the cap, base64-encoded and line-wrapped, inside a JSON-RPC envelope of 64 KiB), replacing the SDK's default of 4 MiB, which would refuse an upload above about 3 MiB. A backend that cannot be reached, or that does not answer the confirmation as the contract says, is 503 backend_unreachable, with the detail logged and never answered; once the backend answers again the server recovers without a restart. CORS is limited to the same origin allowlist, and /sse, the path of the deprecated HTTP+SSE transport, answers 405 naming /mcp, so a legacy client fails instead of hanging.

The eight tools, each with a title, annotations, an input schema and an output schema, are listed to a token whose scope reaches the tool's tier and refused below it; the table is in api.md. A handler validates the slug and the path with the contract's shared rules before calling, so an impossible name costs no round trip, and the backend enforces every rule again. Every failure, the backend's refusal included, comes back as a tool result carrying the contract's error body with isError set, never as an exception the client cannot read. download_file reads the parent folder's listing first and refuses a file above the cap before its bytes travel.

storage

A path arrives validated by the contract's rules in common/naming.py: relative, /-separated, canonical (no leading, trailing or doubled slashes), with no . or .. segment, no backslash or control character, no segment over 255 bytes and no path over 1024 characters. On disk, backend/storage.py inspects every existing component below the site with lstat and resolves the whole path, refusing as 400 invalid_path a site folder or a path component that is a symbolic link, a path whose resolved form lies outside the site, and anything that is neither a regular file nor a folder. Nothing the API writes is ever a symbolic link, so a link inside a site means the volume was changed by hand; the API leaves it alone and listings skip such entries. A site's folder is created with its row and never adopted: a folder already on the volume under a new site's slug is 409 site_exists.

no lock held: the cap enforced on the bytes received, the sha256 computed as they arrive, the file synced to disk checked before a byte is read streams into staging/ site row confirmed the checks run again missing parents created os.replace into place index row written under the site's lock (one asyncio.Lock per site): a short critical section

A reader, nginx included, therefore sees the old file or the new one and never a partial one, and a failure of any kind leaves the old file whole with the staging file removed.

An upload is checked before a byte is read (the site, the path, then the declared length against the cap) and then streams into staging/ under a per-request name, with the cap enforced on the bytes received, the sha256 computed as they arrive, and the file synced to disk, all with no lock held. Only then is the site's lock taken, one asyncio.Lock per site created on first use, for a short critical section: the site row is confirmed to still exist (404 when the site was deleted meanwhile), the containment and target checks run again (400; 409 not_a_folder for a parent segment that is a file, 409 not_a_file for a folder at the path, 409 file_exists without overwrite=true), missing parent folders are created, the file moves into place with os.replace, and its index row is written. A reader, nginx included, therefore sees the old file or the new one and never a partial one, and a failure of any kind leaves the old file whole with the staging file removed. Every change to a site's tree (a file landing, a file or folder removed, the site created or removed) runs under that one lock, so an upload whose body arrived while a deletion ran cannot recreate what the deletion removed. A disk-full or quota error is 507 insufficient_storage with nothing half-written. Deleting a site renames its folder into staging/ in one step under the lock, so nginx stops serving it at once, and deletes its row, which takes its index rows with it; the parked folder is removed afterwards.

The files table is an index of the served tree, kept by every upload and deletion and reconciled by every listing. A listing stats each file in the folder: when its size and modification time match its row, the row's digest is reported without reading the file; otherwise (changed, or placed by other means) the file is hashed and its row rewritten; a row whose file is gone is dropped, and a file with no row is indexed. A folder's total size and newest time come from the rows below it, without a walk, and a site's file count and bytes come from the index. A site whose folder is absent from the disk lists as empty at its root. File operations are single-file: there is no batch upload, no folder upload and no route that creates an empty folder, since an empty folder has no meaning in a static site; folders come into being with the first file below them and stay until deleted (decided 2026-09-17).

the frontend

The frontend serves several people at once, and its rules keep their sessions apart. Every page is built inside its ui.page function for the connecting client; no UI element and no per-user or per-session value lives at module level, and tests/test_frontend_structure.py checks it. The one shared object is a stateless httpx.AsyncClient wrapped in BackendClient, opened in the application's lifespan and closed at shutdown; every call passes the caller's session token explicitly, so one client serves every signed-in person. Per-tab state, the results of the last upload, lives in app.storage.tab, so it survives a reload of that tab and is never seen from another tab or by another person. Transfers stream: an upload sends each file chunk by chunk with its size declared, and the download route streams from the backend into a StreamingResponse, so a file near the cap is never held whole in the frontend and one person's transfer does not block another's page (decided 2026-09-17). The upload control accepts several files at once and sends them one after another against the single-file route, a convenience of the page and not a capability of the server.

The session token lives in app.storage.user, NiceGUI's per-browser store, keyed by a cookie that PAPER_BOXING_STORAGE_SECRET signs; the frontend refuses to start without that secret rather than run with a guessable key. The store is persisted on the frontend container's own filesystem, under .nicegui/ in the working directory, not on the data volume: it is disposable state, and a re-created frontend container asks everyone to sign in again, whilst the backend's sessions and agent tokens, which live in the database, are unaffected. A middleware sends a request for a page to /login (remembering where to return) unless a token is stored for the browser, and answers a fetch, the download route, with a 401 in the contract's error shape instead, so that a sign-in page is never saved as a file. Whether a stored token is still valid is the backend's verdict on the next call, and a 401 there signs the person out. The header carries the official horizontal ParkviewLab logo file, the white artwork on the brand's deep teal, with its wordmark face embedded from the vendored Michroma file so that nothing is fetched from the network (decided 2026-09-17), and beside it the label paper-boxing v<version>, the version being the one paper_boxing.common.config derives from the package metadata, the same that /health reports, so that a person reads which version is running from any signed-in page (decided 2026-09-17). The label is set in Michroma, the name at 21 px and the version at 12 px on the name's baseline, separated by a gap of .5em, with a space character between them in the text so that the label reads paper-boxing v<version>; the label's face is one @font-face in the head, built from the same vendored woff2 as the logo's own declaration and embedded as data, so the label, like the logo, fetches nothing. Neither the logo nor the label can shrink in the header's row, so a header narrower than its contents wraps the row and the logo keeps its full 60 px height and natural width at every width (decided 2026-09-18).

the site server

The nginx configuration is inline in the compose file as a configs entry with content, so the stack is one file that can be pasted into Portainer's editor; the fallback for a Compose older than 2.23.1 is in deployment.md. The directives:

root /data/sites
the served tree inside the read-only mount
index index.html
so a site's or a folder's index.html is served at its address
autoindex on with autoindex_exact_size off and autoindex_localtime on
so a site or folder with no index.html shows nginx's own listing, which during early design is what a person wants of a few hand-made pages before an index exists, and costs one directive and no code (decided 2026-09-17)
try_files $uri $uri/ =404
so a missing path stays a 404
absolute_redirect off
because nginx redirects /<slug> to /<slug>/ and would otherwise write that redirect as an absolute URL naming its internal port 80 instead of the published 35841, which breaks the link
add_header Cache-Control "no-cache"
so a replaced file shows at once, revalidated through the ETag on every request
types
in the same context as the included stock mime.types so that it extends the table, adding application/javascript for .mjs and .cjs, application/manifest+json for .webmanifest, and text/plain; charset=utf-8 for source code, configuration, data and plain-text files (Python, TypeScript and JSX, Rust, C and C++, Swift and Objective-C, other languages, shell and build files, configuration and data, documents and text, schemas and interface definitions, diagram sources, XML schemas and tooling, robotics formats; the block is the list, and tests/_site_server_types.py is the check that holds it to those groups), so that the browser displays them where nginx's stock default_type application/octet-stream, set in its nginx.conf for every extension the table lacks, made it download them, with the bytes served unchanged and only the header differing, and with txt, ts, pl and pm overriding stock entries on purpose, which nginx reports as duplicate extensions at start-up
location ~ "/\.?[^/.]+$"
beside location /, matching a request whose last path segment has no dot, or is a dot-file with no further dot, a name from which nginx reads no extension: its default_type "text/plain; charset=utf-8" answers for such a name, so a file with no extension (README, LICENSE, Makefile, Dockerfile, an extensionless script, v1.2/NOTES inside a folder whose own name has a dot) and a bare .gitignore or .env display too, and its try_files is location /'s, with everything else inherited from the server block, so a folder asked for without its trailing slash is still redirected to its slash form and listed, and Cache-Control and read-only serving are as for any other request. A name with an extension never matches that location, so the stock default_type application/octet-stream stays in force for every extension in no table (.ttf and .otf, which the stock table lacks beside woff and woff2; an archive such as .tar.gz or .tgz; .wav audio; a .sqlite database; a .pyc; a source .map), and those download exactly as before. One cost is accepted: a text file not in UTF-8 (a Windows-1252 .txt, a UTF-16 .csv from a spreadsheet) displays with wrong characters where before the browser inferred the encoding of a .txt and downloaded the rest

The charset is written into the type rather than set with nginx's charset directive, because charset_types always includes text/html beside the types it names and would give every page, the listing and the 404 page a charset header that could override a page's own declaration; written into the type, the charset is carried by text/plain responses and by nothing else (decided 2026-09-17).

no authenticationThe site server has no authentication: anyone who can reach port 35841 can list every site and read every file, and the server's root, the sites/ folder, lists the site folders as a consequence of the listing decision. That is the accepted model for a private LAN, stated here as the northstar asks; an operator who wants the root closed adds one location to the inline configuration, as deployment.md describes.

Links relative to the site work; a link from the host root such as /css/x.css does not, because every site lives under /<slug>/.

tests

the unit and component tier

The unit and component tier is uv run pytest -m "not network and not integration" -q, what CI's test job runs after ruff, the format check and ty. It covers the contract's pure rules (naming, the route table, the schemas, the scopes, the client), the import boundaries, the single source of the version, and the compose file's inline nginx configuration, parsed as text and held to the table of extensions and types in tests/_site_server_types.py. The backend's tests build the app over a temporary data directory with a cheap argon2 profile and an advanceable clock: path containment (encoded traversal, symbolic links, special files), the atomic replace and its failure modes, the size cap, the intent guards, the per-site lock, the hashing of passwords and tokens, session expiry, and the access rule of every route. The MCP server's tests drive one session-scoped TestClient over the MCP app, pointed at the in-memory fake backend through server.http_client_factory, since the session manager refuses to start twice: the three safeguards, proven by the fake's record of every request; the gate's own answers (405, 421, 403, 400, 413); tools listed and refused by scope; round trips of a text file and a PNG compared by sha256; and every tool output checked against its output schema. The frontend's tests use NiceGUI's user fixture, which executes tests/frontend_main.py afresh per test against the fake backend, and a second simulated browser: the sign-in redirect, two people signed in at once who never see each other's session whilst the shared sites and tokens are listed to both, a token shown only once, and the upload, replace and delete flows.

the contract suite

The contract suite, tests/contract/, runs within the same tier and is parametrised over the two implementations of the contract: fake, the in-memory backend in common/, and real, the backend proper over a temporary directory. Every route is exercised against both with the same tests, statuses and error codes, and the same tests passing against both is what proves the fake a faithful model of the real backend, so that the frontend's and the MCP server's tests, which run against the fake, hold for the real one. uv run pytest tests/contract -q runs it alone.

the integration tier

The integration tier, tests/integration/ under the integration marker, runs against the four-container stack built from the tree with the compose override tests/integration/compose.build.yml and the settings in tests/integration/integration.env; CI's integration job runs it on every pull request, and PAPER_BOXING_INTEGRATION=1 uv run pytest -m integration -q runs it locally against a stack brought up with the commands in CONTRIBUTING.md. Without the variable the tier is skipped when no stack answers, so a plain uv run pytest passes on a machine with no stack; with it, an unreachable stack fails. It covers /health and one version on the three services; the frontend's sign-in gate; the MCP handshake over the published port with an agent token minted through the real backend, and its refusals; a site created and filled through the REST API with a session, and a second through the MCP server with an agent token, using the SDK's Streamable-HTTP client; every file fetched through nginx byte for byte and by sha256 with the content type nginx must answer, the index.html at a site root, the listing where there is none, a 404 for a missing path, the redirect that keeps the published port, and a replaced file showing at once; a self-contained page of 100 KiB or more generated in the test rather than committed; a third site holding a file of every extension the site server maps, each fetched through nginx with its exact Content-Type header, the charset on text/plain and on nothing else, together with names without an extension, extensions in no table, a folder asked for without its slash, and the listing, the 404 page and the read-only serving proven unchanged; the deletions, with the intent guards refused across the stack and nothing of a deleted site left on the volume, inspected through the backend container; and the MCP server answering 503 backend_unreachable while the backend container is stopped and recovering when it is started again, run last. Every test removes what it created, so the tier can run twice against one stack.