Ship file viewer, editor improvements, and private package releases

This commit is contained in:
Clintchiz committed 2026-09-09 11:20:11 +05:30
1 parent 645914955c
commit a4a31032b9
158 files changed
+10523 -137

No files matched your search

+2 -2
View File
@@ -948,7 +948,7 @@ security: {
```
Defaults include a strict CSP (`default-src 'self'`, `script-src 'self'`, `style-src 'self'
'unsafe-inline'`, `frame-ancestors 'none'`, …), `X-Frame-Options: DENY`, restrictive
'unsafe-inline'`, `frame-ancestors 'self'`, …), `X-Frame-Options: SAMEORIGIN`, restrictive
Permissions‑Policy, COOP `same-origin`, and Referrer‑Policy. Set any directive/section to `false`
to remove it, or `security.headers = false` to disable all framework headers. WebSocket upgrades are
guarded against cross‑site hijacking (same‑origin + configured CORS origins + non‑browser clients).
@@ -1342,7 +1342,7 @@ export default config;
**CSP defaults:** `default-src 'self'`; `script-src 'self'`; `style-src 'self' 'unsafe-inline'`;
`img-src 'self' data: blob:`; `font-src 'self' data:`; `connect-src 'self' ws: wss:`;
`object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'none'`; `form-action 'self'`. Set a
`object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'self'`; `form-action 'self'`. Set a
directive to `false`/`null` to remove it.
## 33. Directive & attribute cheat sheet
+332
View File
@@ -0,0 +1,332 @@
# Universal file viewer design (`@wrnexus/viewer`)
Date: 2026-09-08
Status: implemented — build order steps 1-5 complete.
Follow-ups: [2026-09-09-file-viewer-follow-ups.md](2026-09-09-file-viewer-follow-ups.md)
Depends on: `@wrnexus/ui` (tokens, `.wrn` components), `@wrnexus/plugin` (`routeEntries`),
`@wrnexus/cache`, `@wrnexus/block-editor` (read-only CodeMirror), `@wrnexus/uploader` (optional)
## Problem
Every app in the workspace that stores a file eventually has to show it. Today each one
solves this alone: an `<img>` here, an `<iframe src=…>` there, a download link for
everything else. There is no shared answer for "the user uploaded a `.docx`, show it to
them", no consistent chrome (zoom, paging, download, fullscreen), and no consistent theming —
a preview pane looks nothing like the app around it.
`@wrnexus/uploader` already owns getting bytes _in_ and serving them back out, including the
extension↔MIME table in `src/mime.ts`. `@wrnexus/ui` already owns the token system that makes
components follow an app's accent and dark mode. `@wrnexus/block-editor` already wraps
CodeMirror read-only in `src/source-editor.ts`. What is missing is the layer that looks at an
arbitrary file and decides how to render it.
## Scope
One package that any app drops in as a single component:
```wrn
<Viewer src={file.url} name={file.name} />
```
…and gets the best available rendering of that file, themed to the app, with a graceful
fallback when the format cannot be rendered at all.
It is **not** an editor. Everything is read-only; editing stays with `@wrnexus/block-editor`
and `WysiwygEditor.wrn`. It is also not a file _manager_ — no listing, no tree, no rename.
It renders one file at a time (with an optional filmstrip for multi-file callers).
## Approach
Three tiers, decided by what the platform can actually do, not by what we would like:
| Tier | Rendered by | Cost |
| ---- | ------------------------------ | ------------------------------------ |
| 1 | The browser, natively | Zero new dependencies |
| 2 | A JS parser in the client | Optional peer dep, lazily imported |
| 3 | A server-side binary converter | Opt-in plugin + a binary on the host |
The tier boundary is a hard fact about the platform: no amount of JavaScript renders a `.psd`
faithfully, and no server round-trip is needed to show a `.png`. Pretending otherwise is how
viewer libraries end up shipping 8 MB to display a JPEG.
Everything degrades down a fixed ladder — **full render → thumbnail → metadata card →
download card** — so an unknown extension, a missing optional dep, and an absent converter
binary all land somewhere useful instead of erroring.
Rejected alternatives:
- **One monolithic `Viewer.wrn` with a big `if/else`.** Every format's dependency ends up in
every app's bundle, and apps cannot add their own formats.
- **Server-render everything to PDF/PNG.** Uniform output, but it makes a hard host-binary
dependency mandatory for viewing a text file, loses text selection and copy, and puts a
conversion hop in front of the 60% of files the browser renders for free.
- **Wrap an existing all-in-one viewer library.** They bundle their own design system, fight
our tokens, and none of them cover Tier 3 anyway.
## Module layout
```
@wrnexus/viewer
src/
kinds.ts ViewerKind union; ext/MIME -> kind table (extends uploader's)
resolve.ts ViewerSource -> ViewerPlan (kind, renderer, fallback chain)
sniff.ts magic-byte detection; extensions are never trusted alone
registry.ts renderer registry; register/override per kind or per ext
parse/ csv, json, xml, sql, archive listing, sanitize-svg
convert/ ViewerConverter contract + the shipped local-binary driver
plugin.ts viewerPlugin(): componentDirs + /_viewer/* routeEntries
uploader.ts separate export: storage key -> signed URL + metadata
index.ts
components/
Viewer.wrn the one component apps use
ViewerToolbar.wrn zoom, page, rotate, download, fullscreen
ViewerShell.wrn chrome, loading, error, and fallback states
ImageViewer.wrn VectorViewer.wrn PdfViewer.wrn CodeViewer.wrn
SheetViewer.wrn DocViewer.wrn MediaViewer.wrn ArchiveViewer.wrn
DownloadCard.wrn
viewer.css consumes the same CSS variables as ui.css
```
## Core contract
```ts
export type ViewerKind =
| "image"
| "vector"
| "pdf"
| "code"
| "text"
| "markup"
| "sheet"
| "doc"
| "audio"
| "video"
| "archive"
| "binary";
export interface ViewerSource {
src?: string; // URL, blob:, or data:
blob?: Blob | File; // client-side preview before upload
key?: string; // @wrnexus/uploader storage key
name: string;
mime?: string; // sniffed when absent
size?: number;
}
export interface Renderer {
kind: ViewerKind;
exts: string[];
tier: 1 | 2 | 3;
component: string; // .wrn component name
load?: () => Promise<unknown>; // lazy optional dep; missing -> degrade
maxInlineBytes?: number;
}
export interface ViewerPlan {
kind: ViewerKind;
renderer: Renderer;
fallback: Renderer[]; // the degradation ladder, pre-computed
needsConvert: boolean;
sandboxed: boolean;
}
export function resolve(source: ViewerSource): Promise<ViewerPlan>;
export function registerRenderer(renderer: Renderer): void;
```
`resolve()` runs ext → declared MIME → magic-byte sniff, then picks the highest-tier renderer
whose dependency is actually available. Apps override anything:
```ts
registerRenderer({ kind: "binary", exts: [".dwg"], tier: 2, component: "CadViewer" });
```
## Format matrix
**Tier 1 — browser-native, zero dependencies.**
| Group | Extensions | Renderer |
| --------- | -------------------------------------------------------- | ------------------------------ |
| Images | `.jpg .jpeg .png .gif .webp .bmp .ico .avif` | `ImageViewer` (zoom/pan) |
| Video | `.mp4 .webm .mov` | `MediaViewer` (`<video>`) |
| Audio | `.mp3 .wav .aac .flac .ogg` | `MediaViewer` + waveform bar |
| Documents | `.pdf` | `PdfViewer` (native `<embed>`) |
| Text | `.txt` | `CodeViewer` (plain mode) |
| Code | `.js .py .java .cpp .cs .css .html .htm .json .xml .sql` | `CodeViewer` (CodeMirror) |
`.mov` and `.flac` are codec-dependent in practice; when playback fails the ladder drops to
the Tier 3 poster-frame/transcode path, then to the download card.
**Tier 2 — pure JS in the client, lazily loaded.**
| Extensions | Parser | Notes |
| ------------------ | -------------------- | ------------------------------------------- |
| `.csv` | in-package | Virtualized table; handles quoted/multiline |
| `.json` | in-package | Collapsible tree + raw toggle |
| `.xml` | `DOMParser` | Tree view + raw toggle |
| `.svg` | in-package sanitizer | Sandboxed; see Security |
| `.xls .xlsx` | SheetJS | Sheet tabs, formulas shown as values |
| `.docx` | mammoth | Semantic HTML, **not** faithful layout |
| `.zip` | fflate | Entry listing, drill-in re-enters `resolve` |
| `.pdf` (rich mode) | pdf.js | Opt-in; page API, text layer, theming |
All declared as optional `peerDependencies` and dynamically imported on first use. An app that
never views a spreadsheet neither installs nor ships SheetJS.
**Tier 3 — requires a server-side converter.**
| Extensions | Converted to | Binary |
| ------------------ | ------------------ | ---------- |
| `.doc .odt .rtf` | PDF | `soffice` |
| `.docx` (fidelity) | PDF | `soffice` |
| `.ppt .pptx` | PDF | `soffice` |
| `.psd .ai .tiff` | PNG | `magick` |
| `.avi .mkv` | poster PNG, or MP4 | `ffmpeg` |
| `.rar .7z` | entry listing | `7z` |
| `.tar .gz` | entry listing | in-package |
## Converter contract
```ts
export interface ViewerConverter {
name: string;
supports(kind: ViewerKind, ext: string): boolean;
toPreview(
input: ReadableStream,
meta: ConvertMeta,
): Promise<{ mime: string; stream: ReadableStream }>;
toThumb?(input: ReadableStream, meta: ConvertMeta): Promise<Uint8Array>;
toText?(input: ReadableStream, meta: ConvertMeta): Promise<string>;
}
```
`viewerPlugin()` contributes three `routeEntries`:
- `GET /_viewer/preview` — the renderable form (PDF or PNG)
- `GET /_viewer/thumb` — a small poster image, for grids and filmstrips
- `GET /_viewer/text` — extracted plain text, for search and indexing
Output is content-addressed by input hash + converter version and cached through
`@wrnexus/cache`, so a 40 MB `.doc` converts once. The shipped `localBinaryConverter` probes
for `soffice`/`magick`/`ffmpeg`/`7z` once at boot; **an absent binary is not an error** — the
format simply falls to the download card. Apps that would rather call Gotenberg or an internal
service implement `ViewerConverter` themselves and pass it to the plugin.
The converter is opt-in. `viewerPlugin()` with no options mounts no routes and shells out to
nothing.
## Security
Uploaded files are hostile input, and this package's whole job is to open them.
- **Never trust the extension.** `sniff.ts` reads magic bytes before choosing a renderer, so a
`.png` that is really an HTML document does not reach an HTML path.
- **Sandbox all markup.** `.html`, `.htm`, and `.svg` render inside an iframe with `sandbox`
(no `allow-scripts`, no `allow-same-origin`) under a strict CSP. SVG is _additionally_
stripped of `<script>`, `<foreignObject>`, external references, and every `on*` attribute —
sandbox and sanitizer, not either alone. Nothing from a file ever reaches `innerHTML`.
- **Cap what is fetched.** `maxInlineBytes` per renderer, checked against a `HEAD` before the
body is pulled; over the cap the ladder drops straight to the download card rather than
pulling a 400 MB `.mkv` into a tab.
- **Contain the converters.** `soffice`, `magick`, and `ffmpeg` are large C/C++ parsers with a
long CVE history. Every invocation gets a hard wall-clock timeout, memory and output-size
caps, no network, and a per-job temp directory as its only writable path. A converter that
exceeds any limit is killed and reported as an unrenderable file.
- **Signed, short-lived URLs.** The uploader integration mints per-view URLs; converted output
inherits the same access check as its input, never a public cache path.
## Theming
`viewer.css` defines no colors of its own. It consumes the same CSS variables `ui.css` does,
so accent, radius, spacing, and dark mode follow the host app with no configuration —
including the CodeMirror theme, which is derived from the tokens rather than shipping a
hard-coded editor palette. `<Viewer theme="auto" />` follows the app; `"light"` / `"dark"`
pin it for cases like a print preview.
## Uploader integration
Core is source-agnostic: `src` or `blob` is enough, and the package has no hard dependency on
`@wrnexus/uploader`. The `key` path lives behind a separate export (`@wrnexus/viewer/uploader`)
that resolves a storage key to a signed URL and reads MIME and size from the upload record,
so uploader-backed apps write `<Viewer key={f.key} />` and everyone else is unaffected. The
ext↔MIME table extends `uploader/src/mime.ts` rather than forking it.
## Testing
- `resolve()` table tests over every extension in the matrix, including conflicting
extension/MIME/magic-byte combinations.
- Sanitizer tests: a corpus of hostile SVG and HTML, asserting no script survives.
- Degradation tests: optional dep missing, converter binary missing, file over the byte cap —
each must land on the expected rung of the ladder, never throw.
- Converter tests run only when the binaries are present, and are skipped otherwise, so CI
without LibreOffice stays green.
- A `.wrn` render test per component against the example app.
The remaining presentation and verification follow-ups were completed on 2026-09-09; see
[the follow-up plan](2026-09-09-file-viewer-follow-ups.md). The checkpoint below records the original implementation.
## Build order
Implementation checkpoint (2026-09-08): steps 1, 2 and 4 are done; step 3 was skipped for now
and step 5 remains. `packages/viewer` has classification, signature sniffing, a renderer
registry, bounded source loading, native image/PDF/media previews, read-only CodeMirror, the
fallback ladder, plugin assets and lifecycle cleanup; the in-package Tier 2 parsers (delimited
tables with windowed rendering, lazy JSON and XML trees, the SVG sanitiser behind a sandboxed
frame, tar/gzip listings); and the Tier 3 converter contract with its opt-in routes, response
cache and contained local-binary driver. `examples/basic-app` has a `/file-viewer` demo
covering each group, with conversion enabled.
Five deviations from this document, all deliberate:
- Renderers are imperative browser adapters sharing one `ViewerMountContext`, not the
separate `.wrn` components listed under Module layout. `Viewer.wrn` is the only component.
`component` is a display label; the runtime does not switch on it, and a renderer with no
`mount` is never selected.
- Conversion routes are `/api/viewer/*`, not `/_viewer/*`: package API routes are served
under the `/api` prefix, as `@wrnexus/graphql` already does.
- Image, media and PDF renderers are `streaming`: for an http(s) source the element is
pointed at the original URL instead of a `blob:` copy. That lets the browser stream the
file, and it keeps the request under the app's own `default-src`/`media-src`/`frame-src`
policy — `blob:` URLs are blocked by the framework's default CSP. Local `File`/`Blob`
input still renders from an object URL served under its sniffed type.
- The PDF renderer uses an `iframe`, not `<object>`, because `object-src 'none'` is a common
default while frames fall back to `default-src`. Embedding a same-origin PDF still needs
the app to allow self-framing; the framework defaults (`frame-ancestors 'none'` plus
`X-Frame-Options: DENY`, `packages/core/src/headers.ts`) block an app from framing even its
own assets, so `examples/basic-app` opts into `frame-ancestors 'self'` / `SAMEORIGIN`.
- `.xlsx` and `.zip` prefer their Tier 2 parser over the converter, since neither needs a
server round-trip; `.docx` prefers the converter, because mammoth's HTML is not a faithful
page layout. `.psd`, `.tiff` and other design formats have signatures so they reach the
converter or the download card rather than being mistaken for text.
- pdf.js is served from its own published build rather than bundled — bundling its ESM entry
breaks the worker handshake — and each page render is bounded by a timeout, because pdf.js
rasterises from `requestAnimationFrame`, which never fires while a document is hidden.
A sixth deviation: there is no default access check for a private object. The plan said the
uploader integration "mints per-view URLs"; in practice a private store is refused unless the
app supplies both a signing secret and an `authorize` hook, because no default answer to "may
this viewer see this object" is safe.
See `packages/viewer/README.md` for current limits, the renderer extension API, the converter
contract, and the uploader options.
1. ~~**Core + Tier 1.**~~ Done. `kinds`, `sniff`, `resolve`, `registry`, the toolbar and
shell chrome, image, media, native PDF, `CodeViewer` over the existing read-only
CodeMirror, and the download card.
2. ~~**Tier 2 in-package parsers.**~~ Done. csv/tsv, json, xml, svg sanitiser, `tar`/`gz`
listing — still zero runtime dependencies.
3. ~~**Tier 2 optional deps.**~~ Done. SheetJS, mammoth, fflate and opt-in pdf.js, each an
optional peer dependency served as its own runtime asset and fetched on first use.
4. ~~**Tier 3.**~~ Done. `ViewerConverter`, `/api/viewer/{capabilities,preview,thumb,text}`,
a bounded response cache, `localBinaryConverter`, and containment of the child processes.
5. ~~**Uploader integration + docs**~~ Done. `@wrnexus/viewer/uploader`, `<Viewer key="…">`,
signed short-lived links for private stores, and `/file-viewer` in `examples/basic-app`
exercising every group including public and private storage keys.
## Out of scope
- Editing or annotation of any kind.
- File management (listing, rename, delete, move).
- OCR over scanned PDFs and images.
- Collaborative or presence features on a preview.
- `.exe`/`.dll` and other executables — metadata card only, never executed or introspected.
@@ -0,0 +1,142 @@
# File viewer follow-ups (`@wrnexus/viewer`)
Date: 2026-09-09
Status: implemented — accepted limits retained
Follows: [file viewer design](2026-09-08-file-viewer-design.md), whose build order is complete
Build order steps 1-5 are implemented and the package renders every group in the format
matrix. What remains falls into four kinds: defects found after the fact, work the design
described but that was never built, one verification gap, and limits that were accepted
deliberately and should stay accepted until something forces the issue.
Nothing here blocks using the package. The first section is the only part with a
correctness argument behind it.
## Implementation result (2026-09-09)
- Stable conversion identities and authorization before cache hits; source streams released on hits.
- Exported `firstOf` / `sameOriginSource`; demo uses the composite with signed uploads.
- Renderer and chrome `.wrn` presentation components integrated through preserved DOM hooks.
- Sanitized HTML preview by default, with Source toggle and strict sandbox/CSP.
- Multi-file filmstrip consuming thumbnails, plus on-demand audio waveform and seek slider.
- Extracted-text UI and bounded public helper consuming the text route.
- Explicit host base URI for conversion/optional-module helpers, with regression tests.
- Suppressed plugin factories are skipped before import/invocation.
- Same-origin framing is now the framework default, explicitly approved by the user;
explicit DENY still sets restrictive framing headers.
- pdf.js success observed in ordinary Chrome: a completed page canvas visibly painted the
sample document text. Waveform generation and filmstrip switching also verified there.
The per-process cache, streaming-driver byte-range limitation and host-binary DOCX fidelity
remain accepted as this plan specifies. No distributed-cache or uploader-driver redesign was
introduced. The findings below are the original rationale, retained for context.
## Defects worth fixing first
**A rotating token defeats the conversion cache.** `packages/viewer/src/convert/service.ts`
keys cached output on `src`, and for a private uploader object `src` is
`/api/viewer/file?token=…` — a link that rotates every `ttlMs` (5 minutes by default). Every
view after the link is re-minted is a cache miss, so exactly the files most expensive to
convert (a 40 MB `.docx` through LibreOffice) are the ones that never hit. The cache key must
identify the _object_, not the URL that happens to reach it: have `resolveSource` return an
optional stable identity (`store:key` for uploader sources, the URL for everything else) and
hash that instead. Until then, private conversions re-run on a timer.
**The example app can no longer convert its own static files.**
`examples/basic-app/wrnexus.config.ts` sets `resolveSource: uploadedConversionSource()`, which
replaces the same-origin default outright. Uploader objects convert; the page's own
`/viewer/sample.docx` cannot, because it has no signed link. On this machine that is invisible
— no LibreOffice, so the request stops at 415 — and it would surface only on a host with the
binaries installed. The example should use whichever composite lands below.
**There is no way to accept more than one kind of source.** `ConversionOptions.resolveSource`
is a single function, so an app that stores some files in an uploader store and serves others
statically has to hand-write the fan-out. Ship a composer — `firstOf(uploadedConversionSource(),
sameOriginSource())` — and export `sameOriginSource` so the default is nameable rather than
private to the module. Small surface, removes the trap the example fell into.
## Unbuilt from the design
**Renderer and chrome `.wrn` components.** The design's module layout lists `ViewerShell`,
`ViewerToolbar`, `ImageViewer`, `MediaViewer`, `PdfViewer`, `CodeViewer`, `SheetViewer`,
`DocViewer`, `ArchiveViewer` and `DownloadCard`; what exists is one `Viewer.wrn` plus
imperative browser adapters sharing a `ViewerMountContext`. The adapters work and the seam is
clean, so this is a presentation question, not a correctness one: components would let an app
restyle the chrome through the normal component-override path instead of registering a
renderer. Worth doing only if an app actually asks to restyle it.
**HTML still renders as inert source.** The design's security section describes `.html` in the
same sandboxed, CSP-restricted frame as SVG. The frame exists (`renderers/frame.ts`) and SVG
and `.docx` both use it; pointing `.html` at it is a small change. It was left out because
inert source is strictly safer and nobody had asked for a rendered preview. Doing it means
deciding what the default view is — preview with a source toggle, or the reverse.
**`/api/viewer/thumb` and `/api/viewer/text` are mounted but unused.** The routes work and the
converter implements both. Nothing calls them because the two features that would — a
multi-file filmstrip, and feeding extracted text to search — are not built. Either build a
consumer or drop the routes; a live endpoint with no caller is a maintenance cost and an
attack surface for nothing.
**Audio waveform and multi-file filmstrip.** Both are in the design, neither is built. The
filmstrip is the one with a dependency: it wants `thumb`, and it wants a story for what a
viewer showing _several_ files does to the toolbar and the disposal path.
## The verification gap
**pdf.js rasterisation has never been observed working.** The library and worker are served
verbatim, `getDocument()` resolves and reports the page count in a real browser, and every
step up to `page.render()` was verified. `page.render()` itself never completes in this
environment, because pdf.js drives rasterisation from `requestAnimationFrame` and the
preview pane reports `document.hidden === true` permanently, even when fronted. The renderer
now bounds each page with a timeout and degrades to the native frame rather than hanging —
that degradation _is_ verified — but the success path is unproven. It needs one run in an
ordinary visible browser: open `/file-viewer` after `registerRenderer(richPdfRenderer())` and
confirm a canvas paints. Until someone does that, treat rich PDF as untested.
## Outside the package
**Plugin discovery calls a suppressed manifest factory anyway.**
`packages/plugin/src/discovery.ts:353` decides whether to keep a manifest-built plugin _after_
constructing it, so a plugin configured explicitly in `wrnexus.config.ts` still has its
factory invoked a second time with no options. That silently cleared the viewer's conversion
config until `viewerPlugin` was changed to only ever arm, never disarm. Any plugin using the
config-global idiom will hit this. Already queued as its own task.
**`frame-ancestors 'none'` stops an app framing its own assets.**
`packages/core/src/headers.ts:119`, with `X-Frame-Options: DENY` alongside it. Embedding a
same-origin PDF — or anything else in a frame — requires every app to opt out, as
`examples/basic-app` now does. `'self'` would still block cross-origin clickjacking while
letting an app embed its own content. This is a framework-wide security default, so it is a
decision to take deliberately rather than a bug to fix in passing.
## Known limits, deliberately accepted
**The conversion cache is per-process.** A bounded `Map` with a TTL, so a multi-process deploy
converts once per process and a restart loses everything. Moving it to `@wrnexus/cache` with
the distributed invalidation layer is the obvious upgrade, and is only worth it once the key
is fixed — a shared cache keyed on a rotating token would be shared misses.
**Byte ranges are served only for buffered objects.** `uploads/service.ts:135` slices a
`Uint8Array` body for a `Range` request; a driver returning a stream answers the whole body
instead. Seeking a large private video therefore re-reads it. Fixing this properly means a
range-aware read on the driver contract, which belongs to `@wrnexus/uploader`, not here.
**Legacy `.doc` and `.ppt` remain converter-only.** The OOXML and OpenDocument families are
ZIP containers of XML and are now read in-package, and RTF is read with no dependency at all.
The pre-2007 OLE compound formats are a different problem — a binary record stream with no
public schema — and reading them in a browser is not worth the size or the risk. LibreOffice
handles them, and without it they keep the download card.
**`.docx` fidelity depends on a host binary.** Mammoth produces semantic HTML, not a page
layout, so the converter's PDF is preferred and mammoth is the fallback. An app with no
LibreOffice gets an approximation, and that is the honest ceiling of a pure-JS path.
**Three client helpers still read the global `document`.** `optional/load.ts:7` and
`convert/client.ts:13,36`. `resolveKey` was changed to take the host's `baseURI` when it turned
out to be untestable without a global; the others have the same latent problem and no test
covering them. Consistency work, not a live bug.
## Out of scope, still
Unchanged from the original design: editing or annotation, file management, OCR,
collaborative features, and executing anything from an uploaded file.