Initial commit with architecture file
Some checks failed
Automated Container Build / build-and-push (push) Failing after 4s

This commit is contained in:
Elijah 2026-06-10 15:37:23 -07:00
commit b393607602
3 changed files with 657 additions and 0 deletions

View file

@ -0,0 +1,25 @@
name: Automated Container Build
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Log into Local Registry
run: |
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
- name: Build and Push Image
run: |
# Force the entire image path string to lowercase dynamically
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]')
docker build -t "$IMAGE_PATH" .
docker push "$IMAGE_PATH"

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
# IDE & Editor files
.vscode/
.cursor/
.idea/
*.swp
*.xml
# Build outputs (if your Node app eventually compiles or bundles code)
dist/
build/
out/
# Local application run data

619
Project Architecture.md Normal file
View file

@ -0,0 +1,619 @@
# Self-Hosted PDF Editor — Implementation Plan
**Project codename:** `paperjet` (rename freely)
**Document version:** 1.1
**Status:** Foundational specification — this document is the single source of truth. Every AI agent working on this project reads this file first, in full, before writing code.
> **v1.1 changes:** resolved the four open questions — upload ceiling set to 200 MB; versioning reframed around accidental-loss recovery with a document-level **trash/restore** added as the primary safety net; cookie/proxy behavior pinned (direct HTTP for dev only, everything through the HTTPS proxy in production); signature capture specified as **draw + type-to-signature with live multi-font preview**.
---
## 1. Purpose & Scope
A self-hosted, single-user, browser-based PDF editor with a Sejda-style annotation workflow. The user adds text boxes, freehand drawing, signatures, images, highlights, and shapes onto a PDF, moves and resizes them freely, and exports a flattened PDF. The application persists work continuously (autosave), keeps a per-file version history, and supports undo/redo.
It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application's own containers are permitted.
### Non-goals (v1)
- Multi-user collaboration / real-time co-editing
- True content editing (reflowing or rewriting the PDF's existing text stream)
- OCR (can be added later as an annotation-source plugin)
- Cloud sync
The architecture must not foreclose these. Future features are designed for, not built now.
---
## 2. Key Architectural Decisions (and changes from earlier discussion)
These decisions supersede anything discussed earlier in planning. Where they change a prior assumption, the change and its rationale are called out.
### 2.1 Rendering happens on the client, not the server *(CHANGED)*
Earlier planning floated server-side page rendering (PyMuPDF renders each page to an image, the browser displays the image). **We are not doing that.** The browser renders pages directly with PDF.js.
**Why:** Client-side rendering gives crisp, GPU-accelerated, infinitely zoomable pages with no per-page network round-trip and far less bandwidth — which matters for the WAN/reverse-proxy use case. Server-side image rendering would be blurry on zoom, slow over WAN, and heavier on the server.
**Consequence:** The server's PDF engine (PyMuPDF) is used for exactly two things — generating small thumbnails for the home page, and flattening annotations into the final exported PDF. It never renders full pages for the live editor.
### 2.2 The server stores annotations opaquely; only export understands them
The backend persists the annotation array as a JSON blob and does not parse individual annotation types for normal CRUD. Only the **export renderer** has per-type logic, dispatched through a type registry. This is the core extensibility decision: a new annotation type (e.g. "stamp", "checkbox", "redaction") requires a new frontend tool and a new export-renderer function, with **no database migration and no API change**.
### 2.3 `pdf-lib` is dropped entirely *(CHANGED)*
Because all PDF writing happens server-side in PyMuPDF, the browser never manipulates raw PDF bytes. Earlier planning included `pdf-lib` as a fallback writer; it is removed. One rendering engine for display (PDF.js), one writing engine for export (PyMuPDF). Fewer engines, fewer coordinate-mismatch bugs.
### 2.4 Versions store annotation snapshots, not flattened PDFs — and exist to recover from accidental loss *(CHANGED / IMPROVED)*
Earlier planning suggested flattening a real PDF per version. Instead, a version is a lightweight JSON snapshot of the annotation state. The original uploaded PDF is immutable on disk; every version is that original plus a layer of annotations. Flattening to an actual PDF happens **only on export/download**.
**Purpose drives the policy.** Versioning here is a *safety net against accidental loss*, not a granular editing timeline. There are two distinct things a user can lose by accident, and each gets its own net:
- **A whole document**, deleted by mistake → recovered via a document-level **trash/restore** (soft delete, §6.2 / §9.6).
- **Annotation work inside a document**, cleared or overwritten by mistake → recovered via **version checkpoints** (§9.2).
Because the goal is recovery rather than fine-grained history, auto-checkpoints are taken at meaningful safety boundaries (document close, and immediately before any destructive operation like "clear all" or a restore) rather than on a noisy time-based cadence. Manual checkpoints are always available.
**Why snapshots:** they're kilobytes, not megabytes, so both nets are effectively free. Any version restores by loading its JSON, and exports on demand.
### 2.5 Canonical coordinate space is fixed and documented
All stored coordinates are in **PDF points (1/72 inch), top-left origin, relative to the page's unrotated CropBox.** This single rule is the most important correctness constraint in the project (see §8).
### 2.6 Session auth with hashed password, not HTTP Basic *(CHANGED)*
Because the app is WAN-exposed, auth is a real login screen backed by an Argon2id password hash and a signed, httpOnly, SameSite cookie — not HTTP Basic Auth. Login is rate-limited.
---
## 3. Technology Stack (final)
| Layer | Choice | Notes |
|---|---|---|
| Frontend language | TypeScript (strict mode) | Type safety across the PDF/screen coordinate boundary |
| Frontend framework | React 18 | Largest ecosystem; best-represented in all agents' training |
| Build tool | Vite | Fast, simple, standard |
| PDF rendering (client) | PDF.js (`pdfjs-dist`) | Display only |
| Canvas editing overlay | Fabric.js | Text boxes, drawing, drag/resize/select handles |
| Frontend state | Zustand | Lightweight, predictable; good fit for editor + undo stack |
| Styling | Tailwind CSS | Fast iteration toward a clean Sejda-like light UI |
| Backend language | Python 3.12 | Chosen for PyMuPDF |
| Backend framework | FastAPI | Async, typed, auto-generates OpenAPI |
| PDF processing (server) | PyMuPDF (`pymupdf` / fitz) | Thumbnails + export flatten. MuPDF compiled into the wheel — self-contained |
| Validation | Pydantic v2 | Mirrors the TS annotation types |
| ORM | SQLAlchemy 2.x | With Alembic for migrations |
| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container |
| Password hashing | Argon2id (`argon2-cffi`) | |
| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry |
| ASGI server (backend) | uvicorn | Behind nginx |
| Orchestration | Docker Compose | |
| External programs / containers / APIs | **None** | Hard requirement |
**The user's own nginx reverse proxy** sits in front of this stack and is out of scope for the compose file (it already exists). The compose stack exposes one HTTP port for the proxy to target.
---
## 4. System Architecture
```
┌───────────────────────────┐
Internet (WAN) ───────▶│ User's nginx reverse proxy│ (existing, out of scope)
LAN ──────────────────▶│ TLS termination, routing │
└─────────────┬─────────────┘
│ http :8080
┌─────────────▼─────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ frontend │ │ backend │ │
│ │ nginx │─────▶│ FastAPI │ │
│ │ serves SPA │ /api │ uvicorn │ │
│ │ proxies /api│ │ PyMuPDF │ │
│ └──────────────┘ │ SQLAlchemy │ │
│ └───────┬────────┘ │
│ │ │
│ ┌──────────▼────────┐ │
│ │ volumes │ │
│ │ pdf_storage/ │ │
│ │ thumbnails/ │ │
│ │ db/app.sqlite │ │
│ └───────────────────┘ │
└────────────────────────────────────────────┘
```
- The `frontend` nginx serves the SPA and reverse-proxies `/api/*` to the `backend`. This means the browser sees a single origin — no CORS complexity, cookies "just work."
- The user's outer reverse proxy points at the `frontend` container's published port.
---
## 5. Repository Structure
A single monorepo. Two deployable images.
```
paperjet/
├── README.md
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
├── docker-compose.yml
├── .env.example
├── .github/workflows/ci.yml # lint + typecheck + test on push
├── frontend/
│ ├── Dockerfile # multi-stage: build SPA, serve via nginx
│ ├── nginx.conf # SPA fallback + /api proxy
│ ├── package.json
│ ├── tsconfig.json # strict: true
│ ├── vite.config.ts
│ ├── tailwind.config.ts
│ └── src/
│ ├── main.tsx
│ ├── app/ # routing, layout, theme
│ ├── pages/
│ │ ├── LoginPage.tsx
│ │ ├── HomePage.tsx # recent + upload + library tabs
│ │ └── EditorPage.tsx
│ ├── features/
│ │ ├── auth/
│ │ ├── library/ # list, upload, delete, search
│ │ ├── editor/
│ │ │ ├── canvas/ # Fabric.js integration
│ │ │ ├── tools/ # one module per annotation tool
│ │ │ ├── toolbar/
│ │ │ ├── pages/ # PDF.js page rendering
│ │ │ └── history/ # undo/redo stack
│ │ └── versions/
│ ├── lib/
│ │ ├── coords.ts # ⚠ THE coordinate transform module (§8)
│ │ ├── api/ # generated/typed API client
│ │ └── annotations/ # shared annotation type defs + registry
│ └── types/ # generated from backend OpenAPI
├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── alembic/ # migrations
│ └── app/
│ ├── main.py # FastAPI app, router mounting
│ ├── config.py # env-driven settings (Pydantic Settings)
│ ├── db.py # engine, session, WAL pragma
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic request/response models
│ ├── api/v1/
│ │ ├── auth.py
│ │ ├── documents.py
│ │ ├── annotations.py
│ │ ├── versions.py
│ │ └── export.py
│ ├── services/
│ │ ├── storage.py # file IO on the volume
│ │ ├── thumbnails.py # PyMuPDF thumbnail generation
│ │ └── export/ # PyMuPDF flatten + type registry (§9.3)
│ │ ├── renderer.py
│ │ └── handlers/ # one handler per annotation type
│ ├── auth/ # hashing, session cookie, dependencies
│ └── tests/
└── shared/
└── annotation-schema.json # canonical schema; source for TS + Pydantic
```
**`shared/annotation-schema.json`** is the contract for the annotation data model. Both the TypeScript types and the Pydantic models derive from it. When the model changes, it changes here first.
---
## 6. Data Model
SQLite, WAL mode enabled. UUIDv4 string primary keys throughout.
### 6.1 Tables
**`settings`** (singleton row, `id = 1`)
| column | type | notes |
|---|---|---|
| id | INTEGER PK | always 1 |
| password_hash | TEXT | Argon2id; NULL until first-run setup |
| created_at | TEXT (ISO8601) | |
| updated_at | TEXT | |
**`documents`**
| column | type | notes |
|---|---|---|
| id | TEXT PK | uuid |
| title | TEXT | display name, user-editable; defaults to filename |
| original_filename | TEXT | |
| file_path | TEXT | path on `pdf_storage` volume |
| thumbnail_path | TEXT | path on `thumbnails` volume; NULL until generated |
| size_bytes | INTEGER | |
| page_count | INTEGER | |
| created_at | TEXT | upload time |
| updated_at | TEXT | last edit (drives "recently edited") |
| deleted_at | TEXT | soft delete; NULL = active |
**`annotation_states`** (current working layer, one row per document)
| column | type | notes |
|---|---|---|
| document_id | TEXT PK FK | → documents.id |
| data | TEXT (JSON) | the annotation array (§7) |
| updated_at | TEXT | written by autosave |
**`versions`** (history snapshots)
| column | type | notes |
|---|---|---|
| id | TEXT PK | uuid |
| document_id | TEXT FK | |
| label | TEXT | NULL for auto-checkpoints |
| data | TEXT (JSON) | full annotation snapshot |
| kind | TEXT | `manual` \| `auto` |
| created_at | TEXT | |
Indexes: `documents(updated_at)`, `documents(deleted_at)`, `versions(document_id, created_at)`.
### 6.2 Soft deletes / trash (v1 feature)
Deleting a document is a **soft delete**: it sets `documents.deleted_at` and moves the row out of the active library into a **Trash** view, from which it can be **restored**. This is the primary recovery path for an accidentally deleted file. The underlying PDF, thumbnail, and annotation state are retained while the document is in trash.
- **Restore** clears `deleted_at`, returning the document to the library with its annotations intact.
- **Permanent delete** happens either when the user empties trash explicitly, or automatically after a retention window (default **30 days** in trash), at which point files and rows are purged. Retention is a config value.
- Bulk delete and bulk restore operate on multiple selected items.
---
## 7. Annotation Data Model
The single most reused structure in the system. Defined once in `shared/annotation-schema.json`, mirrored as a TS discriminated union and Pydantic models.
### 7.1 Envelope (common to all types)
```jsonc
{
"id": "uuid",
"page": 0, // 0-indexed
"type": "text", // discriminator; drives the registry
"rect": { // CANONICAL space: PDF points, top-left origin (§8)
"x": 72.0,
"y": 144.0,
"width": 200.0,
"height": 48.0
},
"rotation": 0, // degrees, clockwise, about rect center
"z": 3, // z-order within the page
"props": { /* type-specific, opaque to the server */ },
"createdAt": "2026-06-10T...",
"updatedAt": "2026-06-10T..."
}
```
### 7.2 Per-type `props` (v1 set)
| type | `props` shape (summary) |
|---|---|
| `text` | `{ text, fontFamily, fontSize, color, align, bold, italic, lineHeight }` |
| `draw` | `{ paths: [[x,y]...], strokeColor, strokeWidth, opacity }` (points in rect-local space) |
| `signature` | discriminated by `mode`: <br>• draw → `{ mode: "draw", ref, strokeColor }` (a captured raster/vector signature, stored as an asset) <br>• type → `{ mode: "type", text, fontFamily, color }` (rendered as text in a bundled signature font) |
| `image` | `{ ref, naturalWidth, naturalHeight }` (uploaded raster) |
| `highlight` | `{ color, opacity }` |
| `shape` | `{ kind: "rect"\|"ellipse"\|"line"\|"arrow", strokeColor, fillColor, strokeWidth }` |
Large binary payloads (signature PNGs, placed images) are **not** inlined into the JSON. They are uploaded to an assets endpoint and referenced by id (`ref`). This keeps the annotation JSON small and the autosave PUT cheap. See §9.4.
### 7.3 Extensibility contract
- Adding a type = (a) add its `props` schema to `annotation-schema.json`, (b) add a frontend tool module under `editor/tools/`, (c) register a frontend renderer + an export handler in the registries. **No DB or API change.**
- The server must round-trip unknown `type` values without error (forward compatibility): if an export handler is missing for a type, it is skipped with a logged warning, never a crash.
---
## 8. The Coordinate System (read this twice)
**Canonical storage space:** PDF points (1/72"), **top-left origin** (y increases downward), relative to each page's CropBox at **0° rotation**.
Rationale for top-left origin: Fabric.js canvas space is top-left, and PyMuPDF's high-level API is top-left native — so two of the three engines need no flip. Only PDF.js (whose `viewport` PDF-point conversions are bottom-left) is flipped, in exactly one place.
`lib/coords.ts` is the **only** module permitted to convert between coordinate spaces. No component computes its own transforms inline. It exposes:
```ts
// canonical (PDF points, top-left) ↔ screen (CSS pixels at current zoom)
pdfToScreen(p: PdfPoint, viewport: PageViewport): ScreenPoint
screenToPdf(p: ScreenPoint, viewport: PageViewport): PdfPoint
pdfRectToScreen(r: PdfRect, viewport): ScreenRect
screenRectToPdf(r: ScreenRect, viewport): PdfRect
```
It must correctly handle, with unit tests for each:
1. **Zoom** — scaling without cumulative drift.
2. **Page rotation** — pages with intrinsic /Rotate of 90/180/270°.
3. **Device pixel ratio** — retina vs standard displays.
4. **CropBox ≠ MediaBox** — pages whose visible box is offset from the media origin.
5. **Y-axis flip** for the PDF.js boundary specifically.
**Verification gate:** before any annotation tool is built, write a round-trip test — `screenToPdf(pdfToScreen(p)) ≈ p` within 0.01pt across all rotations and zoom levels — and a cross-engine test that places a known rect via the frontend transform and confirms PyMuPDF stamps it in the same visual location. The export renderer and the live editor must agree pixel-for-pixel. This gate is non-negotiable; misalignment here corrupts every saved annotation.
---
## 9. Backend Behavior
### 9.1 Autosave model
- Frontend debounces 500ms after the last edit, then `PUT /documents/{id}/annotations` with the **full** current annotation array (idempotent full-state replace — simpler and more crash-safe than diff patches at this scale).
- The PUT updates `annotation_states.data` and bumps `documents.updated_at`.
- Concurrent-tab safety: last write wins (single user, acceptable). Include an `updatedAt` in the payload so a stale tab can be detected and warned, but do not block.
### 9.2 Versioning model
- **Manual checkpoint:** `POST /documents/{id}/versions` snapshots current working state into `versions` with `kind=manual` and an optional label. Manual versions are kept indefinitely.
- **Auto checkpoint:** the backend snapshots automatically at *safety boundaries* — on document close, and immediately **before any destructive operation** (a "clear all", or a version restore). This keeps the net focused on recoverable accidents rather than logging every keystroke. Auto versions are retained for a rolling window (default **30 days**, config-driven) and pruned beyond it; manual versions are never auto-pruned.
- **Restore:** `POST /documents/{id}/versions/{vid}/restore` first auto-snapshots the *current* state (so a restore is itself undoable), then copies the target version's `data` into the working state.
In-document accidental edits during a single session are also covered by the in-memory undo/redo stack (§12.3); versions are the cross-session and pre-destructive-action net.
### 9.3 Export (flatten) via type registry
`services/export/renderer.py` opens the original PDF with PyMuPDF, iterates the annotation array, and dispatches each by `type` to a handler in `services/export/handlers/`. Each handler receives `(page, annotation, coord_ctx)` and draws into the page using PyMuPDF primitives (`insert_textbox`, `draw_line`, `insert_image`, `draw_rect`, etc.). Output is written to a temp file and streamed back.
- The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else.
- Missing handler → log + skip, never crash (§7.3).
- Export accepts an optional `versionId` to export a historical version instead of the working state.
- Fonts: bundle inside the backend image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability.
### 9.4 Assets (binary annotation payloads)
- `POST /documents/{id}/assets` (multipart) stores a signature PNG or placed image on the `pdf_storage` volume, returns `{ ref }`.
- Annotations reference assets by `ref`; the JSON stays small.
- Orphan assets (unreferenced after edits) are swept periodically.
### 9.5 Thumbnails
- On upload, generate a single first-page thumbnail (PyMuPDF, ~300px wide) to `thumbnails/`. Cheap. Regenerate lazily if missing. Used by home-page cards.
### 9.6 Trash / restore
- Delete sets `documents.deleted_at`; the document leaves the active library and appears in **Trash**.
- Restore clears `deleted_at`.
- A periodic sweep permanently purges documents whose `deleted_at` is older than the retention window (default 30 days), deleting their PDF, thumbnail, assets, annotation state, and versions. Purge is also triggerable manually via "empty trash".
- All list queries for the active library filter `deleted_at IS NULL`; the trash view filters `deleted_at IS NOT NULL`.
---
## 10. Full API Specification
Base path `/api/v1`. All responses JSON unless noted. All endpoints except `auth/*` and `health` require a valid session cookie; unauthenticated requests get `401`. Errors use a consistent envelope: `{ "error": { "code": str, "message": str } }`.
### 10.1 Auth
| Method | Path | Body | Returns | Notes |
|---|---|---|---|---|
| GET | `/auth/status` | — | `{ authenticated: bool, setupRequired: bool }` | `setupRequired` true if no password set |
| POST | `/auth/setup` | `{ password }` | `204` | First-run only; 409 if already set |
| POST | `/auth/login` | `{ password }` | `204` + sets cookie | Rate-limited (see §11) |
| POST | `/auth/logout` | — | `204` | Clears cookie |
| PUT | `/auth/password` | `{ currentPassword, newPassword }` | `204` | 403 on mismatch |
### 10.2 Documents
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| GET | `/documents` | `?sort=recent\|title&q=&deleted=false&limit=&offset=` | `{ items: DocumentMeta[], total }` |
| POST | `/documents` | multipart `file` | `DocumentMeta` (201) |
| GET | `/documents/{id}` | — | `DocumentMeta` |
| PATCH | `/documents/{id}` | `{ title? }` | `DocumentMeta` |
| DELETE | `/documents/{id}` | — | `204` (soft delete → trash) |
| POST | `/documents/bulk-delete` | `{ ids: string[] }` | `{ deleted: number }` (soft) |
| POST | `/documents/{id}/restore` | — | `DocumentMeta` (un-trash) |
| POST | `/documents/bulk-restore` | `{ ids: string[] }` | `{ restored: number }` |
| DELETE | `/documents/{id}?permanent=true` | — | `204` (hard delete from trash) |
| POST | `/documents/trash/empty` | — | `{ purged: number }` |
| GET | `/documents/{id}/file` | — | `application/pdf` (original bytes; PDF.js fetches this) |
| GET | `/documents/{id}/thumbnail` | — | `image/png` |
`?deleted=false` (default) lists the active library; `?deleted=true` lists trash. Hard delete requires the explicit `permanent=true` flag so it can never happen by accident from the normal delete path.
`DocumentMeta`:
```jsonc
{ "id", "title", "originalFilename", "sizeBytes", "pageCount",
"createdAt", "updatedAt", "thumbnailUrl" }
```
### 10.3 Annotations (working state)
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/documents/{id}/annotations` | — | `{ data: Annotation[], updatedAt }` |
| PUT | `/documents/{id}/annotations` | `{ data: Annotation[], baseUpdatedAt? }` | `{ updatedAt }` |
`baseUpdatedAt` (optional) lets the server warn on stale-tab overwrite (returns `409` with current state if provided and mismatched; client may force with a retry omitting it).
### 10.4 Assets
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/documents/{id}/assets` | multipart `file` | `{ ref, url }` |
| GET | `/documents/{id}/assets/{ref}` | — | binary (image/png etc.) |
### 10.5 Versions
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/documents/{id}/versions` | — | `{ items: VersionMeta[] }` |
| POST | `/documents/{id}/versions` | `{ label? }` | `VersionMeta` (201) |
| GET | `/documents/{id}/versions/{vid}` | — | `{ data: Annotation[], meta }` |
| POST | `/documents/{id}/versions/{vid}/restore` | — | `{ updatedAt }` |
| DELETE | `/documents/{id}/versions/{vid}` | — | `204` |
`VersionMeta`: `{ id, label, kind, createdAt, annotationCount }`.
### 10.6 Export
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/documents/{id}/export` | `{ versionId?, flatten: true }` | `application/pdf` (synchronous; single-user scale) |
If export ever becomes slow for huge files, migrate to a job pattern (`202` + `GET /jobs/{id}`). The endpoint shape is designed so this is additive, not breaking.
### 10.7 Health
| Method | Path | Returns |
|---|---|---|
| GET | `/health` | `{ status: "ok", version }` |
### 10.8 OpenAPI is generated, not hand-maintained
FastAPI emits `/api/v1/openapi.json`. The frontend's typed API client and `types/` are generated from it (e.g. `openapi-typescript`). **Agents must regenerate types after any schema change** rather than hand-editing them. This keeps frontend and backend in lockstep.
---
## 11. Authentication & Security
- **Single password, no username.** Stored as Argon2id hash in `settings.password_hash`.
- **First-run setup:** if `setupRequired`, the UI shows a "set your password" screen before anything else. `/auth/setup` works only while no hash exists.
- **Session:** on login, issue a signed token (itsdangerous or JWT) in a cookie that is `httpOnly`, `Secure`, `SameSite=Lax`, with a sane expiry (e.g., 7 days, sliding). No token in localStorage (XSS-safe).
- **Login rate limiting:** exponential backoff / lockout after N failed attempts per IP (e.g., 5 attempts → 15-min lockout). In-memory counter is fine for single-user; document it.
- **CSRF:** because auth is cookie-based, protect state-changing routes. With `SameSite=Lax` plus a custom `X-Requested-With` header required on mutating requests (the SPA always sends it; cross-site form posts can't), CSRF is adequately mitigated for single-user. Document the reasoning.
- **Upload validation:** verify magic bytes (`%PDF-`), enforce a max size (config), and open with PyMuPDF in a guarded try/except to reject malformed/malicious files before storing. Never trust the client-supplied content-type or filename; sanitize filenames.
- **Path safety:** all file paths derive from server-generated UUIDs, never from user input. No user string ever becomes a filesystem path.
- **Secrets:** `SECRET_KEY`, cookie settings, max upload size, etc. come from environment variables (`.env`), never hardcoded.
- **Security headers** (set in nginx): `Content-Security-Policy` (restrict to self; allow the wasm/worker for PDF.js), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`.
- **TLS** is terminated at the user's outer reverse proxy. **In production, all traffic — LAN included — goes through that HTTPS proxy**, so `COOKIE_SECURE=true` and the browser always sees HTTPS; the `Secure` cookie flag is honored. **Direct HTTP access to the published port is only for local development/testing**, where `COOKIE_SECURE=false` is set so the session cookie works over plain HTTP. This single env var is the only difference between the two modes; document both in `.env.example`.
---
## 12. Frontend Architecture
### 12.1 Pages
- **LoginPage** — password entry (and first-run setup variant).
- **HomePage** — three zones in a clean light layout:
- **Recently edited** — cards (thumbnail, title, edited-time), sorted by `updatedAt`.
- **Upload** — prominent button **and** full-page drag-and-drop dropzone with progress.
- **Library tab** — grid/list of all PDFs with multi-select, search, and delete (single + bulk). Selection mode reveals a delete action bar.
- **Trash view** — soft-deleted documents with **restore** (single + bulk) and **permanent delete** / **empty trash**. Shows days remaining before auto-purge.
- **EditorPage** — the canvas workspace.
### 12.2 Editor composition
- **PageStack** — renders each PDF page via PDF.js into a `<canvas>`, lazily (virtualized) for large docs.
- **AnnotationLayer** — a Fabric.js canvas overlaid per page, sized to the rendered page, fed transforms from `lib/coords.ts`.
- **Toolbar** — tool selection (select, text, draw, signature, image, highlight, shape), color/size controls, zoom, undo/redo, save-status indicator, export.
- **Tool modules** (`editor/tools/*`) — each tool implements a small interface: `onActivate`, `onPointerDown/Move/Up`, `createAnnotation`, `renderToFabric`. Registered in a frontend tool registry mirroring the backend handler registry.
- **VersionsPanel** — list, label, restore, delete; opens as a side drawer.
### 12.3 Editor state (Zustand)
- `document`, `annotations` (working set), `selection`, `activeTool`, `zoom`, `saveStatus`.
- **Undo/redo:** a command-stack history of annotation-set mutations (add/modify/delete/transform). Each user action pushes an inverse command. History is per-document and in-memory (lost on reload — acceptable; persisted versions cover durable history). Keep the stack bounded (e.g., 100 steps).
- Autosave subscribes to annotation-set changes, debounced.
### 12.5 Signature capture
Triggered by the signature tool, a modal offers two tabs (Sejda-style):
- **Draw** — a freehand canvas (smoothed strokes) where the user signs with mouse/trackpad/touch/stylus. On confirm, the drawn signature is rasterized to a transparent PNG, uploaded via the assets endpoint, and placed as a `signature` annotation with `mode: "draw"` referencing the asset.
- **Type** — a text input plus a **live preview that renders the typed text simultaneously in several bundled signature/script fonts** (the same faces available server-side for export). The user picks a font; on confirm a `signature` annotation with `mode: "type"` is created carrying the text, chosen `fontFamily`, and color.
Once placed, a signature behaves like any other annotation — drag, resize, rotate, delete, undo/redo. Recently used signatures may be offered for quick re-insertion (future enhancement; not required for v1). Because typed signatures are stored as text + font (not flattened to an image until export), they stay crisp at any zoom and re-render identically on the server.
### 12.6 UI / visual design direction
Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrained palette (one accent color), soft shadows, rounded corners, clear iconography. Tailwind tokens defined once in `tailwind.config.ts`. The editor chrome stays out of the way; the page is the focus. Mobile/touch: the canvas supports pinch-zoom and touch drag; toolbar collapses into an overflow menu on narrow viewports. Define a small design-token set (spacing scale, radius, accent, neutrals) up front so all three agents produce visually consistent components.
---
## 13. Docker Compose & Deployment
```yaml
# docker-compose.yml (illustrative; agents finalize)
services:
backend:
build: ./backend
environment:
- SECRET_KEY=${SECRET_KEY}
- MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
- TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
- AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
- COOKIE_SECURE=${COOKIE_SECURE:-false} # false for direct-HTTP dev; set true in production (behind the HTTPS proxy)
volumes:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
expose:
- "8000"
restart: unless-stopped
frontend:
build: ./frontend
depends_on: [backend]
ports:
- "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this
restart: unless-stopped
volumes:
pdf_storage:
thumbnails:
db:
```
- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `backend:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated.
- A 200 MB ceiling comfortably covers 100200 page documents with embedded images; PDF.js page virtualization (§12.2) keeps the editor responsive on documents that large.
- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs).
- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image.
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups.
---
## 14. Extensibility / Future Features (design for, don't build)
| Future feature | How the design already accommodates it |
|---|---|
| New annotation types (stamp, redaction, checkbox) | Annotation registry + opaque server storage; no schema/API change |
| OCR / searchable text | Add as an "annotation source" that emits `text` annotations; export already handles text |
| Form filling (AcroForms) | Add a `formfield` annotation type + PyMuPDF widget handler; API unchanged |
| Async export for huge files | Export endpoint shape allows `202 + job` migration additively |
| Multi-user | Add a `users` table + per-row ownership; auth already cookie/session based |
| Page operations (merge/split/rotate/reorder) | New document-level endpoints; PyMuPDF supports natively, no new programs |
| Real-time collaboration | Autosave is full-state today; swap to CRDT/WebSocket later without changing storage canon |
**Rule:** never hardcode the v1 tool list anywhere that would require editing in multiple places to extend. Tools and export handlers are discovered from their registries.
---
## 15. Rules for AI Agents
Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the whole file) into every working session.
1. **Read `ARCHITECTURE.md` (this file) fully before writing code.** Do not infer architecture from existing partial code.
2. **Conform to the API contract.** Never invent endpoint shapes, field names, or status codes. If the contract is insufficient, propose a change to this document first; do not improvise divergent shapes.
3. **One coordinate-transform module.** All space conversions go through `lib/coords.ts`. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8).
4. **Store coordinates only in canonical space.** Never persist screen pixels.
5. **Types are generated, not hand-written.** After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with `shared/annotation-schema.json`.
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the existing two images are fine. If a task seems to need an external dependency, stop and flag it; do not add one.
7. **Extend via registries.** New annotation types/tools register themselves; never scatter `switch(type)` logic across the codebase. Unknown types must round-trip without crashing.
8. **Security code is review-gated.** Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material.
9. **Tests are required for the risk areas:** the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass.
10. **Small, single-purpose changes.** One feature or fix per change set. Conventional-commit messages. Don't refactor unrelated code in a feature change.
11. **Frontend is TypeScript strict; backend is fully type-hinted.** No `any` on the frontend without a written justification. CI runs lint + typecheck + tests and must pass.
12. **Respect the rendering split:** PDF.js renders for display; PyMuPDF writes for export. Don't introduce a second display renderer or a second writer.
13. **Idempotent, full-state autosave.** Don't switch to diff-based saving without updating this document and the version model.
14. **When uncertain, ask the plan, not the vibe.** If two reasonable implementations exist, pick the one this document specifies; if it doesn't specify, propose an amendment here before coding.
---
## 16. Implementation Phases
Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early.
**Phase 0 — Scaffolding**
Monorepo, both Dockerfiles, compose, `.env.example`, CI (lint/typecheck/test), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds.
**Phase 1 — Auth & Library core**
First-run setup, login/logout, session cookie, rate limiting. Upload (button + drag-drop), document list, thumbnails, soft delete + **trash/restore** (single + bulk, empty trash, retention purge sweep), home page (recent + library + trash). *Done when:* a user can log in, upload, see, delete, and restore PDFs.
**Phase 2 — Coordinate module & rendering (CRITICAL)**
`lib/coords.ts` with full unit tests (round-trip across zoom/rotation/DPR). PDF.js page rendering in the editor. The cross-engine verification gate (§8) against PyMuPDF. *Done when:* the verification gate passes. No annotation tools before this.
**Phase 3 — Annotation engine & text tool**
Annotation data model end-to-end, Fabric.js overlay, the `text` tool (add/move/resize/edit), working-state GET/PUT, debounced autosave, save-status UI. *Done when:* a text box can be placed, moved, autosaved, reloaded, and round-trips through canonical space correctly.
**Phase 4 — Remaining tools**
draw, **signature (draw + type-to-signature with live multi-font preview)**, image (+ assets endpoint), highlight, shape — each via the tool/handler registries. *Done when:* all v1 tools place and persist.
**Phase 5 — Versioning & undo/redo**
Manual + auto checkpoints (on close and before destructive ops), versions panel, restore, retention prune; in-memory undo/redo command stack. *Done when:* history can be checkpointed, browsed, and restored; undo/redo works within a session.
**Phase 6 — Export**
PyMuPDF flatten via the handler registry, bundled fonts, export of working state or a chosen version. *Done when:* exported PDFs match the on-screen editor pixel-for-pixel across all tools, rotations, and zoom levels.
**Phase 7 — Polish**
Sejda-grade light UI pass, mobile/touch refinement, error states, empty states, large-document virtualization, accessibility basics, backup documentation.
---
## 17. Resolved Decisions (formerly open questions)
All four open questions are now settled:
- **Upload ceiling:** 200 MB (`MAX_UPLOAD_MB=200`), comfortably covering 100200 page documents with embedded images. No separate page-count ceiling; PDF.js virtualization handles large docs. The same limit must be raised in both the app's nginx and the user's outer reverse proxy.
- **Versioning purpose & policy:** the safety net against accidental loss. A whole-document accidental delete is recovered via **trash/restore** (30-day retention). Accidental annotation loss inside a document is recovered via **version checkpoints** — taken manually anytime, and automatically on document close and before destructive operations (clear-all, restore). Manual versions kept indefinitely; auto versions retained 30 days.
- **Networking:** production routes **all** traffic (LAN included) through the HTTPS reverse proxy with `COOKIE_SECURE=true`. Direct HTTP to the published port is **dev/testing only**, with `COOKIE_SECURE=false`.
- **Signature capture:** two modes — **draw on canvas**, and **type-to-signature** with multiple bundled signature fonts and a Sejda-style live preview rendering the typed text in each font as you type. Both stored as `signature` annotations (`mode: "draw"` | `mode: "type"`); the same fonts are bundled server-side so export matches the preview exactly.
---
*End of plan v1.1. Amendments are made to this document first, then to code.*