diff --git a/CLAUDE.md b/CLAUDE.md index 1c4cade6..52e9af40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,45 +220,6 @@ The workspace is a set of focused crates (one responsibility each). Key groups: `TC-*` cases, embedded fixtures, page-count/glyph-coverage canaries, SSIM primitives, and the `load_bench` open-latency benchmark). See `loki-acid/README.md`. -- **Server (collaboration & storage)** — spec: - [docs/adr/LOKI_WEB_SERVER_SPEC.md](docs/adr/LOKI_WEB_SERVER_SPEC.md) - (ADRs C012–C020). `loki-model` (server-side IDs, `EncryptionTier`, - RBAC `Role`/`Action`, EU-pinned `Residency`), `loki-crypto` (DEK envelope - encryption + crypto-agile `KeyWrap`: symmetric KEK for Tiers 0/1, X25519 - for Tier-2 zero-knowledge), `loki-server-audit` (hash-chained audit log), - `loki-server-store` (Postgres/SQLx + `object_store` ports, with in-memory - test impls; `doc_meta.snapshot_seq` is the move-forward-only compaction - guard), `loki-server-collab` (WebSocket relay, `FanOutBus`: - `PgNotifyBus`/`InMemoryBus`, and the ADR-C013 `Compactor` — a periodic - task folds each Tier-0/1 oplog backlog into a Loro snapshot and - truncates; Tier-2 documents are compacted by clients via - `PUT /v1/documents/{doc}/snapshot`), `loki-server-auth` (OIDC relying - party + RBAC; keys come from the IdP's JWKS endpoint with caching and - rotation-on-unknown-kid, or a static PEM), `loki-server-api` (REST - `/v1`, problem+json errors, `E2eeCapabilityDisabled` = the canonical - Tier-2 409), and the `loki-server` binary (env config with sovereignty - validation, graceful shutdown). Deliberate deferrals are marked in-code: - `TODO(kms)` (KEK from Vault/KMS instead of env), `TODO(headless-c025)` - (apalis export job queue), `TODO(ws-membership)` (workspace-scope roles + - listing join). -- **Headless (print & conversion)** — spec: - [docs/adr/LOKI_HEADLESS_SERVER_SPEC.md](docs/adr/LOKI_HEADLESS_SERVER_SPEC.md) - (ADRs C021–C028). `loki-convert` (ADR-C024 conversion matrix over the - existing import/export crates — DOCX/ODT ↔ each other and → EPUB/PDF; - XLSX ↔ ODS; PPTX/ODP/ODG gated behind the unbuilt ACID PPTX generator, - ratified decision §5.1; unsupported pairs are a typed - `ConversionUnsupported`), `loki-print` (ADR-C023 blocking IPP client: - Print-Job dispatch of rendered PDF with copies/duplex/media/colour - attributes, Get-Job-Attributes polling), and the `loki-headless` CLI - (`convert`/`render`/`print`/`formats`; print renders non-PDF inputs via - `loki-convert` first). The whole print path is CPU-only already - (Parley layout → pdf-writer), so no GPU is involved. Deferrals marked - in-code in `loki-headless/src/main.rs`: `TODO(headless-c025)` (apalis - worker + HTTP endpoint), `TODO(headless-c021)` (vello_cpu thumbnails), - `TODO(headless-c022)` (krilla migration for PDF/A-2b — `pdf-a2b` is a - typed `ProfileUnsupported` today), `TODO(headless-c023-discovery)` - (DNS-SD printer discovery), `TODO(headless-c027)` (fail-closed fonts), - `TODO(headless-c028)` (TEE attestation). The **Publish** ribbon tab in `loki-text` drives PDF/X + EPUB export and the Dublin Core metadata editor. diff --git a/Cargo.lock b/Cargo.lock index 5c7fa3c5..edfbe562 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,16 +116,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - [[package]] name = "ahash" version = "0.8.12" @@ -229,56 +219,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.60.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.60.2", -] - [[package]] name = "anyhow" version = "1.0.102" @@ -638,15 +584,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -748,73 +685,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "axum-macros", - "base64", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "base16" version = "0.2.1" @@ -827,12 +697,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.8.0" @@ -1181,30 +1045,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", - "zeroize", -] - [[package]] name = "chrono" version = "0.4.45" @@ -1244,17 +1084,6 @@ dependencies = [ "half", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", - "zeroize", -] - [[package]] name = "clap" version = "4.6.1" @@ -1262,7 +1091,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", - "clap_derive", ] [[package]] @@ -1271,22 +1099,8 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", "anstyle", "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.118", ] [[package]] @@ -1339,12 +1153,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "combine" version = "4.6.7" @@ -1364,12 +1172,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-serialize" version = "0.7.2" @@ -1521,21 +1323,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - [[package]] name = "crc32fast" version = "1.5.0" @@ -1605,15 +1392,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1633,7 +1411,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1676,32 +1453,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "darling" version = "0.20.11" @@ -1789,23 +1540,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1851,9 +1585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", - "subtle", ] [[package]] @@ -1983,7 +1715,7 @@ dependencies = [ "subsecond", "thiserror 2.0.18", "tracing", - "tungstenite 0.28.0", + "tungstenite", ] [[package]] @@ -2339,12 +2071,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - [[package]] name = "downcast-rs" version = "1.2.1" @@ -2429,9 +2155,6 @@ name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" -dependencies = [ - "serde", -] [[package]] name = "embedded-io" @@ -2490,29 +2213,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "enum-as-inner" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0359ee92f81184d7985519e474bda2a5738476334edd3746c9b1265c067afe70" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "enum-primitive-derive" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c" -dependencies = [ - "num-traits", - "quote", - "syn 2.0.118", -] - [[package]] name = "enum_dispatch" version = "0.3.13" @@ -2619,17 +2319,6 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - [[package]] name = "euclid" version = "0.22.14" @@ -2700,12 +2389,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2773,17 +2456,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "fnv" version = "1.0.7" @@ -2943,21 +2615,6 @@ dependencies = [ "new_debug_unreachable", ] -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -2974,17 +2631,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-intrusive" version = "0.5.0" @@ -3044,7 +2690,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -3273,25 +2918,6 @@ dependencies = [ "svg_fmt", ] -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.7.1" @@ -3373,15 +2999,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heapless" version = "0.7.17" @@ -3446,33 +3063,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "html-escape" version = "0.2.13" @@ -3532,18 +3122,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - [[package]] name = "hyper" version = "1.10.1" @@ -3554,11 +3132,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3576,11 +3152,10 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", + "webpki-roots", ] [[package]] @@ -3875,15 +3450,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "interpolate_name" version = "0.2.4" @@ -3920,30 +3486,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "ipp" -version = "6.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c50f52bbd6cf4cdaac254c696a8a479e3962b0f5858d3b659c7658ea728a94f" -dependencies = [ - "base64", - "bytes", - "enum-as-inner 0.7.0", - "enum-primitive-derive", - "http", - "log", - "num-traits", - "rustls-native-certs", - "thiserror 2.0.18", - "ureq", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.11.0" @@ -4087,21 +3629,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "keyboard-types" version = "0.7.0" @@ -4178,9 +3705,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] name = "leb128" @@ -4238,16 +3762,6 @@ dependencies = [ "redox_syscall 0.8.1", ] -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "pkg-config", - "vcpkg", -] - [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -4331,35 +3845,6 @@ dependencies = [ "sys-locale", ] -[[package]] -name = "loki-convert" -version = "0.1.0" -dependencies = [ - "loki-doc-model", - "loki-epub", - "loki-odf", - "loki-ooxml", - "loki-pdf", - "loki-sheet-model", - "thiserror 2.0.18", -] - -[[package]] -name = "loki-crypto" -version = "0.1.0" -dependencies = [ - "base64", - "chacha20poly1305", - "hkdf", - "rand_core 0.6.4", - "serde", - "serde_json", - "sha2", - "thiserror 2.0.18", - "x25519-dalek", - "zeroize", -] - [[package]] name = "loki-doc-model" version = "0.1.1" @@ -4423,18 +3908,6 @@ dependencies = [ "serde", ] -[[package]] -name = "loki-headless" -version = "0.1.0" -dependencies = [ - "clap", - "loki-convert", - "loki-doc-model", - "loki-ooxml", - "loki-print", - "thiserror 2.0.18", -] - [[package]] name = "loki-i18n" version = "0.1.0" @@ -4463,16 +3936,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "loki-model" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "thiserror 2.0.18", - "uuid", -] - [[package]] name = "loki-odf" version = "0.1.0" @@ -4601,14 +4064,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "loki-print" -version = "0.1.0" -dependencies = [ - "ipp", - "thiserror 2.0.18", -] - [[package]] name = "loki-render-cache" version = "0.1.0" @@ -4635,120 +4090,6 @@ dependencies = [ "wgpu", ] -[[package]] -name = "loki-server" -version = "0.1.0" -dependencies = [ - "axum", - "base64", - "jsonwebtoken", - "loki-crypto", - "loki-model", - "loki-server-api", - "loki-server-auth", - "loki-server-collab", - "loki-server-store", - "object_store", - "sqlx", - "thiserror 2.0.18", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "loki-server-api" -version = "0.1.0" -dependencies = [ - "async-trait", - "axum", - "base64", - "chrono", - "http-body-util", - "loki-crypto", - "loki-model", - "loki-server-audit", - "loki-server-auth", - "loki-server-collab", - "loki-server-store", - "object_store", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tower", - "tracing", - "uuid", -] - -[[package]] -name = "loki-server-audit" -version = "0.1.0" -dependencies = [ - "chrono", - "serde", - "sha2", - "thiserror 2.0.18", -] - -[[package]] -name = "loki-server-auth" -version = "0.1.0" -dependencies = [ - "async-trait", - "base64", - "jsonwebtoken", - "loki-model", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "loki-server-collab" -version = "0.1.0" -dependencies = [ - "async-trait", - "axum", - "base64", - "chrono", - "futures-util", - "loki-model", - "loki-server-store", - "loro", - "object_store", - "serde", - "serde_json", - "sqlx", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "loki-server-store" -version = "0.1.0" -dependencies = [ - "async-trait", - "chrono", - "loki-crypto", - "loki-model", - "loki-server-audit", - "object_store", - "serde", - "serde_json", - "sqlx", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "loki-sheet-model" version = "0.1.0" @@ -5175,12 +4516,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "maybe-rayon" version = "0.1.1" @@ -5191,16 +4526,6 @@ dependencies = [ "rayon", ] -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "md5" version = "0.7.0" @@ -5421,22 +4746,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -5446,12 +4755,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - [[package]] name = "num-derive" version = "0.4.2" @@ -5915,71 +5218,17 @@ dependencies = [ ] [[package]] -name = "object_store" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" -dependencies = [ - "async-trait", - "base64", - "bytes", - "chrono", - "form_urlencoded", - "futures", - "http", - "http-body-util", - "humantime", - "hyper", - "itertools 0.14.0", - "md-5", - "parking_lot", - "percent-encoding", - "quick-xml 0.38.4", - "rand 0.9.4", - "reqwest", - "ring", - "serde", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "walkdir", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "once_cell" -version = "1.21.4" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "oorandom" version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "option-ext" version = "0.2.0" @@ -6128,25 +5377,6 @@ dependencies = [ "ryu", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "peniko" version = "0.5.0" @@ -6304,27 +5534,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -6398,17 +5607,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -6448,12 +5646,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -6575,16 +5767,6 @@ dependencies = [ "serde", ] -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.39.4" @@ -6948,7 +6130,6 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", @@ -6961,7 +6142,6 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -6969,16 +6149,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", - "webpki-roots 1.0.8", + "webpki-roots", ] [[package]] @@ -7064,26 +6242,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rust-embed" version = "8.11.0" @@ -7171,7 +6329,6 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -7180,18 +6337,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -7252,15 +6397,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -7286,29 +6422,6 @@ dependencies = [ "tiny-skia", ] -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "selectors" version = "0.32.0" @@ -7430,17 +6543,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -7530,16 +6632,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "simd-adler32" version = "0.3.9" @@ -7571,18 +6663,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - [[package]] name = "simplecss" version = "0.2.2" @@ -7803,214 +6883,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap", - "log", - "memchr", - "once_cell", - "percent-encoding", - "rustls", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", - "webpki-roots 0.26.11", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.118", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck 0.5.0", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.118", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags 2.13.0", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.6", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags 2.13.0", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.6", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.18", - "tracing", - "url", - "uuid", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8057,17 +6929,6 @@ dependencies = [ "quote", ] -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -8470,36 +7331,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "time" -version = "0.3.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-skia" version = "0.11.4" @@ -8611,23 +7442,10 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -8638,42 +7456,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.29.0", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -8751,7 +7533,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -8790,7 +7571,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -8828,16 +7608,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -8848,15 +7618,12 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", - "serde", - "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", - "tracing-serde", ] [[package]] @@ -8902,22 +7669,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", -] - [[package]] name = "twox-hash" version = "2.1.2" @@ -9007,15 +7758,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-properties" version = "0.1.4" @@ -9052,50 +7794,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots 1.0.8", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -9154,31 +7858,18 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -9201,12 +7892,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "vello" version = "0.6.0" @@ -9351,12 +8036,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -9582,15 +8261,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.8", -] - [[package]] name = "webpki-roots" version = "1.0.8" @@ -9765,16 +8435,6 @@ dependencies = [ "wgpu", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - [[package]] name = "winapi" version = "0.3.9" @@ -9980,15 +8640,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -10040,21 +8691,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -10103,12 +8739,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -10127,12 +8757,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -10151,12 +8775,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -10187,12 +8805,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -10211,12 +8823,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -10235,12 +8841,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -10259,12 +8859,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -10391,18 +8985,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "serde", - "zeroize", -] - [[package]] name = "xcursor" version = "0.3.10" @@ -10724,20 +9306,6 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 94c40817..003d1836 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server","loki-convert","loki-print","loki-headless"] +members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell"] [workspace.dependencies] # Shared across appthere-ui and any other workspace member that needs Dioxus. @@ -20,45 +20,6 @@ loki-file-access = { git = "https://github.com/appthere/loki-file-access", branc loki-fonts = { path = "loki-fonts" } android-activity = "0.6" -# ── Server-side crates (LOKI_WEB_SERVER_SPEC.md, ADRs C012–C020) ───────────── -loki-model = { path = "loki-model" } -loki-crypto = { path = "loki-crypto" } -loki-server-audit = { path = "loki-server-audit" } -loki-server-store = { path = "loki-server-store" } -loki-server-collab = { path = "loki-server-collab" } -loki-server-auth = { path = "loki-server-auth" } -loki-server-api = { path = "loki-server-api" } -loki-convert = { path = "loki-convert" } -loki-print = { path = "loki-print" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -uuid = { version = "1", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde", "clock", "std"], default-features = false } -tokio = { version = "1", default-features = false } -axum = { version = "0.8", features = ["ws", "macros"] } -sqlx = { version = "0.8", default-features = false, features = [ - "runtime-tokio", - "tls-rustls", - "postgres", - "uuid", - "chrono", - "migrate", - "derive", - "macros", - "json", -] } -object_store = { version = "0.12", features = ["aws"] } -async-trait = "0.1" -tracing = "0.1" -chacha20poly1305 = "0.10" -x25519-dalek = { version = "2", features = ["static_secrets"] } -hkdf = "0.12" -sha2 = "0.10" -rand_core = { version = "0.6", features = ["getrandom"] } -zeroize = { version = "1", features = ["derive"] } -jsonwebtoken = "9" -base64 = "0.22" - [patch.crates-io] # PATCH: vendors dioxus-native-dom to avoid runtime unimplemented!() panics in # HtmlEventConverter (composition, touch, scroll, etc.) — remove when upstream diff --git a/docs/adr/LOKI_HEADLESS_SERVER_SPEC.md b/docs/adr/LOKI_HEADLESS_SERVER_SPEC.md deleted file mode 100644 index 089b8c8f..00000000 --- a/docs/adr/LOKI_HEADLESS_SERVER_SPEC.md +++ /dev/null @@ -1,308 +0,0 @@ - - -# AppThere Loki — Headless Server Spec (Print & Conversion) - -**Status:** Ratified (v1) — open decisions closed 2026-07-01 -**Series:** AppThere Cloud, ADRs C021–C028 -**Companion:** `LOKI_SERVER_COLLABORATION_SPEC.md` (C012–C020) -**Target edition:** Rust 2024 - ---- - -## 0. Scope - -A headless Loki deployment for office environments: batch/on-demand **printing** and **file -conversion** with no display, no GPU, and deterministic output. It runs as a worker that -consumes jobs from the collaboration server's queue **or** as a standalone CLI/HTTP service for -customers who only want conversion and printing without collaboration. - -### Goals - -- Render Loki documents to print-ready output with no GPU and no window server. -- Deterministic, conformance-validated fidelity (byte-stable where the format allows). -- Convert between all formats Loki already imports/exports, plus PDF. -- Dispatch to office printers over standard IPP. -- Horizontally scalable, stateless workers. -- Honour the confidentiality tiers from C014–C015. - -### Non-goals (v1) - -- Interactive preview (this is headless by definition). -- Server-side processing of Tier-2 (zero-knowledge) documents (impossible; see C026). -- OCR / scanning (out of scope). - -### Engineering standards - -Inherited verbatim from the collaboration spec (300-line ceiling, no `unwrap()`/`expect()` in -library code, `#![forbid(unsafe_code)]` at crate roots, `thiserror`, SPDX line 1, `fl!()` for -user-visible strings, audit-first / implement-second). - ---- - -## 1. Architecture - -``` - ┌──────────────────────────────────────────────────────────────┐ - │ Job source │ - │ • apalis queue (from loki-server) • CLI • HTTP endpoint │ - └───────────────┬────────────────────────────────────────────────┘ - │ Job { Render | Print | Convert | Thumbnail | Export } - ┌───────────▼────────────┐ - │ loki-headless (N≥1) │ stateless, GPU-free - │ ┌───────────────────┐ │ - │ │ loki layout+text │ │ (existing PaginatedLayout, Parley) - │ ├───────────────────┤ │ - │ │ vello_cpu render │ │ deterministic, no wgpu/GPU - │ ├───────────────────┤ │ - │ │ krilla → PDF │ │ PDF / PDF-A / PDF-X profiles - │ ├───────────────────┤ │ - │ │ import/export │ │ DOCX/ODT/XLSX/PPTX/ODS/ODP/ODG - │ └───────────────────┘ │ - └───────┬─────────────┬────┘ - │ │ - ┌─────────▼───┐ ┌─────▼──────────────┐ - │ IPP / CUPS │ │ object storage / │ - │ office print│ │ return artifact │ - └─────────────┘ └─────────────────────┘ -``` - -Crates: - -| Crate | Responsibility | -|---|---| -| `loki-headless` | Worker binary + CLI + optional Axum HTTP endpoint. | -| `loki-render-cpu` | vello_cpu render path; deterministic rasterisation. | -| `loki-pdf` | krilla-based PDF/PDF-A/PDF-X emission. | -| `loki-print` | IPP client; printer discovery; job dispatch. | -| `loki-convert` | Format matrix orchestration over existing import/export. | -| (reuses) `loki-text`, `loki-doc-model`, `loki-fonts`, `appthere-conformance` | - ---- - -## 2. ADRs - -### ADR-C021 — Headless rendering via `vello_cpu` (GPU-free, deterministic) - -**Context.** Office/server hardware frequently has no GPU, and print fidelity demands -determinism. Loki already chose `vello_cpu` for deterministic headless conformance rendering -(the `appthere-conformance` crate, ~141 cases via the promoted `loki-acid` harness). - -**Decision.** The headless render path uses `vello_cpu` exclusively — the **same** path the -conformance harness exercises. No wgpu, no surface, no GPU dependency. This means the print -output and the conformance-tested output are produced by identical code, so print fidelity is -covered by the existing test suite rather than a parallel, untested path. - -**Consequences.** Runs on any Linux box (CCX server, office NUC, container). Print regressions -are caught by the conformance harness. No display server required. - ---- - -### ADR-C022 — PDF emission via `krilla`; PDF/A and PDF/X profiles - -**Context.** The Cloud spec already chose `krilla` as the PDF backend. Office printing and -archival need conformance profiles, not just "a PDF". - -**Decision.** `loki-pdf` emits via krilla with selectable profiles: - -- **PDF 1.7** — default general output. -- **PDF/A-2b** — archival (records retention, GDPR-adjacent recordkeeping). -- **PDF/X-4** — print-production colour fidelity (ties into `appthere-color` ICC/CMYK). - -Colour management routes through the published `appthere-color` crate (moxcms, CMYK, ICC, soft -proofing) so print colour matches Loki's on-screen soft-proof. - -**Consequences.** One PDF engine serves screen, print, and archive. Archival and print-shop -outputs are first-class, not conversions-of-conversions. - ---- - -### ADR-C023 — Printing via IPP dispatch of rendered PDF - -**Context.** "Print documents in an office environment" means network printers, print queues, -and (often) CUPS. Rendering directly to a printer PDL per device is a fidelity and maintenance -trap. - -**Decision.** Render → PDF (§C022) → dispatch over **IPP** (`ipp`/IPP-everywhere), optionally -through a CUPS server. `loki-print` supports: - -- Printer discovery (IPP / DNS-SD) and explicit printer URIs. -- Job options: copies, duplex, media size, colour/mono, staple/finishing where advertised. -- Job status polling and completion reporting back to the audit log. - -Direct-to-printer PDL (PCL/PostScript) is a documented later option, not v1. - -**Consequences.** Works with essentially all modern office printers via one code path. The PDF -intermediate keeps fidelity identical to on-screen and archive output. - ---- - -### ADR-C024 — Conversion pipeline reuses existing import/export, conformance-gated - -**Context.** Loki already has full OOXML and ODF round-tripping (DOCX/ODT/XLSX/PPTX/ODS/ODP/ODG) -plus PDF export. Conversion is orchestration, not new format code. - -**Decision.** `loki-convert` exposes a conversion matrix built entirely on the existing -import/export crates: - -- Import source → in-memory `doc-model` → export target (or PDF via `loki-pdf`). -- Every supported pair is registered in a static capability table; unsupported pairs return a - typed `ConversionUnsupported` error rather than a lossy best-effort. -- Fidelity of each pair is asserted by the `appthere-conformance` harness, extending the ACID - test plan (`LOKI_ACID_TEST_PLAN.md`). The PPTX generator gap noted there (29 cases documented, - file unbuilt) is a prerequisite before PPTX conversion is declared supported. - -**Consequences.** No second, drifting implementation of format handling. Conversion quality is -exactly the round-trip quality already measured. New format support lands in one place. - ---- - -### ADR-C025 — Job model via `apalis`; idempotency, retries, dead-letter - -**Context.** The Cloud spec already chose `apalis` for job queuing (Postgres backend, so no -Redis dependency is forced). - -**Decision.** Job types: `Render`, `Print`, `Convert`, `Thumbnail`, `Export`. Each carries: - -- an **idempotency key** (duplicate submissions coalesce), -- a **document reference + version** (so a job renders a pinned snapshot, not a moving target), -- **retry policy** (bounded, exponential backoff) and a **dead-letter** queue for poison jobs. - -Workers are stateless and horizontally scalable; concurrency is bounded per worker to protect -memory (Loki's steady-state is ~750 MB shared via `Arc`/`PaginatedLayout`/ -`FontResources`; the worker reuses that sharing across jobs in-process). - -**Consequences.** Print/convert scale by adding workers. Failures are visible and recoverable, -not silent. Batch office workloads (e.g. nightly statement runs) are natural. - ---- - -### ADR-C026 — Headless workers operate only on decryptable documents (Tier 0/1) - -**Context.** Per collaboration ADR-C015, a Tier-2 (zero-knowledge) document is ciphertext to the -server; a server-side worker cannot render it. This is a hard boundary. - -**Decision.** - -- Server-side headless jobs are accepted **only for Tier 0 and Tier 1** documents. Under Tier 1 - the worker obtains the document DEK from the customer KMS/HSM **within the trust boundary** to - decrypt, render, then discard the key from memory. -- For **Tier 2**, the headless engine is still available but must run **client-side / in-boundary** - with a **caller-supplied key**: the same `loki-headless` binary runs as a local process the - client feeds the DEK to, so a Tier-2 user can still print/convert locally without the key ever - reaching the shared server. The server-side queue rejects Tier-2 jobs with - `E2eeCapabilityDisabled`. - -**Consequences.** The confidentiality guarantee is never quietly broken. Tier-2 users keep -print/convert capability, relocated to where the key legitimately lives. - ---- - -### ADR-C027 — Deterministic font resolution and substitution policy - -**Context.** Print fidelity collapses if the server substitutes fonts differently from the -author's machine. Loki already bundles Gelasio (metric-compatible with Georgia) as a -substitution anchor. - -**Decision.** - -- `loki-fonts` performs **deterministic resolution**: bundled fonts first (Gelasio et al.), then - a configured, ordered fallback set, then a **fail-closed** option for print jobs that must not - substitute silently (returns `FontUnavailable` instead of guessing). -- The active substitution map is recorded in job metadata and the audit log, so a print run is - reproducible and explainable. -- Metric-compatible substitutes (e.g. Gelasio→Georgia, and the standard Liberation/Carlito style - pairings) are the documented defaults for office interchange. - -**Consequences.** Same document → same pages → same output on any worker. Font drift becomes a -policy choice, not an accident. - ---- - -### ADR-C028 — Confidential-computing hardening for Tier-1 workers (SEV-SNP / TDX) - -**Context.** Under Tier 1, a headless worker holds document plaintext **and** the DEK in memory -while it renders. On a shared or provider-operated host, that in-use plaintext is exposed to a -compromised or curious hypervisor / host operator — the one gap that at-rest and in-transit -encryption do not close. C5:2026 introduces explicit confidential-computing criteria. - -**Decision (adopted).** Tier-1 headless workers run inside a hardware Trusted Execution -Environment — **AMD SEV-SNP** (or equivalently **Intel TDX**) — which encrypts VM memory and CPU -register state per-VM and attests the running image. Key release is **attestation-gated**: the -customer KMS/HSM unwraps the Tier-1 KEK only for a worker presenting a valid remote-attestation -report (via `snpguest`/`snphost`) proving genuine AMD hardware, a verified TCB/firmware level, -and the expected `loki-headless` measurement. A worker that cannot attest never receives a key -and therefore cannot decrypt. - -**Hetzner reality check.** Hetzner **Cloud** VMs do **not** support confidential computing — no -vTPM, no data-in-use encryption, no UEFI secure boot. SEV-SNP therefore requires one of: - -- a **Hetzner dedicated / bare-metal AMD EPYC server** (3rd-gen "Milan" or newer) with - self-managed host enablement (QEMU + OVMF; e.g. an Ubuntu 25.04-class host), **or** -- an EU sovereign cloud that exposes confidential VMs. - -Azure/GCP confidential VMs exist but are **non-sovereign** for EU purposes and are noted only as -non-default fallbacks. The sovereign path is Hetzner dedicated EPYC bare metal. - -**Consequences.** Closes the in-use exposure gap: even the infrastructure operator cannot read -Tier-1 plaintext while it is being processed — the confidential-computing control C5:2026 now -expects. It is an **optional deployment mode** — Tier-1 without a TEE still works (trusting the -host); Tier-1 with a TEE removes that trust. Non-attesting hosts are simply ineligible to -process Tier-1 jobs. - ---- - -## 3. Interfaces - -**CLI (standalone, no server required):** - -``` -loki-headless convert --in report.docx --out report.pdf --profile pdf-a2b -loki-headless convert --in sheet.xlsx --out sheet.ods -loki-headless print --in report.docx --printer ipp://officeprinter.local/ipp/print \ - --duplex --copies 3 --media A4 -loki-headless render --in doc.odt --out doc.pdf --profile pdf-x4 -``` - -**HTTP (optional, for in-house automation):** - -``` -POST /v1/convert { source, target_format, profile } -> artifact | 409 (Tier 2) -POST /v1/print { source, printer_uri, options } -> job id -GET /v1/jobs/{id} -> status -``` - -**Worker:** subscribes to the apalis queue from `loki-server`; same job types as HTTP. - -All errors typed; the canonical rejections are `ConversionUnsupported`, `FontUnavailable`, -and `E2eeCapabilityDisabled`. - ---- - -## 4. Deployment - -- **Alongside `loki-server`** (Hetzner OpenTofu module / Helm chart): a worker pool of CCX - (dedicated-vCPU) instances; no GPU nodes needed. Scale by replica count. -- **Confidential-computing pool (Tier-1 hardening, per ADR-C028):** a separate worker pool on - SEV-SNP/TDX nodes — Hetzner dedicated EPYC bare metal (Milan+) or an EU confidential cloud — - with attestation-gated key release. Tier-1 jobs needing in-use protection route here; all - other jobs run on the standard CCX pool. -- **Standalone office box:** single container or binary + a CUPS/IPP printer on the LAN; no - Postgres required for pure CLI conversion/print. This is the "office printing" deployment. -- **Air-gapped:** fully offline — bundled fonts, local ICC profiles, no network egress; suits - the strictest sovereignty and classified-adjacent environments. - -Deterministic, GPU-free, and self-contained means the same binary serves a Hetzner worker pool -and a single office NUC by the print room. - ---- - -## 5. Ratified decisions - -1. **PPTX conversion gating — RATIFIED.** PPTX conversion stays behind the unbuilt PPTX - generator from `LOKI_ACID_TEST_PLAN.md` until its 29 cases pass. -2. **Fail-closed fonts — RATIFIED.** Fail-closed font substitution is the default for `print` - jobs; best-effort for `convert`. -3. **CUPS dependency — RATIFIED.** Direct IPP is the default; CUPS is an optional backend. -4. **Confidential-computing workers — ADOPTED.** Tier-1 workers run in a SEV-SNP/TDX TEE with - attestation-gated key release (ADR-C028). Optional deployment mode; sovereign path is Hetzner - dedicated EPYC bare metal. diff --git a/docs/adr/LOKI_WEB_SERVER_SPEC.md b/docs/adr/LOKI_WEB_SERVER_SPEC.md deleted file mode 100644 index a165f40e..00000000 --- a/docs/adr/LOKI_WEB_SERVER_SPEC.md +++ /dev/null @@ -1,393 +0,0 @@ - - -# AppThere Loki — Web Server & Collaboration Spec - -**Status:** Ratified (v1) — open decisions closed 2026-07-01 -**Series:** AppThere Cloud, ADRs C012–C020 (extends C009–C011) -**Supersedes:** nothing; extends the AppThere Cloud spec -**Target edition:** Rust 2024 - ---- - -## 0. Scope & relationship to existing specs - -This spec realises the Loki-specific server: the real-time collaboration relay, the -document/storage API, and the identity and deployment story. It builds directly on the -AppThere Cloud spec (Axum / SQLx / Tokio, OIDC delegation, CRDT relay, ADRs C009–C011) -and does **not** re-litigate those foundations. What is new here: - -1. A **tiered encryption model** that reconciles "optional E2EE" with enterprise - server-side features, resolving the E2EE-vs-server-side-export mutual exclusivity - already identified in the Cloud spec. -2. First-class **self-hostability**: the same artifact deploys to Hetzner Cloud or to - the customer's own infrastructure, with no functional gap. -3. An explicit **European Digital Sovereignty** posture aligned to C5:2026, NIS2, and the - EU Cloud Sovereignty Framework (SEAL / SOV objectives). - -The **headless render/print/convert worker** is specified separately in -`LOKI_HEADLESS_SERVER_SPEC.md` (ADRs C021–C027); the two are designed to interlock. - -### Goals - -- Real-time multi-user editing of Loki documents backed by Loro CRDTs. -- PostgreSQL as the durable system-of-record; object storage for large blobs and snapshots. -- Three selectable confidentiality tiers, including zero-knowledge E2EE. -- One binary, two deployment modes (Hetzner Cloud / self-host), no feature fork. -- Enterprise readiness: SSO, RBAC, audit trail, HA, backup/restore, observability. -- Digital-sovereignty defaults suitable for EU public sector and regulated industry. - -### Non-goals (v1) - -- Iris raster collaboration (deferred to per-layer ownership per Cloud spec). -- Server-side full-text search over E2EE documents (impossible by construction; see C015). -- Billing (already covered by the Cloud spec's `async-stripe` decision; out of scope here). -- Federation between independent Loki server instances. - -### Engineering standards (inherited, non-negotiable) - -300-line file ceiling; no `unwrap()`/`expect()` in library code; `#![forbid(unsafe_code)]` -at every crate root; typed errors via `thiserror`; Apache-2.0 SPDX header on line 1; -no hardcoded user-visible strings (`fl!()`); audit-first / implement-second Claude Code -discipline. - ---- - -## 1. Component architecture - -``` - ┌────────────────────────────────────────────┐ - │ Reverse proxy / LB │ - │ (TLS 1.3 termination, HTTP/2) │ - └───────────────┬──────────────┬───────────────┘ - │ │ - ┌─────────────────────▼──┐ ┌──▼─────────────────────┐ - │ loki-server (N≥1) │ ... │ loki-server (N≥1) │ - │ ── Axum binary ── │ │ │ - │ • REST/API module │ │ │ - │ • WS collaboration mod │ │ │ - │ • auth (OIDC) module │ │ │ - └───┬───────────┬─────────┘ └────────────────────────┘ - │ │ (fan-out bus: pg LISTEN/NOTIFY or Redis) - ┌──────────────▼──┐ ┌───▼─────────────┐ ┌───────────────────────────┐ - │ PostgreSQL │ │ Object storage │ │ Job queue (apalis) │ - │ system-of-record│ │ (S3-compatible)│ │ → headless workers │ - │ metadata/ACL/ │ │ blobs/snapshots│ │ (render/print/convert) │ - │ oplog/audit │ │ SSE-C / app-enc│ └───────────────────────────┘ - └──────────────────┘ └─────────────────┘ - ▲ - ┌──────────────┴───────────────┐ - │ OIDC IdP (external) │ - │ Keycloak / Authentik / Zitadel│ ← self-hostable for sovereignty - └────────────────────────────────┘ -``` - -Crates (all new unless noted): - -| Crate | Responsibility | -|---|---| -| `loki-server` | Axum binary; wires modules; config; graceful shutdown. | -| `loki-server-api` | REST surface: workspaces, documents, members, blobs. | -| `loki-server-collab` | WebSocket relay, Loro update ingest, presence/awareness. | -| `loki-server-auth` | OIDC verification, session/token issuance, RBAC checks. | -| `loki-server-store` | Persistence port: Postgres (SQLx) + `object_store` adapter. | -| `loki-crypto` | Envelope encryption, key wrapping, crypto-agility (Tier 1/2). | -| `loki-server-audit` | Append-only, hash-chained audit log. | -| `loki-model` (exists) | Shared `DocumentId`, `WorkspaceId`, ACL types (no server deps). | - -The transport port in `loki-server-store` and the fan-out bus in `loki-server-collab` are -traits, so single-node (Postgres LISTEN/NOTIFY) and clustered (Redis pub/sub) deployments -share code — a self-host requirement, not an afterthought. - ---- - -## 2. ADRs - -### ADR-C012 — Single modular Axum binary with a pluggable fan-out bus - -**Context.** We need horizontal scalability for HA behind a load balancer, but self-hosters -must be able to run everything on one small box. Splitting sync and API into separate -services now would double the operational surface for the 90% single-node case. - -**Decision.** Ship one `loki-server` binary containing API, collaboration, and auth as -route modules. Cross-instance awareness (so two collaborators pinned to different instances -see each other) goes through a `FanOutBus` trait with two implementations: -`PgNotifyBus` (default, zero extra infra) and `RedisBus` (for larger clusters). Sticky -sessions at the LB keep a document's WebSocket connections on one instance where possible; -the bus handles the residual cross-instance case. - -**Consequences.** One artifact, one config surface. Self-host stays trivial. If a document -becomes a hotspot, we can later shard by `DocumentId` without changing the public API. - ---- - -### ADR-C013 — Loro wire protocol over WebSocket; snapshot + oplog persistence - -**Context.** Loki already round-trips full `ParaProps`/`CharProps`/`PageLayout`/`HeaderFooter` -through Loro (per `loro_bridge.rs`). The Cloud spec already committed to Loro's official wire -protocol. The server is a relay, not an authority on document semantics. - -**Decision.** Clients exchange Loro update messages over a per-document WebSocket channel. -The server: - -- **Relays** updates to all connected members and appends them to a per-document **oplog**. -- **Compacts** periodically into a **snapshot** (Loro `export(Snapshot)`), stored in object - storage; the oplog is truncated to updates newer than the snapshot. -- **Never merges or interprets** CRDT semantics beyond opaque append + broadcast. This is - what makes Tier 2 (§ADR-C014) possible: under E2EE the server compacts nothing and merely - stores encrypted update blobs plus client-produced encrypted snapshots. - -**Persistence layout.** - -| Store | Data | -|---|---| -| Postgres `doc_oplog` | `(doc_id, seq, actor, payload, created_at)` — recent updates, hot. | -| Object storage | Snapshots (`{doc_id}/snap/{version}`), attachments, images. | -| Postgres `doc_meta` | Title, ACL, current snapshot pointer, encryption tier, residency. | - -Object storage is HDD/Ceph on Hetzner and therefore correct for warm snapshots and blobs -but **not** for the hot oplog — the oplog stays in Postgres on NVMe/volume. - -**Consequences.** Recovery = latest snapshot + replay of oplog tail. Presence is ephemeral -and never persisted. Awareness (cursors/selections) is broadcast-only. - ---- - -### ADR-C014 — Tiered confidentiality model (Tier 0 / Tier 1 CMK / Tier 2 zero-knowledge) - -**Context.** "Optional E2EE" and "enterprise-ready server-side printing/conversion" pull in -opposite directions. A single boolean E2EE flag would either cripple server features or fail -the sovereignty promise. The right primitive is a **tier**, chosen per workspace (default) or -per document (override). - -**Decision.** Three tiers, envelope-encrypted throughout via per-document **DEK**s wrapped by -a tier-specific **KEK**: - -| Tier | Name | Server sees plaintext? | Server-side render/print/convert/search? | Key custody | -|---|---|---|---|---| -| **0** | Transport + at-rest | Yes | Yes | Server / platform KMS | -| **1** | Customer-managed keys (CMK) | Yes, within trust boundary | Yes | Customer KMS/HSM (Vault, PKCS#11) | -| **2** | Zero-knowledge E2EE | No (ciphertext only) | No | Client only | - -- **Tier 0.** TLS 1.3 in transit. At rest: encrypted Postgres volume (LUKS/pgcrypto) and - **SSE-C or app-layer AEAD** for object storage — mandatory because Hetzner Object Storage - has **no default at-rest encryption**. Baseline; all features available. -- **Tier 1 (CMK).** Per-document DEK wrapped by a customer-controlled KEK in their KMS/HSM. - The server decrypts *inside the customer's trust boundary* to run server-side features. Keys - never leave the customer's jurisdiction. This is the sovereignty sweet spot: confidentiality - **and** server-side office printing, because in a self-hosted enterprise the server *is* the - trust boundary. -- **Tier 2 (Zero-knowledge).** DEK is generated and held client-side; the server stores only - ciphertext. Loro updates are AEAD-encrypted (XChaCha20-Poly1305) client-side before send; - snapshots are encrypted client-side. Sharing = re-wrapping the DEK to each new member's - public key (X25519). ACL/membership metadata remains server-visible; content does not. - -**Crypto-agility.** All wrapping is expressed through a `KeyWrap` trait so hybrid PQC -(X25519 + ML-KEM) can be adopted without a data migration — C5:2026 calls out post-quantum -readiness explicitly. - -**Consequences.** One code path, three policies. Enterprises needing both confidentiality and -server-side processing choose Tier 1. Tier 2 is reserved for maximal-confidentiality documents -that accept the loss of server-side processing. - ---- - -### ADR-C015 — E2EE (Tier 2) and server-side document processing are mutually exclusive - -**Context.** Under Tier 2 the server holds only ciphertext, so it cannot render, print, -convert, thumbnail, or index a document. This is a law of the design, not a limitation to be -engineered away. - -**Decision.** Make the exclusivity explicit and enforced, not implicit: - -- Encryption tier is a first-class field on `doc_meta` and on the workspace default. -- Server-side capabilities (headless print, convert, export, server search, thumbnails) are - **feature-gated on `tier != 2`**; the API returns a typed `E2eeCapabilityDisabled` error - rather than silently producing garbage. -- Under Tier 2, those operations are the **client's** responsibility (the client can drive a - local headless instance — see headless spec ADR-C026). -- The UI must surface the trade-off at the moment a workspace/document is set to Tier 2. - -**Consequences.** No surprising failures; the exclusivity is visible in the type system and the -product. A workspace can mix tiers per document if it needs both postures. - ---- - -### ADR-C016 — PostgreSQL system-of-record; object storage via `object_store` - -**Context.** The Cloud spec already chose `object_store` for S3 abstraction. We must run -equally on Hetzner Object Storage (managed, EU-only) and MinIO (self-host), with no code fork. - -**Decision.** - -- **PostgreSQL** holds all relational, transactional, and hot data: workspaces, users, ACLs, - `doc_meta`, `doc_oplog`, audit log, and the apalis job queue (Postgres backend, so no Redis - is *required* for jobs — Redis is optional, for the fan-out bus only at scale). -- **Object storage** (via `object_store`) holds snapshots, images, and attachments. Adapter is - chosen by config URL (`s3://…` for Hetzner/MinIO). SSE-C headers or app-layer AEAD supply the - at-rest encryption Hetzner does not provide by default. -- Schema migrations via `sqlx migrate`, forward-only, zero-downtime (expand/contract pattern). - -**Consequences.** A minimal self-host is Postgres + MinIO + `loki-server`. Managed Hetzner -deployments swap MinIO for Hetzner Object Storage by changing one URL and adding SSE-C keys. - ---- - -### ADR-C017 — OIDC identity delegation; self-hostable IdP; RBAC - -**Context.** Sovereignty forbids hard dependence on a US identity provider. Enterprises expect -SSO. The Cloud spec already chose OIDC delegation. - -**Decision.** - -- The server is an **OIDC relying party**; it never stores passwords. Any compliant IdP works; - the documented sovereign defaults are **Keycloak / Authentik / Zitadel** (all self-hostable, - EU-runnable). Azure AD / Google are supported but flagged as non-sovereign. -- Optional **SCIM 2.0** provisioning endpoint for enterprise user lifecycle. -- **RBAC** at workspace and document scope: - - | Role | Rights | - |---|---| - | Owner | Full control, membership, deletion, tier changes. | - | Editor | Read/write content and metadata. | - | Commenter | Read + comment; no content edit. | - | Viewer | Read only. | - -- Under Tier 2, granting a role additionally requires re-wrapping the document DEK to the new - member's public key (an out-of-band, client-driven step surfaced by the API). - -**Consequences.** No proprietary auth. Enterprises keep identity in-house. Access decisions are -always server-authoritative (metadata), independent of tier. - ---- - -### ADR-C018 — Deployment: Hetzner (OpenTofu) and self-host (Compose / Helm), EU region pinning - -**Context.** Users deploy to Hetzner Cloud *or* their own infrastructure. The two must not -diverge functionally. - -**Decision.** Three first-class deployment artifacts, all in-repo: - -1. **OpenTofu/Terraform module** targeting the `hcloud` provider: CCX (dedicated-vCPU) servers - for `loki-server` and render workers, Private Networks, a Hetzner Load Balancer, an encrypted - Volume for Postgres data, and Hetzner Object Storage buckets. **Region is pinned to `fsn1`, - `nbg1`, or `hel1` (DE/FI) only** — never a US location — with the choice recorded as the - document residency value. -2. **Docker Compose** (single node): Postgres + MinIO + `loki-server` + one headless worker, - for evaluation and small self-host. -3. **Helm chart** (k3s/k8s): HA `loki-server` replicas, external or in-cluster Postgres, - MinIO or external S3, horizontal worker pool. - -Config is env + optional file; secrets via env / Docker secrets / Vault. **No phone-home; -telemetry opt-in and off by default.** - -**Consequences.** "Deploy to the cloud or your own infrastructure" is literally one of three -supported paths, not a porting exercise. - ---- - -### ADR-C019 — Digital-sovereignty posture - -**Context.** Target market is European Digital Sovereignty: EU public sector and regulated -industry. Certifications don't change jurisdiction — a US-owned provider remains exposed to the -US CLOUD Act regardless of badges — so the architecture itself must carry the sovereignty -guarantee. - -**Decision.** Sovereignty is a design property, mapped to the EU Cloud Sovereignty Framework's -SOV objectives: - -- **Data residency (SOV-2).** All data in EU DCs; residency pinned and recorded per document; - object storage region enforced at config validation time. -- **No non-EU jurisdiction dependency (SOV-1).** Hetzner is a German GmbH operating EU-only - DCs; self-hostable IdP; no mandatory US SaaS in the data path. -- **Open standards / no lock-in / portability (SOV-6).** `object_store` abstraction, standard - S3, standard OIDC, open-source stack, documented backup/restore → workload is portable off - Hetzner or off the cloud entirely. -- **Operational sovereignty (SOV-4).** Full self-host path means the customer's own staff can - operate, patch, and recover with no vendor in the loop. -- **Crypto (C5:2026).** Crypto-agile key wrapping ready for hybrid PQC; confidential-computing - hardening for Tier-1 (AMD SEV-SNP / Intel TDX) is **adopted** — see headless ADR-C028. - -Design to **C5:2026 / EUCS-Substantial-level** controls. EUCS itself remains voluntary and its -"High/sovereignty" tier is still unresolved at EU level, so we target the stable C5:2026 baseline -and track the forthcoming BSI cloud-sovereignty catalogue. - -**Consequences.** The sovereignty story survives audit because it rests on ownership, -residency, and portability — not on marketing. - ---- - -### ADR-C020 — Audit, retention, and GDPR data-subject operations - -**Decision.** - -- **Append-only, hash-chained audit log** (`loki-server-audit`): auth events, ACL changes, tier - changes, exports, deletions. Each entry carries the prior entry's hash → tamper-evident. -- **Retention & legal hold** via object-storage **Object Lock** (retention + legal hold), - available on Hetzner and MinIO. -- **GDPR operations** as first-class endpoints: data export (portability), right-to-erasure - (crypto-shredding the document DEK renders Tier 1/2 ciphertext unrecoverable — clean erasure), - and records of processing. DPA-ready. - -**Consequences.** Erasure is provable under Tier 1/2 by destroying keys; audit is defensible. - ---- - -## 3. API surface (sketch) - -REST, JSON, versioned under `/v1`. WebSocket for collaboration. - -``` -POST /v1/workspaces -GET /v1/workspaces/{ws}/documents -POST /v1/workspaces/{ws}/documents # sets tier (default = workspace tier) -GET /v1/documents/{doc} # metadata; content via WS/snapshot -GET /v1/documents/{doc}/snapshot # latest snapshot (cipher or plain by tier) -POST /v1/documents/{doc}/members # role grant (+ DEK re-wrap under Tier 2) -POST /v1/documents/{doc}/blobs # attachment/image upload -WS /v1/documents/{doc}/collab # Loro updates + awareness -POST /v1/documents/{doc}/export # 409 E2eeCapabilityDisabled if tier==2 -GET /v1/gdpr/export POST /v1/gdpr/erase -``` - -Errors are typed (`thiserror`) and mapped to stable problem+json codes; `E2eeCapabilityDisabled` -is the canonical Tier-2/server-side rejection. - ---- - -## 4. Data model (essentials) - -```sql -workspace(id, name, default_tier, residency, created_at) -app_user(id, oidc_sub, display_name, public_key /* X25519, for Tier 2 */) -doc_meta(id, workspace_id, title, tier, residency, snapshot_ptr, dek_wrapped, created_at) -doc_member(doc_id, user_id, role, dek_wrapped_for_user /* Tier 2 only */) -doc_oplog(doc_id, seq, actor, payload, created_at) -- payload is ciphertext under Tier 2 -audit_log(id, prev_hash, hash, actor, action, target, created_at) --- apalis job tables (Postgres backend) -``` - ---- - -## 5. HA, backup, observability - -- **HA:** ≥2 `loki-server` replicas behind the LB; Postgres primary + streaming replica; - fan-out bus makes collaboration instance-independent. -- **Backup:** Postgres PITR (WAL archived to object storage); object storage versioning + - Object Lock; documented restore runbook. -- **Observability:** OpenTelemetry traces, Prometheus metrics, structured JSON logs. No content - or plaintext ever logged; under Tier 2 nothing to leak by construction. -- **Security headers, rate limiting, CSP** at the proxy and app layers. - ---- - -## 6. Ratified decisions - -1. **Default tier — RATIFIED.** Tier 0 default for SaaS; Tier 1 default for enterprise - self-host; Tier 2 opt-in per document. -2. **Fan-out bus — RATIFIED.** `PgNotifyBus` is the default. Switch to `RedisBus` when any of: - `loki-server` replicas > 3; sustained cross-instance update fan-out > ~1,000 msg/s; or - concurrent collaborative WebSocket connections > ~2,000. The switch point is a single config - tunable; these are shipped defaults, not hard limits. -3. **Presence under Tier 2 — RATIFIED.** Awareness payloads are AEAD-encrypted client-side — - cursor sharing is retained, contents opaque to the server. -4. **ADR numbering — RATIFIED.** The C-series continues at C012. diff --git a/loki-convert/Cargo.toml b/loki-convert/Cargo.toml deleted file mode 100644 index 0d8da483..00000000 --- a/loki-convert/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "loki-convert" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" -description = "Format-conversion matrix over Loki's existing import/export crates (headless spec ADR-C024)" -repository = "https://github.com/appthere/loki" - -[dependencies] -thiserror = { workspace = true } -loki-doc-model = { path = "../loki-doc-model" } -loki-sheet-model = { path = "../loki-sheet-model" } -loki-ooxml = { path = "../loki-ooxml", features = ["docx", "xlsx"] } -loki-odf = { path = "../loki-odf" } -loki-epub = { path = "../loki-epub" } -loki-pdf = { path = "../loki-pdf" } diff --git a/loki-convert/src/error.rs b/loki-convert/src/error.rs deleted file mode 100644 index 9e7b534d..00000000 --- a/loki-convert/src/error.rs +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Typed conversion errors — the canonical rejection is -//! [`ConvertError::ConversionUnsupported`] (headless spec §3). - -use crate::format::Format; - -/// Conversion failures. -#[derive(Debug, thiserror::Error)] -pub enum ConvertError { - /// The pair is not in the capability matrix (ADR-C024): either no code - /// path exists, or the pair is deliberately gated (PPTX/ODP/ODG, - /// ratified decision §5.1). Never silently lossy. - // (Field is `source_format`, not `source` — thiserror reserves the - // latter name for the error-source chain.) - #[error("conversion from {source_format} to {target_format} is not supported: {reason}")] - ConversionUnsupported { - /// Source format. - source_format: Format, - /// Target format. - target_format: Format, - /// Why the pair is absent (missing importer/exporter or gated). - reason: &'static str, - }, - /// A format name/extension was not recognised. - #[error("unknown format {0:?}")] - UnknownFormat(String), - /// A `--profile` value was not recognised. - #[error("unknown PDF profile {0:?} (expected pdf, pdf-x1a, pdf-x3, pdf-x4, or pdf-a2b)")] - UnknownProfile(String), - /// The profile is recognised but this engine cannot emit it yet. - #[error("PDF profile {0} is not yet supported by the PDF engine")] - ProfileUnsupported(&'static str), - /// A PDF profile was supplied for a non-PDF target. - #[error("a PDF profile applies only when the target format is pdf")] - ProfileWithoutPdfTarget, - /// OOXML import/export failed. - #[error("ooxml error: {0}")] - Ooxml(#[from] loki_ooxml::error::OoxmlError), - /// ODF import/export failed. - #[error("odf error: {0}")] - Odf(#[from] loki_odf::error::OdfError), - /// EPUB export failed. - #[error("epub error: {0}")] - Epub(#[from] loki_epub::EpubError), - /// PDF export failed. - #[error("pdf error: {0}")] - Pdf(#[from] loki_pdf::PdfError), -} diff --git a/loki-convert/src/format.rs b/loki-convert/src/format.rs deleted file mode 100644 index 717b9f8e..00000000 --- a/loki-convert/src/format.rs +++ /dev/null @@ -1,135 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! File formats and PDF conformance profiles. - -/// A file format Loki can (potentially) convert between. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Format { - /// Word document (OOXML). - Docx, - /// OpenDocument text. - Odt, - /// Excel workbook (OOXML). - Xlsx, - /// OpenDocument spreadsheet. - Ods, - /// PowerPoint presentation (OOXML) — gated, see ratified decision §5.1. - Pptx, - /// OpenDocument presentation — gated alongside PPTX. - Odp, - /// OpenDocument graphics — gated alongside PPTX. - Odg, - /// EPUB 3.3 (export only). - Epub, - /// PDF (export only; profile selected via [`crate::PdfProfile`]). - Pdf, -} - -impl Format { - /// All formats, for capability-table iteration. - pub const ALL: [Format; 9] = [ - Format::Docx, - Format::Odt, - Format::Xlsx, - Format::Ods, - Format::Pptx, - Format::Odp, - Format::Odg, - Format::Epub, - Format::Pdf, - ]; - - /// Guesses the format from a file extension (case-insensitive). - #[must_use] - pub fn from_extension(ext: &str) -> Option { - match ext.to_ascii_lowercase().as_str() { - "docx" => Some(Format::Docx), - "odt" => Some(Format::Odt), - "xlsx" => Some(Format::Xlsx), - "ods" => Some(Format::Ods), - "pptx" => Some(Format::Pptx), - "odp" => Some(Format::Odp), - "odg" => Some(Format::Odg), - "epub" => Some(Format::Epub), - "pdf" => Some(Format::Pdf), - _ => None, - } - } - - /// Canonical lowercase name (matches the file extension). - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Format::Docx => "docx", - Format::Odt => "odt", - Format::Xlsx => "xlsx", - Format::Ods => "ods", - Format::Pptx => "pptx", - Format::Odp => "odp", - Format::Odg => "odg", - Format::Epub => "epub", - Format::Pdf => "pdf", - } - } -} - -impl std::fmt::Display for Format { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -impl std::str::FromStr for Format { - type Err = crate::ConvertError; - - fn from_str(s: &str) -> Result { - Format::from_extension(s).ok_or_else(|| crate::ConvertError::UnknownFormat(s.to_owned())) - } -} - -/// PDF conformance profile (headless spec ADR-C022 / §3 `--profile`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PdfProfile { - /// General output. Emitted by the current `loki-pdf` engine as its - /// default (PDF/X-1a-shaped output that any reader accepts). - #[default] - Default, - /// PDF/X-1a — CMYK/spot print production. - PdfX1a, - /// PDF/X-3 — CMYK plus ICC-tagged colour. - PdfX3, - /// PDF/X-4 — print production with live transparency. - PdfX4, - /// PDF/A-2b — archival. Recognised but not yet emitted; see - /// [`crate::ConvertError::ProfileUnsupported`]. - PdfA2b, -} - -impl PdfProfile { - /// CLI/API token (`pdf-x4`, `pdf-a2b`, …). - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - PdfProfile::Default => "pdf", - PdfProfile::PdfX1a => "pdf-x1a", - PdfProfile::PdfX3 => "pdf-x3", - PdfProfile::PdfX4 => "pdf-x4", - PdfProfile::PdfA2b => "pdf-a2b", - } - } -} - -impl std::str::FromStr for PdfProfile { - type Err = crate::ConvertError; - - fn from_str(s: &str) -> Result { - match s { - "pdf" => Ok(PdfProfile::Default), - "pdf-x1a" => Ok(PdfProfile::PdfX1a), - "pdf-x3" => Ok(PdfProfile::PdfX3), - "pdf-x4" => Ok(PdfProfile::PdfX4), - "pdf-a2b" => Ok(PdfProfile::PdfA2b), - other => Err(crate::ConvertError::UnknownProfile(other.to_owned())), - } - } -} diff --git a/loki-convert/src/lib.rs b/loki-convert/src/lib.rs deleted file mode 100644 index 58b75194..00000000 --- a/loki-convert/src/lib.rs +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Format-conversion matrix (headless spec ADR-C024). -//! -//! Conversion is orchestration, not new format code: every pair goes -//! *import source → in-memory model → export target* through the existing -//! `loki-ooxml` / `loki-odf` / `loki-epub` / `loki-pdf` crates, so -//! conversion quality is exactly the round-trip quality those crates -//! already measure. Unsupported pairs are a typed -//! [`ConvertError::ConversionUnsupported`], never a lossy best-effort; -//! PPTX (and ODP/ODG) stay gated until the ACID PPTX generator lands -//! (ratified decision §5.1). - -#![forbid(unsafe_code)] - -mod error; -mod format; -mod matrix; -mod pipeline; - -pub use error::ConvertError; -pub use format::{Format, PdfProfile}; -pub use matrix::{is_supported, supported_pairs}; -pub use pipeline::{ConvertOptions, ConvertOutput, convert}; diff --git a/loki-convert/src/matrix.rs b/loki-convert/src/matrix.rs deleted file mode 100644 index 8aa742fc..00000000 --- a/loki-convert/src/matrix.rs +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! The static capability matrix (ADR-C024). -//! -//! Support is derived from which importers/exporters actually exist, plus -//! the deliberate PPTX/ODP/ODG gate (ratified decision §5.1). A pair absent -//! here is a typed error at the API surface, never a best-effort. - -use crate::format::Format; - -const GATED_PRESENTATION: &str = "presentation/graphics conversion is gated until the ACID PPTX generator's 29 cases pass \ - (ratified decision 5.1)"; - -/// Why a pair is unsupported, or `None` when it is supported. -pub(crate) fn unsupported_reason(source: Format, target: Format) -> Option<&'static str> { - use Format::{Docx, Epub, Odg, Odp, Ods, Odt, Pdf, Pptx, Xlsx}; - if matches!(source, Pptx | Odp | Odg) || matches!(target, Pptx | Odp | Odg) { - return Some(GATED_PRESENTATION); - } - match source { - Epub => return Some("EPUB is export-only; there is no EPUB importer"), - Pdf => return Some("PDF is export-only; there is no PDF importer"), - _ => {} - } - let text_source = matches!(source, Docx | Odt); - match target { - Docx | Odt | Epub | Pdf if text_source => None, - Xlsx | Ods if !text_source => None, - Xlsx | Ods => Some("spreadsheet targets require a spreadsheet source (XLSX/ODS)"), - _ => Some( - "text-document targets require a text-document source; spreadsheets have no \ - layout/PDF path yet", - ), - } -} - -/// Whether `source → target` is in the capability matrix. -#[must_use] -pub fn is_supported(source: Format, target: Format) -> bool { - unsupported_reason(source, target).is_none() -} - -/// Every supported `(source, target)` pair, for `--list`-style output and -/// conformance-plan iteration. -#[must_use] -pub fn supported_pairs() -> Vec<(Format, Format)> { - let mut pairs = Vec::new(); - for source in Format::ALL { - for target in Format::ALL { - if is_supported(source, target) { - pairs.push((source, target)); - } - } - } - pairs -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn matrix_matches_the_spec() { - // Text documents convert among themselves and to EPUB/PDF. - for source in [Format::Docx, Format::Odt] { - for target in [Format::Docx, Format::Odt, Format::Epub, Format::Pdf] { - assert!(is_supported(source, target), "{source}->{target}"); - } - } - // Spreadsheets convert among themselves only (no PDF path yet). - for source in [Format::Xlsx, Format::Ods] { - for target in [Format::Xlsx, Format::Ods] { - assert!(is_supported(source, target), "{source}->{target}"); - } - assert!(!is_supported(source, Format::Pdf)); - assert!(!is_supported(source, Format::Docx)); - } - // Cross-family and export-only sources are rejected. - assert!(!is_supported(Format::Docx, Format::Xlsx)); - assert!(!is_supported(Format::Epub, Format::Pdf)); - assert!(!is_supported(Format::Pdf, Format::Docx)); - // The presentation gate (ratified decision 5.1). - for gated in [Format::Pptx, Format::Odp, Format::Odg] { - assert!(!is_supported(gated, Format::Pdf)); - assert!(!is_supported(Format::Docx, gated)); - } - assert_eq!(supported_pairs().len(), 2 * 4 + 2 * 2); - } -} diff --git a/loki-convert/src/pipeline.rs b/loki-convert/src/pipeline.rs deleted file mode 100644 index 7dcf3415..00000000 --- a/loki-convert/src/pipeline.rs +++ /dev/null @@ -1,172 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! The conversion pipeline: import source → in-memory model → export target. - -use std::io::Cursor; - -use loki_doc_model::document::Document; -use loki_doc_model::io::DocumentExport; -use loki_epub::{EpubExport, EpubOptions}; -use loki_odf::odt::import::OdtImporter; -use loki_odf::{OdsExport, OdsImport, OdsImportOptions, OdtExport, OdtImportOptions}; -use loki_ooxml::docx::import::DocxImporter; -use loki_ooxml::{DocxExport, DocxImportOptions, XlsxExport, XlsxImport, XlsxImportOptions}; -use loki_pdf::{PdfXLevel, PdfXOptions}; -use loki_sheet_model::workbook::Workbook; - -use crate::error::ConvertError; -use crate::format::{Format, PdfProfile}; -use crate::matrix::unsupported_reason; - -/// Options applied across the whole conversion. -#[derive(Debug, Clone, Default)] -pub struct ConvertOptions { - /// PDF conformance profile; only meaningful when the target is PDF. - pub pdf_profile: PdfProfile, - /// Overrides the document title in formats that carry one. - pub title: Option, -} - -/// A successful conversion. -#[derive(Debug)] -pub struct ConvertOutput { - /// The converted file bytes. - pub bytes: Vec, - /// Non-fatal import warnings (unrecognised elements, unresolved - /// relationships, …) — surfaced so batch jobs can log quality issues. - pub warnings: Vec, -} - -/// Converts `input` (bytes of a `source`-format file) into `target` format. -/// -/// Unsupported pairs return [`ConvertError::ConversionUnsupported`] before -/// any parsing happens (ADR-C024). -pub fn convert( - source: Format, - input: &[u8], - target: Format, - options: &ConvertOptions, -) -> Result { - if let Some(reason) = unsupported_reason(source, target) { - return Err(ConvertError::ConversionUnsupported { - source_format: source, - target_format: target, - reason, - }); - } - if options.pdf_profile != PdfProfile::Default && target != Format::Pdf { - return Err(ConvertError::ProfileWithoutPdfTarget); - } - match source { - Format::Docx | Format::Odt => { - let text_source = if source == Format::Docx { - TextSource::Docx - } else { - TextSource::Odt - }; - let (mut doc, warnings) = import_text(text_source, input)?; - if let Some(title) = &options.title { - doc.meta.title = Some(title.clone()); - } - let bytes = export_text(&doc, source, target, options)?; - Ok(ConvertOutput { bytes, warnings }) - } - Format::Xlsx | Format::Ods => { - let workbook = import_sheet(source, input)?; - let bytes = export_sheet(&workbook, target)?; - Ok(ConvertOutput { - bytes, - warnings: Vec::new(), - }) - } - // unsupported_reason() already rejected every other source; this arm - // exists only to keep the match exhaustive without panicking. - _ => Err(ConvertError::ConversionUnsupported { - source_format: source, - target_format: target, - reason: "source format has no import path", - }), - } -} - -/// Text-document sources, narrowed after the matrix check. -enum TextSource { - Docx, - Odt, -} - -fn import_text(source: TextSource, input: &[u8]) -> Result<(Document, Vec), ConvertError> { - let cursor = Cursor::new(input); - match source { - TextSource::Docx => { - let result = DocxImporter::new(DocxImportOptions::default()).run(cursor)?; - let warnings = result.warnings.iter().map(|w| format!("{w:?}")).collect(); - Ok((result.document, warnings)) - } - TextSource::Odt => { - let result = OdtImporter::new(OdtImportOptions::default()).run(cursor)?; - let warnings = result.warnings.iter().map(|w| format!("{w:?}")).collect(); - Ok((result.document, warnings)) - } - } -} - -fn export_text( - doc: &Document, - source: Format, - target: Format, - options: &ConvertOptions, -) -> Result, ConvertError> { - let mut cursor = Cursor::new(Vec::new()); - match target { - Format::Docx => DocxExport::export(doc, &mut cursor, ())?, - Format::Odt => OdtExport::export(doc, &mut cursor, Default::default())?, - Format::Epub => EpubExport::export(doc, &mut cursor, EpubOptions::default())?, - Format::Pdf => { - let level = match options.pdf_profile { - PdfProfile::Default | PdfProfile::PdfX1a => PdfXLevel::X1a, - PdfProfile::PdfX3 => PdfXLevel::X3, - PdfProfile::PdfX4 => PdfXLevel::X4, - // TODO(headless-c022): PDF/A-2b needs the krilla engine - // migration; the current pdf-writer engine emits PDF/X only. - PdfProfile::PdfA2b => return Err(ConvertError::ProfileUnsupported("pdf-a2b")), - }; - let pdf_options = PdfXOptions { - level, - title: options.title.clone(), - ..Default::default() - }; - let mut out = Vec::new(); - loki_pdf::export_document(doc, &pdf_options, &mut out)?; - return Ok(out); - } - // The matrix admits only the targets above for text sources. - other => { - return Err(ConvertError::ConversionUnsupported { - source_format: source, - target_format: other, - reason: "target format has no text-document export path", - }); - } - } - Ok(cursor.into_inner()) -} - -fn import_sheet(source: Format, input: &[u8]) -> Result { - let cursor = Cursor::new(input); - match source { - Format::Ods => Ok(OdsImport::import(cursor, OdsImportOptions::default())?), - // The caller only passes Xlsx or Ods; default to the XLSX importer. - _ => Ok(XlsxImport::import(cursor, XlsxImportOptions::default())?), - } -} - -fn export_sheet(workbook: &Workbook, target: Format) -> Result, ConvertError> { - let mut cursor = Cursor::new(Vec::new()); - match target { - Format::Ods => OdsExport::export(workbook, &mut cursor)?, - // The matrix admits only Xlsx | Ods here. - _ => XlsxExport::export(workbook, &mut cursor)?, - } - Ok(cursor.into_inner()) -} diff --git a/loki-convert/tests/matrix_round_trips.rs b/loki-convert/tests/matrix_round_trips.rs deleted file mode 100644 index 900d9244..00000000 --- a/loki-convert/tests/matrix_round_trips.rs +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! End-to-end conversions through real format bytes: build a document / -//! workbook, export it, run it through the matrix, and validate the output -//! parses as the target format (ADR-C024). - -use std::io::Cursor; - -use loki_convert::{ConvertError, ConvertOptions, Format, PdfProfile, convert}; -use loki_doc_model::content::block::Block; -use loki_doc_model::content::inline::Inline; -use loki_doc_model::document::Document; -use loki_doc_model::io::{DocumentExport, DocumentImport}; -use loki_ooxml::{DocxExport, DocxImport}; -use loki_sheet_model::workbook::{Workbook, Worksheet}; - -fn sample_docx() -> Vec { - let mut doc = Document::new(); - doc.meta.title = Some("Conversion fixture".into()); - if let Some(section) = doc.sections.first_mut() { - section.blocks.push(Block::Heading( - 1, - Default::default(), - vec![Inline::Str("Quarterly report".into())], - )); - section.blocks.push(Block::Para(vec![Inline::Str( - "Printed and converted without a GPU.".into(), - )])); - } - let mut cursor = Cursor::new(Vec::new()); - DocxExport::export(&doc, &mut cursor, ()).expect("fixture docx"); - cursor.into_inner() -} - -fn sample_ods() -> Vec { - let mut workbook = Workbook::new(); - workbook.sheets.push(Worksheet::new("Sheet1")); - let mut cursor = Cursor::new(Vec::new()); - loki_odf::OdsExport::export(&workbook, &mut cursor).expect("fixture ods"); - cursor.into_inner() -} - -#[test] -fn docx_to_odt_to_docx_preserves_text() { - let docx = sample_docx(); - let odt = convert(Format::Docx, &docx, Format::Odt, &ConvertOptions::default()).unwrap(); - assert_eq!(&odt.bytes[..2], b"PK"); - - let back = convert( - Format::Odt, - &odt.bytes, - Format::Docx, - &ConvertOptions::default(), - ) - .unwrap(); - let doc = DocxImport::import(Cursor::new(&back.bytes), Default::default()).unwrap(); - let text = format!("{:?}", doc.sections); - assert!( - text.contains("Quarterly report"), - "heading lost in round trip" - ); - assert!( - text.contains("without a GPU"), - "paragraph lost in round trip" - ); -} - -#[test] -fn docx_to_pdf_emits_requested_profile() { - let docx = sample_docx(); - let options = ConvertOptions { - pdf_profile: PdfProfile::PdfX4, - title: Some("Print run".into()), - }; - let pdf = convert(Format::Docx, &docx, Format::Pdf, &options).unwrap(); - assert!(pdf.bytes.starts_with(b"%PDF-1."), "missing PDF header"); - let text = String::from_utf8_lossy(&pdf.bytes); - assert!(text.contains("PDF/X-4"), "PDF/X-4 marker missing"); -} - -#[test] -fn docx_to_epub_produces_epub_container() { - let docx = sample_docx(); - let epub = convert( - Format::Docx, - &docx, - Format::Epub, - &ConvertOptions::default(), - ) - .unwrap(); - // OCF: ZIP whose first entry is the stored `mimetype` file. - assert_eq!(&epub.bytes[..2], b"PK"); - assert!( - epub.bytes.windows(20).any(|w| w == b"application/epub+zip"), - "EPUB mimetype missing" - ); -} - -#[test] -fn ods_and_xlsx_round_trip() { - let ods = sample_ods(); - let xlsx = convert(Format::Ods, &ods, Format::Xlsx, &ConvertOptions::default()).unwrap(); - assert_eq!(&xlsx.bytes[..2], b"PK"); - let back = convert( - Format::Xlsx, - &xlsx.bytes, - Format::Ods, - &ConvertOptions::default(), - ) - .unwrap(); - assert_eq!(&back.bytes[..2], b"PK"); -} - -#[test] -fn unsupported_pairs_are_typed_and_parse_nothing() { - // Cross-family: even valid bytes are rejected before parsing. - let err = convert( - Format::Docx, - b"garbage", - Format::Xlsx, - &ConvertOptions::default(), - ) - .unwrap_err(); - assert!(matches!(err, ConvertError::ConversionUnsupported { .. })); - - // The presentation gate (ratified decision 5.1). - let err = convert( - Format::Pptx, - b"garbage", - Format::Pdf, - &ConvertOptions::default(), - ) - .unwrap_err(); - assert!(matches!(err, ConvertError::ConversionUnsupported { .. })); -} - -#[test] -fn pdf_a2b_is_honestly_unsupported() { - let docx = sample_docx(); - let options = ConvertOptions { - pdf_profile: PdfProfile::PdfA2b, - title: None, - }; - let err = convert(Format::Docx, &docx, Format::Pdf, &options).unwrap_err(); - assert!(matches!(err, ConvertError::ProfileUnsupported("pdf-a2b"))); -} - -#[test] -fn profile_on_non_pdf_target_is_rejected() { - let docx = sample_docx(); - let options = ConvertOptions { - pdf_profile: PdfProfile::PdfX4, - title: None, - }; - let err = convert(Format::Docx, &docx, Format::Odt, &options).unwrap_err(); - assert!(matches!(err, ConvertError::ProfileWithoutPdfTarget)); -} diff --git a/loki-crypto/Cargo.toml b/loki-crypto/Cargo.toml deleted file mode 100644 index 46fa4406..00000000 --- a/loki-crypto/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "loki-crypto" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" -description = "Envelope encryption and crypto-agile key wrapping for the Loki server (ADR-C014)" -repository = "https://github.com/appthere/loki" - -[dependencies] -thiserror = { workspace = true } -serde = { workspace = true } -chacha20poly1305 = { workspace = true } -x25519-dalek = { workspace = true } -hkdf = { workspace = true } -sha2 = { workspace = true } -rand_core = { workspace = true } -zeroize = { workspace = true } -base64 = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } diff --git a/loki-crypto/src/aead_wrap.rs b/loki-crypto/src/aead_wrap.rs deleted file mode 100644 index 9fb746da..00000000 --- a/loki-crypto/src/aead_wrap.rs +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Symmetric-KEK key wrapping for Tiers 0 and 1 (ADR-C014). - -use zeroize::{Zeroize, ZeroizeOnDrop}; - -use crate::dek::{DEK_LEN, Dek}; -use crate::error::CryptoError; -use crate::wrap::{KeyWrap, WrappedDek}; - -/// Stable algorithm tag for this scheme. -pub const AEAD_WRAP_ALGORITHM: &str = "xchacha20-poly1305-kek.v1"; - -/// A symmetric key-encryption key. -/// -/// Under Tier 0 the KEK lives in the platform KMS; under Tier 1 it is -/// customer-managed (Vault, PKCS#11). In both cases the raw KEK bytes enter -/// this process only inside the relevant trust boundary. -#[derive(Clone, Zeroize, ZeroizeOnDrop)] -pub struct Kek([u8; DEK_LEN]); - -impl Kek { - /// Reconstructs a KEK from raw bytes supplied by the KMS integration. - pub fn from_bytes(bytes: &[u8]) -> Result { - let arr: [u8; DEK_LEN] = bytes - .try_into() - .map_err(|_| CryptoError::InvalidKeyLength { - expected: DEK_LEN, - actual: bytes.len(), - })?; - Ok(Self(arr)) - } - - /// Generates a fresh random KEK (used by tests and local-dev setups). - #[must_use] - pub fn generate() -> Self { - Self(*Dek::generate().as_bytes()) - } -} - -impl std::fmt::Debug for Kek { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("Kek(..)") - } -} - -/// Wraps DEKs under a symmetric KEK with XChaCha20-Poly1305. -#[derive(Debug)] -pub struct AeadKeyWrap { - kek: Kek, -} - -impl AeadKeyWrap { - /// Creates a wrapper around the given KEK. - #[must_use] - pub fn new(kek: Kek) -> Self { - Self { kek } - } -} - -impl KeyWrap for AeadKeyWrap { - fn algorithm(&self) -> &'static str { - AEAD_WRAP_ALGORITHM - } - - fn wrap(&self, dek: &Dek) -> Result { - // The KEK is structurally a 256-bit XChaCha20 key, so reuse Dek's - // sealed-blob layout (nonce || ciphertext+tag) with the algorithm - // tag as AAD. - let kek_cipher = Dek::from_bytes(&self.kek.0)?; - let blob = kek_cipher.seal(dek.as_bytes(), AEAD_WRAP_ALGORITHM.as_bytes())?; - Ok(WrappedDek { - algorithm: AEAD_WRAP_ALGORITHM.to_owned(), - blob, - }) - } - - fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result { - if wrapped.algorithm != AEAD_WRAP_ALGORITHM { - return Err(CryptoError::UnsupportedAlgorithm(wrapped.algorithm.clone())); - } - let kek_cipher = Dek::from_bytes(&self.kek.0)?; - let raw = kek_cipher.open(&wrapped.blob, AEAD_WRAP_ALGORITHM.as_bytes())?; - Dek::from_bytes(&raw) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn wrap_unwrap_round_trip() { - let wrapper = AeadKeyWrap::new(Kek::generate()); - let dek = Dek::generate(); - let wrapped = wrapper.wrap(&dek).unwrap(); - assert_eq!(wrapped.algorithm, AEAD_WRAP_ALGORITHM); - let unwrapped = wrapper.unwrap_dek(&wrapped).unwrap(); - assert_eq!(unwrapped.as_bytes(), dek.as_bytes()); - } - - #[test] - fn wrong_kek_fails() { - let wrapper = AeadKeyWrap::new(Kek::generate()); - let other = AeadKeyWrap::new(Kek::generate()); - let wrapped = wrapper.wrap(&Dek::generate()).unwrap(); - assert!(other.unwrap_dek(&wrapped).is_err()); - } - - #[test] - fn algorithm_mismatch_is_typed() { - let wrapper = AeadKeyWrap::new(Kek::generate()); - let mut wrapped = wrapper.wrap(&Dek::generate()).unwrap(); - wrapped.algorithm = "x25519-xchacha20.v1".to_owned(); - assert!(matches!( - wrapper.unwrap_dek(&wrapped), - Err(CryptoError::UnsupportedAlgorithm(_)) - )); - } -} diff --git a/loki-crypto/src/dek.rs b/loki-crypto/src/dek.rs deleted file mode 100644 index a39fbe4b..00000000 --- a/loki-crypto/src/dek.rs +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Per-document data-encryption keys and AEAD sealing. - -use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, Payload}; -use chacha20poly1305::{XChaCha20Poly1305, XNonce}; -use rand_core::OsRng; -use zeroize::{Zeroize, ZeroizeOnDrop}; - -use crate::error::CryptoError; - -/// Byte length of a DEK (XChaCha20-Poly1305 key). -pub const DEK_LEN: usize = 32; - -/// XChaCha20-Poly1305 nonce length; the nonce is prepended to ciphertext. -const NONCE_LEN: usize = 24; - -/// A per-document data-encryption key. -/// -/// The key material is zeroized on drop. Destroying every wrapped copy of a -/// DEK crypto-shreds the document (GDPR erasure, ADR-C020). -#[derive(Clone, Zeroize, ZeroizeOnDrop)] -pub struct Dek([u8; DEK_LEN]); - -impl Dek { - /// Generates a fresh random DEK. - #[must_use] - pub fn generate() -> Self { - let key = XChaCha20Poly1305::generate_key(&mut OsRng); - Self(key.into()) - } - - /// Reconstructs a DEK from raw bytes (e.g. after unwrapping). - pub fn from_bytes(bytes: &[u8]) -> Result { - let arr: [u8; DEK_LEN] = bytes - .try_into() - .map_err(|_| CryptoError::InvalidKeyLength { - expected: DEK_LEN, - actual: bytes.len(), - })?; - Ok(Self(arr)) - } - - /// Exposes the raw key bytes (needed to wrap the DEK; handle with care). - #[must_use] - pub fn as_bytes(&self) -> &[u8; DEK_LEN] { - &self.0 - } - - /// Seals `plaintext` with this DEK, binding `aad` (e.g. the document id). - /// - /// Output layout: `nonce (24 bytes) || ciphertext+tag`. A fresh random - /// nonce is used per call; XChaCha's 192-bit nonce makes random nonces - /// collision-safe at any realistic volume. - pub fn seal(&self, plaintext: &[u8], aad: &[u8]) -> Result, CryptoError> { - let cipher = XChaCha20Poly1305::new(self.0.as_slice().into()); - let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); - let ciphertext = cipher - .encrypt( - &nonce, - Payload { - msg: plaintext, - aad, - }, - ) - .map_err(|_| CryptoError::EncryptFailed)?; - let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len()); - out.extend_from_slice(&nonce); - out.extend_from_slice(&ciphertext); - Ok(out) - } - - /// Opens a blob produced by [`Dek::seal`] with the same `aad`. - pub fn open(&self, sealed: &[u8], aad: &[u8]) -> Result, CryptoError> { - if sealed.len() < NONCE_LEN { - return Err(CryptoError::CiphertextTooShort(sealed.len())); - } - let (nonce, ciphertext) = sealed.split_at(NONCE_LEN); - let cipher = XChaCha20Poly1305::new(self.0.as_slice().into()); - cipher - .decrypt( - XNonce::from_slice(nonce), - Payload { - msg: ciphertext, - aad, - }, - ) - .map_err(|_| CryptoError::DecryptFailed) - } -} - -impl std::fmt::Debug for Dek { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Never print key material. - f.write_str("Dek(..)") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn seal_open_round_trip() { - let dek = Dek::generate(); - let sealed = dek.seal(b"hello loki", b"doc-1").unwrap(); - assert_eq!(dek.open(&sealed, b"doc-1").unwrap(), b"hello loki"); - } - - #[test] - fn wrong_key_or_aad_fails() { - let dek = Dek::generate(); - let other = Dek::generate(); - let sealed = dek.seal(b"secret", b"doc-1").unwrap(); - assert!(matches!( - other.open(&sealed, b"doc-1"), - Err(CryptoError::DecryptFailed) - )); - assert!(matches!( - dek.open(&sealed, b"doc-2"), - Err(CryptoError::DecryptFailed) - )); - } - - #[test] - fn tampered_ciphertext_fails() { - let dek = Dek::generate(); - let mut sealed = dek.seal(b"secret", b"").unwrap(); - let last = sealed.len() - 1; - sealed[last] ^= 0x01; - assert!(dek.open(&sealed, b"").is_err()); - assert!(matches!( - dek.open(&sealed[..10], b""), - Err(CryptoError::CiphertextTooShort(10)) - )); - } - - #[test] - fn debug_never_leaks_key_material() { - let dek = Dek::generate(); - assert_eq!(format!("{dek:?}"), "Dek(..)"); - } -} diff --git a/loki-crypto/src/error.rs b/loki-crypto/src/error.rs deleted file mode 100644 index a7ddd417..00000000 --- a/loki-crypto/src/error.rs +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Typed errors for envelope encryption and key wrapping. - -/// Errors produced by sealing, opening, wrapping, or unwrapping keys. -/// -/// AEAD failures are deliberately opaque (no distinction between a bad key, -/// a truncated ciphertext, and a forged tag) to avoid oracle behaviour. -#[derive(Debug, thiserror::Error)] -pub enum CryptoError { - /// Authenticated decryption failed (wrong key, tampered or truncated data). - #[error("authenticated decryption failed")] - DecryptFailed, - /// Authenticated encryption failed (should not happen with valid inputs). - #[error("authenticated encryption failed")] - EncryptFailed, - /// The ciphertext blob is too short to contain a nonce. - #[error("ciphertext too short ({0} bytes)")] - CiphertextTooShort(usize), - /// A wrapped DEK declared an algorithm this build does not implement. - #[error("unsupported key-wrap algorithm {0:?}")] - UnsupportedAlgorithm(String), - /// A key blob had the wrong length. - #[error("invalid key length {actual} (expected {expected})")] - InvalidKeyLength { - /// Expected byte length. - expected: usize, - /// Actual byte length received. - actual: usize, - }, -} diff --git a/loki-crypto/src/lib.rs b/loki-crypto/src/lib.rs deleted file mode 100644 index 45090987..00000000 --- a/loki-crypto/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Envelope encryption and crypto-agile key wrapping (ADR-C014). -//! -//! Every document has a per-document **DEK** (data-encryption key). Content -//! is sealed with the DEK via XChaCha20-Poly1305. The DEK itself is wrapped -//! by a tier-specific **KEK** through the [`KeyWrap`] trait: -//! -//! - Tier 0/1: a symmetric KEK ([`AeadKeyWrap`]) held by the platform KMS -//! (Tier 0) or the customer's KMS/HSM (Tier 1). -//! - Tier 2: the recipient's X25519 public key ([`X25519KeyWrap`]) — the -//! server only ever sees the wrapped form. -//! -//! [`WrappedDek`] records the wrapping algorithm alongside the blob so hybrid -//! PQC (X25519 + ML-KEM) can be introduced later without a data migration -//! (crypto-agility, C5:2026). - -#![forbid(unsafe_code)] - -mod aead_wrap; -mod dek; -mod error; -mod wrap; -mod x25519_wrap; - -pub use aead_wrap::{AeadKeyWrap, Kek}; -pub use dek::{DEK_LEN, Dek}; -pub use error::CryptoError; -pub use wrap::{KeyWrap, WrappedDek}; -pub use x25519_wrap::{X25519KeyWrap, X25519PublicKey, X25519SecretKey}; diff --git a/loki-crypto/src/wrap.rs b/loki-crypto/src/wrap.rs deleted file mode 100644 index 3fa92feb..00000000 --- a/loki-crypto/src/wrap.rs +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! The crypto-agile key-wrapping port (ADR-C014). - -use serde::{Deserialize, Serialize}; - -use crate::dek::Dek; -use crate::error::CryptoError; - -/// A wrapped (encrypted) DEK, tagged with the algorithm that wrapped it. -/// -/// The `algorithm` tag is stored alongside the blob (in `doc_meta.dek_wrapped` -/// / `doc_member.dek_wrapped_for_user`) so a future hybrid-PQC wrap -/// (X25519 + ML-KEM) can coexist with current data — unwrapping dispatches on -/// the tag instead of assuming one scheme. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WrappedDek { - /// Identifier of the wrapping scheme, e.g. `"xchacha20-poly1305-kek.v1"`. - pub algorithm: String, - /// Opaque wrapped key material. - #[serde(with = "serde_bytes_base64")] - pub blob: Vec, -} - -/// A scheme that can wrap and unwrap document DEKs. -/// -/// Implementations: [`crate::AeadKeyWrap`] (symmetric KEK, Tiers 0/1) and -/// [`crate::X25519KeyWrap`] (per-member public key, Tier 2). A hybrid PQC -/// implementation slots in without touching stored data. -/// -/// `Send + Sync` because instances are shared server state (`Arc`). -pub trait KeyWrap: Send + Sync { - /// Stable identifier written into [`WrappedDek::algorithm`]. - fn algorithm(&self) -> &'static str; - - /// Wraps `dek` for the key custodian this instance represents. - fn wrap(&self, dek: &Dek) -> Result; - - /// Unwraps a DEK previously produced by [`KeyWrap::wrap`]. - /// - /// Returns [`CryptoError::UnsupportedAlgorithm`] if `wrapped.algorithm` - /// does not match this scheme. - fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result; -} - -/// Serializes the wrapped blob as base64 for JSON transport. -mod serde_bytes_base64 { - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD; - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize(bytes: &[u8], serializer: S) -> Result { - serializer.serialize_str(&STANDARD.encode(bytes)) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - let s = String::deserialize(deserializer)?; - STANDARD.decode(&s).map_err(serde::de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn wrapped_dek_json_round_trip() { - let wrapped = WrappedDek { - algorithm: "xchacha20-poly1305-kek.v1".to_owned(), - blob: (0..64u8).collect(), - }; - let json = serde_json::to_string(&wrapped).unwrap(); - let back: WrappedDek = serde_json::from_str(&json).unwrap(); - assert_eq!(back, wrapped); - } -} diff --git a/loki-crypto/src/x25519_wrap.rs b/loki-crypto/src/x25519_wrap.rs deleted file mode 100644 index 97879210..00000000 --- a/loki-crypto/src/x25519_wrap.rs +++ /dev/null @@ -1,170 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Public-key DEK wrapping for Tier 2 (zero-knowledge E2EE, ADR-C014). -//! -//! Sharing a Tier-2 document means re-wrapping its DEK to each member's -//! X25519 public key (ECIES-style: ephemeral X25519 → HKDF-SHA256 → -//! XChaCha20-Poly1305). The server stores only the wrapped form; unwrapping -//! happens on clients holding the member's secret key. - -use hkdf::Hkdf; -use rand_core::OsRng; -use sha2::Sha256; -use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; - -use crate::dek::{DEK_LEN, Dek}; -use crate::error::CryptoError; -use crate::wrap::{KeyWrap, WrappedDek}; - -/// Stable algorithm tag for this scheme. -pub const X25519_WRAP_ALGORITHM: &str = "x25519-hkdf-sha256-xchacha20.v1"; - -/// Byte length of an X25519 public key. -const PK_LEN: usize = 32; - -/// A member's X25519 public key (stored in `app_user.public_key`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct X25519PublicKey(PublicKey); - -impl X25519PublicKey { - /// Reconstructs a public key from its 32-byte form. - pub fn from_bytes(bytes: &[u8]) -> Result { - let arr: [u8; PK_LEN] = bytes - .try_into() - .map_err(|_| CryptoError::InvalidKeyLength { - expected: PK_LEN, - actual: bytes.len(), - })?; - Ok(Self(PublicKey::from(arr))) - } - - /// Returns the 32-byte wire form. - #[must_use] - pub fn as_bytes(&self) -> &[u8; PK_LEN] { - self.0.as_bytes() - } -} - -/// A member's X25519 secret key (client-side only; never sent to the server). -pub struct X25519SecretKey(StaticSecret); - -impl X25519SecretKey { - /// Generates a fresh keypair secret. - #[must_use] - pub fn generate() -> Self { - Self(StaticSecret::random_from_rng(OsRng)) - } - - /// Reconstructs a secret key from its 32-byte form. - pub fn from_bytes(bytes: &[u8]) -> Result { - let arr: [u8; PK_LEN] = bytes - .try_into() - .map_err(|_| CryptoError::InvalidKeyLength { - expected: PK_LEN, - actual: bytes.len(), - })?; - Ok(Self(StaticSecret::from(arr))) - } - - /// Derives the matching public key. - #[must_use] - pub fn public_key(&self) -> X25519PublicKey { - X25519PublicKey(PublicKey::from(&self.0)) - } -} - -impl std::fmt::Debug for X25519SecretKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("X25519SecretKey(..)") - } -} - -/// Wraps DEKs to an X25519 recipient; unwraps with the recipient's secret. -/// -/// Blob layout: `ephemeral_pk (32 bytes) || nonce || ciphertext+tag`. -#[derive(Debug)] -pub struct X25519KeyWrap { - recipient_pk: X25519PublicKey, - recipient_sk: Option, -} - -impl X25519KeyWrap { - /// Wrap-only instance (all the server / a sharing client needs). - #[must_use] - pub fn for_recipient(recipient_pk: X25519PublicKey) -> Self { - Self { - recipient_pk, - recipient_sk: None, - } - } - - /// Full instance for the key holder (a client unwrapping its own DEKs). - #[must_use] - pub fn for_key_holder(secret: X25519SecretKey) -> Self { - Self { - recipient_pk: secret.public_key(), - recipient_sk: Some(secret), - } - } - - fn derive_wrapping_key( - shared: &x25519_dalek::SharedSecret, - ephemeral_pk: &PublicKey, - recipient_pk: &X25519PublicKey, - ) -> Result { - // Salt binds both public keys; info binds the algorithm tag. - let mut salt = Vec::with_capacity(PK_LEN * 2); - salt.extend_from_slice(ephemeral_pk.as_bytes()); - salt.extend_from_slice(recipient_pk.as_bytes()); - let hkdf = Hkdf::::new(Some(&salt), shared.as_bytes()); - let mut okm = [0u8; DEK_LEN]; - hkdf.expand(X25519_WRAP_ALGORITHM.as_bytes(), &mut okm) - .map_err(|_| CryptoError::EncryptFailed)?; - Dek::from_bytes(&okm) - } -} - -impl KeyWrap for X25519KeyWrap { - fn algorithm(&self) -> &'static str { - X25519_WRAP_ALGORITHM - } - - fn wrap(&self, dek: &Dek) -> Result { - let ephemeral = EphemeralSecret::random_from_rng(OsRng); - let ephemeral_pk = PublicKey::from(&ephemeral); - let shared = ephemeral.diffie_hellman(&self.recipient_pk.0); - let wrapping_key = Self::derive_wrapping_key(&shared, &ephemeral_pk, &self.recipient_pk)?; - let sealed = wrapping_key.seal(dek.as_bytes(), X25519_WRAP_ALGORITHM.as_bytes())?; - let mut blob = Vec::with_capacity(PK_LEN + sealed.len()); - blob.extend_from_slice(ephemeral_pk.as_bytes()); - blob.extend_from_slice(&sealed); - Ok(WrappedDek { - algorithm: X25519_WRAP_ALGORITHM.to_owned(), - blob, - }) - } - - fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result { - if wrapped.algorithm != X25519_WRAP_ALGORITHM { - return Err(CryptoError::UnsupportedAlgorithm(wrapped.algorithm.clone())); - } - let Some(secret) = &self.recipient_sk else { - // A wrap-only instance (e.g. the server) cannot unwrap — that is - // the zero-knowledge property, expressed as a decrypt failure. - return Err(CryptoError::DecryptFailed); - }; - if wrapped.blob.len() < PK_LEN { - return Err(CryptoError::CiphertextTooShort(wrapped.blob.len())); - } - let (pk_bytes, sealed) = wrapped.blob.split_at(PK_LEN); - let ephemeral_pk = X25519PublicKey::from_bytes(pk_bytes)?; - let shared = secret.0.diffie_hellman(&ephemeral_pk.0); - let wrapping_key = Self::derive_wrapping_key(&shared, &ephemeral_pk.0, &self.recipient_pk)?; - let raw = wrapping_key.open(sealed, X25519_WRAP_ALGORITHM.as_bytes())?; - Dek::from_bytes(&raw) - } -} - -#[cfg(test)] -#[path = "x25519_wrap_tests.rs"] -mod tests; diff --git a/loki-crypto/src/x25519_wrap_tests.rs b/loki-crypto/src/x25519_wrap_tests.rs deleted file mode 100644 index 43e54402..00000000 --- a/loki-crypto/src/x25519_wrap_tests.rs +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Tests for the Tier-2 X25519 DEK wrap. - -use super::*; - -#[test] -fn share_flow_round_trips() { - // A sharing client wraps the DEK to a member's public key… - let member_secret = X25519SecretKey::generate(); - let member_pk = member_secret.public_key(); - let dek = Dek::generate(); - let sharer = X25519KeyWrap::for_recipient(member_pk); - let wrapped = sharer.wrap(&dek).unwrap(); - assert_eq!(wrapped.algorithm, X25519_WRAP_ALGORITHM); - - // …and only the member's client can unwrap it. - let member = X25519KeyWrap::for_key_holder(member_secret); - let unwrapped = member.unwrap_dek(&wrapped).unwrap(); - assert_eq!(unwrapped.as_bytes(), dek.as_bytes()); -} - -#[test] -fn server_side_wrap_only_instance_cannot_unwrap() { - let member_secret = X25519SecretKey::generate(); - let sharer = X25519KeyWrap::for_recipient(member_secret.public_key()); - let wrapped = sharer.wrap(&Dek::generate()).unwrap(); - // The zero-knowledge property: without the secret key, unwrap fails. - assert!(matches!( - sharer.unwrap_dek(&wrapped), - Err(CryptoError::DecryptFailed) - )); -} - -#[test] -fn wrong_member_cannot_unwrap() { - let alice = X25519SecretKey::generate(); - let mallory = X25519SecretKey::generate(); - let wrapped = X25519KeyWrap::for_recipient(alice.public_key()) - .wrap(&Dek::generate()) - .unwrap(); - assert!( - X25519KeyWrap::for_key_holder(mallory) - .unwrap_dek(&wrapped) - .is_err() - ); -} - -#[test] -fn truncated_blob_is_typed_error() { - let member_secret = X25519SecretKey::generate(); - let member = X25519KeyWrap::for_key_holder(member_secret); - let wrapped = WrappedDek { - algorithm: X25519_WRAP_ALGORITHM.to_owned(), - blob: vec![0u8; 16], - }; - assert!(matches!( - member.unwrap_dek(&wrapped), - Err(CryptoError::CiphertextTooShort(16)) - )); -} - -#[test] -fn public_key_round_trips_through_bytes() { - let secret = X25519SecretKey::generate(); - let pk = secret.public_key(); - let restored = X25519PublicKey::from_bytes(pk.as_bytes()).unwrap(); - assert_eq!(restored, pk); - assert!(X25519PublicKey::from_bytes(&[0u8; 16]).is_err()); -} diff --git a/loki-headless/Cargo.toml b/loki-headless/Cargo.toml deleted file mode 100644 index 01b4ef2d..00000000 --- a/loki-headless/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "loki-headless" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" -description = "Headless Loki worker/CLI: GPU-free printing and file conversion (headless spec C021-C028)" -repository = "https://github.com/appthere/loki" - -[dependencies] -thiserror = { workspace = true } -clap = { version = "4", features = ["derive"] } -loki-convert = { workspace = true } -loki-print = { workspace = true } - -[dev-dependencies] -loki-doc-model = { path = "../loki-doc-model" } -loki-ooxml = { path = "../loki-ooxml" } diff --git a/loki-headless/src/cli.rs b/loki-headless/src/cli.rs deleted file mode 100644 index dfe63a85..00000000 --- a/loki-headless/src/cli.rs +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Argument definitions (headless spec §3). - -use std::path::PathBuf; - -use clap::{Args, Parser, Subcommand}; - -/// Headless Loki: print and convert office documents with no GPU. -#[derive(Debug, Parser)] -#[command(name = "loki-headless", version, about)] -pub struct Cli { - #[command(subcommand)] - pub command: Command, -} - -#[derive(Debug, Subcommand)] -pub enum Command { - /// Convert a document to another format. - Convert(ConvertArgs), - /// Render a document to PDF (alias for `convert` with a PDF target). - Render(ConvertArgs), - /// Render a document to PDF and dispatch it to an IPP printer. - Print(PrintArgs), - /// List the supported conversion pairs. - Formats, -} - -#[derive(Debug, Args)] -pub struct ConvertArgs { - /// Input file (format inferred from the extension unless --from is set). - #[arg(long = "in", value_name = "FILE")] - pub input: PathBuf, - /// Output file (format inferred from the extension unless --to is set). - #[arg(long = "out", value_name = "FILE")] - pub output: PathBuf, - /// Source format override (docx, odt, xlsx, ods, …). - #[arg(long)] - pub from: Option, - /// Target format override. - #[arg(long)] - pub to: Option, - /// PDF profile: pdf, pdf-x1a, pdf-x3, pdf-x4, or pdf-a2b. - #[arg(long)] - pub profile: Option, - /// Override the document title in the output metadata. - #[arg(long)] - pub title: Option, -} - -#[derive(Debug, Args)] -pub struct PrintArgs { - /// Input file (printed as-is if already PDF, otherwise rendered first). - #[arg(long = "in", value_name = "FILE")] - pub input: PathBuf, - /// Printer URI (`ipp://printer.local/ipp/print`). - #[arg(long)] - pub printer: String, - /// Number of copies. - #[arg(long, default_value_t = 1)] - pub copies: u32, - /// Double-sided (long-edge binding). - #[arg(long)] - pub duplex: bool, - /// With --duplex: flip on the short edge instead. - #[arg(long, requires = "duplex")] - pub short_edge: bool, - /// Media size: A3, A4, A5, letter, legal, or a raw IPP media keyword. - #[arg(long)] - pub media: Option, - /// Force monochrome output. - #[arg(long)] - pub mono: bool, - /// Job title shown in the printer queue (defaults to the file name). - #[arg(long)] - pub title: Option, - /// Submit without waiting for the printer to finish the job. - #[arg(long)] - pub no_wait: bool, - /// Seconds to wait for job completion before giving up. - #[arg(long, default_value_t = 300)] - pub timeout: u64, -} diff --git a/loki-headless/src/commands.rs b/loki-headless/src/commands.rs deleted file mode 100644 index 1b203ddb..00000000 --- a/loki-headless/src/commands.rs +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Command implementations over `loki-convert` and `loki-print`. - -use std::path::Path; -use std::time::Duration; - -use loki_convert::{ConvertError, ConvertOptions, Format, PdfProfile, convert}; -use loki_print::{ColorMode, Duplex, IppPrinter, PrintError, PrintOptions}; - -use crate::cli::{Cli, Command, ConvertArgs, PrintArgs}; - -/// CLI failures (all map to exit code 1). -#[derive(Debug, thiserror::Error)] -pub enum CliError { - #[error("cannot determine the format of {path}: {reason} (use --from/--to)")] - UnknownFileFormat { path: String, reason: &'static str }, - #[error(transparent)] - Convert(#[from] ConvertError), - #[error(transparent)] - Print(#[from] PrintError), - #[error("failed to read {path}: {source}")] - ReadInput { - path: String, - #[source] - source: std::io::Error, - }, - #[error("failed to write {path}: {source}")] - WriteOutput { - path: String, - #[source] - source: std::io::Error, - }, -} - -pub fn run(cli: Cli) -> Result<(), CliError> { - match cli.command { - Command::Convert(args) | Command::Render(args) => run_convert(&args), - Command::Print(args) => run_print(&args), - Command::Formats => { - for (source, target) in loki_convert::supported_pairs() { - println!("{source} -> {target}"); - } - Ok(()) - } - } -} - -fn format_of(path: &Path, explicit: Option<&str>) -> Result { - if let Some(name) = explicit { - return name.parse().map_err(CliError::Convert); - } - let ext = - path.extension() - .and_then(|e| e.to_str()) - .ok_or_else(|| CliError::UnknownFileFormat { - path: path.display().to_string(), - reason: "no file extension", - })?; - Format::from_extension(ext).ok_or_else(|| CliError::UnknownFileFormat { - path: path.display().to_string(), - reason: "unrecognised extension", - }) -} - -fn convert_options(profile: Option<&str>, title: Option<&str>) -> Result { - let pdf_profile = match profile { - Some(profile) => profile.parse::().map_err(CliError::Convert)?, - None => PdfProfile::Default, - }; - Ok(ConvertOptions { - pdf_profile, - title: title.map(str::to_owned), - }) -} - -fn run_convert(args: &ConvertArgs) -> Result<(), CliError> { - let source = format_of(&args.input, args.from.as_deref())?; - let target = format_of(&args.output, args.to.as_deref())?; - let options = convert_options(args.profile.as_deref(), args.title.as_deref())?; - let input = std::fs::read(&args.input).map_err(|e| CliError::ReadInput { - path: args.input.display().to_string(), - source: e, - })?; - let output = convert(source, &input, target, &options)?; - for warning in &output.warnings { - eprintln!("warning: {warning}"); - } - std::fs::write(&args.output, &output.bytes).map_err(|e| CliError::WriteOutput { - path: args.output.display().to_string(), - source: e, - })?; - println!( - "{} -> {} ({} bytes)", - args.input.display(), - args.output.display(), - output.bytes.len() - ); - Ok(()) -} - -fn run_print(args: &PrintArgs) -> Result<(), CliError> { - let source = format_of(&args.input, None)?; - let input = std::fs::read(&args.input).map_err(|e| CliError::ReadInput { - path: args.input.display().to_string(), - source: e, - })?; - // Already-rendered PDFs dispatch as-is; anything else renders first - // (render → PDF → IPP, ADR-C023). - let pdf = if source == Format::Pdf { - input - } else { - convert(source, &input, Format::Pdf, &ConvertOptions::default())?.bytes - }; - - let file_name = args - .input - .file_name() - .map(|n| n.to_string_lossy().into_owned()); - let options = PrintOptions { - copies: args.copies, - duplex: match (args.duplex, args.short_edge) { - (false, _) => Duplex::Simplex, - (true, false) => Duplex::LongEdge, - (true, true) => Duplex::ShortEdge, - }, - media: args.media.clone(), - color: if args.mono { - ColorMode::Monochrome - } else { - ColorMode::Auto - }, - job_title: args.title.clone().or(file_name), - }; - - let printer = IppPrinter::connect(&args.printer)?; - let job_id = printer.print_pdf(pdf, &options)?; - println!("submitted job {job_id} to {}", args.printer); - if !args.no_wait { - let state = printer.wait_for_completion(job_id, Duration::from_secs(args.timeout))?; - println!("job {job_id} finished: {state:?}"); - } - Ok(()) -} diff --git a/loki-headless/src/main.rs b/loki-headless/src/main.rs deleted file mode 100644 index c1c572dd..00000000 --- a/loki-headless/src/main.rs +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! `loki-headless` — GPU-free printing and file conversion (headless spec -//! §3). The standalone CLI is the "office printing" deployment: no server, -//! no Postgres, just files in, PDF/converted files or IPP print jobs out. -//! -//! Deliberately deferred (recorded here so the gaps are visible): -// TODO(headless-c025): apalis worker mode consuming loki-server's job queue -// (idempotency keys, retries, dead-letter) and the optional HTTP endpoint. -// TODO(headless-c021): vello_cpu rasterisation for Thumbnail jobs; the -// print/convert path needs no rasteriser (layout → PDF is already CPU-only). -// TODO(headless-c027): fail-closed font policy for print jobs — needs -// loki-layout to report substitutions instead of silently falling back. -// TODO(headless-c028): SEV-SNP/TDX attestation-gated key release for Tier-1 -// worker pools. - -mod cli; -mod commands; - -use clap::Parser; - -fn main() -> std::process::ExitCode { - let args = cli::Cli::parse(); - match commands::run(args) { - Ok(()) => std::process::ExitCode::SUCCESS, - Err(error) => { - eprintln!("error: {error}"); - std::process::ExitCode::FAILURE - } - } -} diff --git a/loki-headless/tests/cli.rs b/loki-headless/tests/cli.rs deleted file mode 100644 index 6a71454e..00000000 --- a/loki-headless/tests/cli.rs +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! End-to-end CLI tests: run the real binary on real files. - -use std::io::Cursor; -use std::process::Command; - -use loki_doc_model::content::block::Block; -use loki_doc_model::content::inline::Inline; -use loki_doc_model::document::Document; -use loki_doc_model::io::DocumentExport; -use loki_ooxml::DocxExport; - -fn bin() -> Command { - Command::new(env!("CARGO_BIN_EXE_loki-headless")) -} - -fn write_sample_docx(path: &std::path::Path) { - let mut doc = Document::new(); - if let Some(section) = doc.sections.first_mut() { - section.blocks.push(Block::Para(vec![Inline::Str( - "Printed from the office NUC by the print room.".into(), - )])); - } - let mut cursor = Cursor::new(Vec::new()); - DocxExport::export(&doc, &mut cursor, ()).expect("fixture"); - std::fs::write(path, cursor.into_inner()).expect("write fixture"); -} - -#[test] -fn convert_docx_to_pdf_via_cli() { - let dir = std::env::temp_dir().join(format!("loki-headless-test-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let input = dir.join("report.docx"); - let output = dir.join("report.pdf"); - write_sample_docx(&input); - - let status = bin() - .args(["convert", "--in"]) - .arg(&input) - .arg("--out") - .arg(&output) - .args(["--profile", "pdf-x4"]) - .status() - .expect("run CLI"); - assert!(status.success()); - let pdf = std::fs::read(&output).expect("output written"); - assert!(pdf.starts_with(b"%PDF-1.")); - std::fs::remove_dir_all(&dir).ok(); -} - -#[test] -fn unsupported_conversion_fails_with_typed_message() { - let dir = std::env::temp_dir().join(format!("loki-headless-gate-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let input = dir.join("deck.pptx"); - std::fs::write(&input, b"not really a pptx").unwrap(); - - let output = bin() - .args(["convert", "--in"]) - .arg(&input) - .arg("--out") - .arg(dir.join("deck.pdf")) - .output() - .expect("run CLI"); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("not supported"), - "expected a ConversionUnsupported message, got: {stderr}" - ); - std::fs::remove_dir_all(&dir).ok(); -} - -#[test] -fn formats_lists_the_matrix() { - let output = bin().arg("formats").output().expect("run CLI"); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("docx -> pdf")); - assert!(stdout.contains("ods -> xlsx")); - assert!(!stdout.contains("pptx"), "gated formats must not be listed"); -} diff --git a/loki-model/Cargo.toml b/loki-model/Cargo.toml deleted file mode 100644 index 8ace8d9a..00000000 --- a/loki-model/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "loki-model" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" -description = "Shared server-side identifiers, encryption tiers, and ACL types for the Loki suite (no server dependencies)" -repository = "https://github.com/appthere/loki" - -[dependencies] -thiserror = { workspace = true } -serde = { workspace = true } -uuid = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } diff --git a/loki-model/src/ids.rs b/loki-model/src/ids.rs deleted file mode 100644 index 7600d201..00000000 --- a/loki-model/src/ids.rs +++ /dev/null @@ -1,113 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Identifier newtypes for server-side entities. -//! -//! Each identifier wraps a UUID so that a `DocumentId` can never be passed -//! where a `WorkspaceId` is expected. All types serialize as plain UUID -//! strings. - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -macro_rules! id_newtype { - ($(#[$doc:meta])* $name:ident) => { - $(#[$doc])* - #[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, - )] - #[serde(transparent)] - pub struct $name(Uuid); - - impl $name { - /// Creates a fresh random identifier. - #[must_use] - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - - /// Wraps an existing UUID (e.g. one loaded from the database). - #[must_use] - pub const fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - - /// Returns the underlying UUID. - #[must_use] - pub const fn as_uuid(&self) -> Uuid { - self.0 - } - } - - impl Default for $name { - fn default() -> Self { - Self::new() - } - } - - impl std::fmt::Display for $name { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } - } - - impl std::str::FromStr for $name { - type Err = uuid::Error; - - fn from_str(s: &str) -> Result { - Ok(Self(Uuid::parse_str(s)?)) - } - } - - impl From for $name { - fn from(uuid: Uuid) -> Self { - Self(uuid) - } - } - - impl From<$name> for Uuid { - fn from(id: $name) -> Self { - id.0 - } - } - }; -} - -id_newtype!( - /// Identifies a workspace (the unit of membership and default policy). - WorkspaceId -); -id_newtype!( - /// Identifies a single collaborative document. - DocumentId -); -id_newtype!( - /// Identifies a user account (linked to an OIDC subject, never a password). - UserId -); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ids_round_trip_through_strings() { - let id = DocumentId::new(); - let parsed: DocumentId = id.to_string().parse().unwrap(); - assert_eq!(id, parsed); - } - - #[test] - fn ids_are_distinct_types() { - // Compile-time property: this test documents that the newtypes exist. - let doc = DocumentId::new(); - let ws = WorkspaceId::from_uuid(doc.as_uuid()); - assert_eq!(doc.as_uuid(), ws.as_uuid()); - } - - #[test] - fn serde_is_transparent() { - let id = UserId::new(); - let json = serde_json::to_string(&id).unwrap(); - assert_eq!(json, format!("\"{id}\"")); - } -} diff --git a/loki-model/src/lib.rs b/loki-model/src/lib.rs deleted file mode 100644 index ad468d00..00000000 --- a/loki-model/src/lib.rs +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Shared server-side domain types for the Loki suite. -//! -//! This crate holds the identifier newtypes, the tiered-confidentiality model -//! (ADR-C014), the RBAC role/action matrix (ADR-C017), and the data-residency -//! type (ADR-C019) shared between `loki-server-*` crates and future clients. -//! It deliberately has **no** server dependencies (no Axum, no SQLx) so that -//! client crates can depend on it too. -//! -//! See `docs/adr/LOKI_WEB_SERVER_SPEC.md`. - -#![forbid(unsafe_code)] - -mod ids; -mod residency; -mod role; -mod tier; - -pub use ids::{DocumentId, UserId, WorkspaceId}; -pub use residency::{Residency, ResidencyError}; -pub use role::{Action, Role, RoleParseError}; -pub use tier::{EncryptionTier, TierParseError}; diff --git a/loki-model/src/residency.rs b/loki-model/src/residency.rs deleted file mode 100644 index 45267fcb..00000000 --- a/loki-model/src/residency.rs +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Data-residency type (ADR-C018 / ADR-C019). -//! -//! Residency is recorded per workspace and per document. Managed (Hetzner) -//! deployments are pinned to the EU regions `fsn1`, `nbg1`, `hel1`; -//! self-hosted deployments declare a free-form `self-hosted: